from pathlib import Path from datetime import datetime, timezone import hashlib import hmac import json import os import secrets import subprocess import threading import time from uuid import UUID, uuid4 import psycopg from psycopg.types.json import Jsonb from fastapi import FastAPI, Header, Request from pydantic import BaseModel from fastapi.responses import FileResponse LOG_PATH = Path("activation.log") DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL") AGENT_POLL_INTERVAL_SECONDS = int(os.environ.get("TUXFLOTTE_AGENT_POLL_INTERVAL", "300")) ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} # Name der pro Organisation automatisch angelegten Standard-OE (siehe # Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte Geraete. STANDARD_OE_NAME = "Neue Geräte" # Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/ # build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode # geklontes Repo (nicht Teil von provisioning-server selbst), MINT_ISO_PATH # das einmalig von der offiziellen Downloadseite geholte Quellabbild. TUXFLOTTE_INSTALLER_DIR = os.environ.get("TUXFLOTTE_INSTALLER_DIR", "/root/tuxflotte-installer") # Absolut aufloesen: run_iso_build() startet build_customer_iso.sh mit # cwd=TUXFLOTTE_INSTALLER_DIR, ein relativer Pfad wuerde also dort statt # relativ zum eigenen Arbeitsverzeichnis (WorkingDirectory des Service) # gesucht - genau das hat den ersten Live-Test auf anode fehlschlagen lassen. MINT_ISO_PATH = str(Path(os.environ.get("TUXFLOTTE_MINT_ISO_PATH", "data/upstream/linuxmint-22.3-cinnamon-64bit.iso")).resolve()) # Aus demselben Grund wie MINT_ISO_PATH absolut aufloesen: der als $2 an # build_customer_iso.sh uebergebene Ausgabepfad wird von xorriso relativ # zum Subprocess-cwd (TUXFLOTTE_INSTALLER_DIR) interpretiert, nicht relativ # zum eigenen Arbeitsverzeichnis. ISO_BUILD_OUTPUT_DIR = Path(os.environ.get("TUXFLOTTE_ISO_BUILD_OUTPUT_DIR", "data/iso-builds")).resolve() ISO_BUILD_TIMEOUT_SECONDS = 1800 # ISO-Ablauf nach 7 Tagen (Migration 0021): ergaenzt die bestehende # "neuer Build ersetzt alten"-Aufraeumung um eine reine Zeitdimension. ISO_BUILD_EXPIRY_DAYS = 7 ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS = 60 * 60 app = FastAPI(title="Provisioning Activation Server", version="0.1.0") @app.on_event("startup") def start_iso_build_expiry_sweep() -> None: threading.Thread(target=run_iso_build_expiry_sweep, daemon=True).start() class ActivationRequest(BaseModel): activation_code: str device_fingerprint: str hostname: str | None = None machine_id: str | None = None client_version: str | None = None hardware: dict class ResolveRequest(BaseModel): device_id: str class AgentBootstrapRequest(BaseModel): device_id: str class AgentCheckinRequest(BaseModel): device_id: str class AgentReportEntry(BaseModel): merkmal: str event_type: str detail: dict | None = None class AgentReportRequest(BaseModel): device_id: str results: list[AgentReportEntry] class AuftragSelectRequest(BaseModel): optionen: dict = {} class CreateOrganizationRequest(BaseModel): name: str class UpdateOrganizationRequest(BaseModel): name: str kontakt_name: str | None = None adresse: str | None = None telefon: str | None = None kontakt_email: str | None = None notizen: str | None = None class CreateMerkmalRequest(BaseModel): key: str name: str description: str | None = None class UpdateMerkmalRequest(BaseModel): name: str description: str | None = None kategorie_id: str | None = None im_auftragskatalog: bool = False class CreateKategorieRequest(BaseModel): key: str name: str description: str | None = None sort_order: int = 0 class CreateWorkspaceRequest(BaseModel): key: str name: str description: str | None = None organization_id: str | None = None class CreateBereitstellungsvorlageRequest(BaseModel): workspace_id: str backend_id: str label: str is_default: bool = False disk_encryption: bool = False root_filesystem: str = "ext4" secure_boot_required: bool = False class CreateEnrollmentSessionRequest(BaseModel): bereitstellungsvorlage_id: str max_devices: int expires_at: str class RechargeEnrollmentSessionRequest(BaseModel): additional_devices: int = 0 expires_at: str | None = None class CreateIsoBuildRequest(BaseModel): activation_code: str wifi_ssid: str | None = None wifi_psk: str | None = None class CreateOrganisationseinheitRequest(BaseModel): name: str parent_id: str | None = None class UpdateOrganisationseinheitRequest(BaseModel): name: str parent_id: str | None = None class MoveDeviceOeRequest(BaseModel): oe_id: str class CreateGruppeRequest(BaseModel): name: str class UpdateGruppeRequest(BaseModel): name: str def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") def get_database_connection(): if not DATABASE_URL: raise RuntimeError("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.") return psycopg.connect(DATABASE_URL) def load_or_create_device( organization_id, device_fingerprint: str, hostname: str | None, ) -> tuple[dict, bool]: """ Lädt ein bekanntes Gerät oder legt es neu an. Rückgabe: (device, created) """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, organization_id, device_fingerprint, hostname, created_at, last_seen FROM devices WHERE device_fingerprint = %s """, (device_fingerprint,), ) row = cur.fetchone() if row is not None: if str(row[1]) != str(organization_id): raise RuntimeError( "Das Gerät ist bereits einer anderen Organization zugeordnet." ) cur.execute( """ UPDATE devices SET hostname = %s, last_seen = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, (hostname, row[0]), ) row = cur.fetchone() created = False else: device_id = uuid4() cur.execute( "SELECT id FROM organisationseinheiten WHERE organization_id = %s AND ist_standard", (organization_id,), ) standard_oe_row = cur.fetchone() standard_oe_id = standard_oe_row[0] if standard_oe_row is not None else None cur.execute( """ INSERT INTO devices ( id, organization_id, device_fingerprint, hostname, oe_id ) VALUES (%s, %s, %s, %s, %s) RETURNING id, organization_id, device_fingerprint, hostname, created_at, last_seen """, ( device_id, organization_id, device_fingerprint, hostname, standard_oe_id, ), ) row = cur.fetchone() created = True device = { "id": str(row[0]), "organization_id": str(row[1]), "device_fingerprint": row[2], "hostname": row[3], "created_at": row[4].isoformat(), "last_seen": row[5].isoformat(), } return device, created def store_hardware_snapshot( device_id: str, hardware: dict, ) -> str: snapshot_id = uuid4() identity = hardware.get("identity", {}) system = hardware.get("system", {}) mainboard = hardware.get("mainboard", {}) firmware = hardware.get("firmware", {}) security = hardware.get("security", {}) cpu = system.get("cpu", {}) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO hardware_snapshots ( id, device_id, architecture, manufacturer, product_name, product_version, system_uuid, system_serial, board_vendor, board_name, board_serial, bios_vendor, bios_version, boot_mode, secure_boot, tpm_version, cpu_model, cpu_logical_count, memory_bytes ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) """, ( snapshot_id, device_id, system["architecture"], system.get("manufacturer"), system.get("product_name"), system.get("product_version"), identity.get("system_uuid"), identity.get("system_serial"), mainboard.get("vendor"), mainboard.get("name"), identity.get("board_serial"), firmware.get("bios_vendor"), firmware.get("bios_version"), firmware.get("boot_mode"), firmware.get("secure_boot"), security.get("tpm_version"), cpu.get("model"), cpu.get("logical_count"), system.get("memory_bytes"), ), ) for interface in hardware.get("network_interfaces", []): cur.execute( """ INSERT INTO network_interfaces ( id, hardware_snapshot_id, name, type, mac_address ) VALUES (%s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, interface["name"], interface["type"], interface["mac"], ), ) for storage_device in hardware.get("storage_devices", []): cur.execute( """ INSERT INTO storage_devices ( id, hardware_snapshot_id, name, model, serial, size_bytes, transport ) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( uuid4(), snapshot_id, storage_device["name"], storage_device.get("model"), storage_device.get("serial"), storage_device["size_bytes"], storage_device.get("transport"), ), ) return str(snapshot_id) def find_activation_code(code: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT ac.organization_id, o.name FROM activation_codes ac JOIN organizations o ON o.id = ac.organization_id WHERE ac.code = %s AND ac.active """, (code,), ) row = cur.fetchone() if row is None: return None 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: cur.execute( """ SELECT bv.id, bv.label, bv.is_default, w.id, w.name, b.id, b.name, b.version FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.organization_id = %s ORDER BY bv.is_default DESC, bv.label """, (organization_id,), ) rows = cur.fetchall() return [ { "id": str(row[0]), "label": row[1], "workspace": {"id": str(row[3]), "name": row[4]}, "backend": {"id": str(row[5]), "name": row[6], "version": row[7]}, "is_default": row[2], } for row in rows ] def create_bereitstellungsvorlage( organization_id: str, workspace_id: str, backend_id: str, label: str, is_default: bool, disk_encryption: bool, root_filesystem: str, secure_boot_required: bool, ) -> dict: """ Bislang gab es dafuer keinen API-/UI-Weg - Bereitstellungsvorlagen kamen nur per Seed-Migration (0005/0011) fuer die "Default Lab"-Organisation zustande (siehe Phase 5 des Self-Service-ISO-Plans, dort als offene Luecke notiert). partitioning bekommt bewusst nur eine einfache "root_filesystem"-Wahl im single-scheme (siehe Migration 0015 fuer die volle Form) - ein custom-Schema mit extra_partitions ist Sache der direkten DB-Pflege, nicht dieses Formulars. """ vorlage_id = uuid4() partitioning = {"scheme": "single", "root_filesystem": root_filesystem} with get_database_connection() as conn: with conn.cursor() as cur: if is_default: # Der Unique-Index bereitstellungsvorlagen_one_default_per_org # erlaubt nur eine is_default=TRUE-Zeile je Organisation - # bestehenden Default zuerst explizit zuruecksetzen (gleiches # Zwei-Schritte-Muster wie Migration 0011). cur.execute( "UPDATE bereitstellungsvorlagen SET is_default = FALSE WHERE organization_id = %s AND is_default", (organization_id,), ) cur.execute( """ INSERT INTO bereitstellungsvorlagen (id, organization_id, workspace_id, backend_id, label, is_default, disk_encryption, partitioning, secure_boot_required) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( vorlage_id, organization_id, workspace_id, backend_id, label, is_default, disk_encryption, Jsonb(partitioning), secure_boot_required, ), ) return {"id": str(vorlage_id), "label": label, "is_default": is_default} def require_service_token(authorization: str | None) -> bool: """ Akzeptiert entweder den Betreiber-Admin-Token oder das separate Kundenplattform-Service-Token (siehe ADR-0011) - beide dürfen die Auftragskatalog-Endpoints aufrufen, Kompromittierung/Rotation des einen betrifft den anderen nicht. """ if authorization is None or not authorization.startswith("Bearer "): return False provided_token = authorization.removeprefix("Bearer ") if ADMIN_TOKEN and hmac.compare_digest(provided_token, ADMIN_TOKEN): return True if KUNDENPLATTFORM_TOKEN and hmac.compare_digest(provided_token, KUNDENPLATTFORM_TOKEN): return True return False def hash_agent_secret(secret: str) -> str: return hashlib.sha256(secret.encode("utf-8")).hexdigest() def verify_agent_secret(device_id: str, provided_secret: str) -> bool: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT agent_secret_hash FROM devices WHERE id = %s", (device_id,), ) row = cur.fetchone() if row is None or row[0] is None: return False return hmac.compare_digest(row[0], hash_agent_secret(provided_secret)) def fetch_assigned_blueprints(device_id: str): 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 [] workspace_id, backend_id = assignment_row cur.execute( """ SELECT m.key, bp.ansible_role FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s WHERE wm.workspace_id = %s """, (backend_id, workspace_id), ) return [ {"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall() ] def resolve_katalog_overrides(device_id: str) -> dict: """ Ermittelt für ein Gerät die OE-/Gruppen-Zwischenschicht des Auftragskatalogs (siehe Konfigurationsgruppen-Plan): pro Merkmal-ID ein (aktiv, quelle, quelle_id)-Tupel, falls die OE-Kette oder eine Gruppe eine explizite Meinung dazu hat - fehlt der Merkmal-Key im Ergebnis, hat weder OE-Kette noch eine Gruppe eine Meinung, der Aufrufer fällt dann auf den Workspace-Default zurück. quelle_id ist die konkrete oe_id/gruppe_id, die das Ergebnis geliefert hat (fürs Kundenportal, Sprung von der Herkunfts-Anzeige zur verantwortlichen OE/Gruppe) - bei mehreren zustimmenden Gruppen wird eine davon genannt, kein Anspruch auf vollständige Attribution aller Beitragenden. Reihenfolge: innerhalb der OE-Kette gewinnt die spezifischste OE (klassische Vererbung, von der eigenen OE aus nach oben zur Wurzel laufend - erste explizite Zeile pro Merkmal gewinnt). Das OE-Kettenergebnis zählt danach als eine weitere Stimme neben den Gruppen: sobald irgendeine zutreffende Stimme (OE-Kette oder eine Gruppe) "aktiv" sagt, gewinnt aktiv ("mehr gewinnt"). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT organization_id, oe_id FROM devices WHERE id = %s", (device_id,), ) device_row = cur.fetchone() if device_row is None: return {} organization_id, oe_id = device_row # OE-Kette von der eigenen OE aus zur Wurzel laufen (parent_id # in Python verfolgen statt rekursivem SQL - ein Baum von # wenigen Dutzend OEs pro Schule passt komplett in den Speicher, # siehe Plan). oe_chain = [] if oe_id is not None: cur.execute( "SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,), ) parent_by_id = {row[0]: row[1] for row in cur.fetchall()} current = oe_id while current is not None: oe_chain.append(current) current = parent_by_id.get(current) oe_aktiv_by_merkmal = {} if oe_chain: cur.execute( "SELECT oe_id, merkmal_id, aktiv FROM oe_merkmale WHERE oe_id = ANY(%s)", (oe_chain,), ) rows_by_oe = {} for row_oe_id, merkmal_id, aktiv in cur.fetchall(): rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = aktiv for oe_id_in_chain in oe_chain: for merkmal_id, aktiv in rows_by_oe.get(oe_id_in_chain, {}).items(): oe_aktiv_by_merkmal.setdefault(merkmal_id, (aktiv, oe_id_in_chain)) cur.execute( """ SELECT gm.merkmal_id, gm.gruppe_id, gm.aktiv FROM device_gruppen dg JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id WHERE dg.device_id = %s """, (device_id,), ) gruppen_stimmen_by_merkmal = {} for merkmal_id, gruppe_id, aktiv in cur.fetchall(): gruppen_stimmen_by_merkmal.setdefault(merkmal_id, []).append((gruppe_id, aktiv)) ergebnis = {} for merkmal_id in set(oe_aktiv_by_merkmal) | set(gruppen_stimmen_by_merkmal): oe_aktiv, oe_id_gewinner = oe_aktiv_by_merkmal.get(merkmal_id, (None, None)) gruppen_stimmen = gruppen_stimmen_by_merkmal.get(merkmal_id, []) aktive_gruppen = [gruppe_id for gruppe_id, aktiv in gruppen_stimmen if aktiv] inaktive_gruppen = [gruppe_id for gruppe_id, aktiv in gruppen_stimmen if not aktiv] if oe_aktiv is True or aktive_gruppen: quelle = "oe" if oe_aktiv is True else "gruppe" quelle_id = oe_id_gewinner if oe_aktiv is True else aktive_gruppen[0] ergebnis[merkmal_id] = (True, quelle, quelle_id) else: quelle = "oe" if oe_aktiv is False else "gruppe" quelle_id = oe_id_gewinner if oe_aktiv is False else inaktive_gruppen[0] ergebnis[merkmal_id] = (False, quelle, quelle_id) return ergebnis def fetch_auftragskatalog_state(device_id: str): """ Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für das Backend des Device (siehe ADR-0010, erweitert um OEs/Gruppen siehe Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl abgeleitet, keine Historie nötig. Auflösungsreihenfolge: Workspace-Default → OE-/Gruppen-Zwischenschicht (resolve_katalog_overrides) → device_merkmale-Override, der gewinnt immer, unabhängig davon was OE-Kette oder Gruppen sagen. """ 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 [] workspace_id, backend_id = assignment_row cur.execute( """ SELECT m.id, m.key, bp.ansible_role, dm.aktiv, (wm.merkmal_id IS NOT NULL) AS workspace_default, COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s 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 """, (backend_id, device_id, workspace_id), ) rows = cur.fetchall() overrides = resolve_katalog_overrides(device_id) ergebnis = [] for merkmal_id, key, ansible_role, device_override, workspace_default, optionen in rows: if device_override is not None: aktiv = device_override elif merkmal_id in overrides: aktiv, _quelle, _quelle_id = overrides[merkmal_id] else: aktiv = workspace_default ergebnis.append({ "merkmal": key, "ansible_role": ansible_role, "state": "present" if aktiv else "absent", "optionen": optionen, }) return ergebnis 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, Herkunft der Einstellung) 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.id, m.key, m.name, m.description, k.key, k.name, k.sort_order, dm.aktiv, (wm.merkmal_id IS NOT NULL) AS workspace_default, 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), ) rows = cur.fetchall() overrides = resolve_katalog_overrides(device_id) ergebnis = [] for ( merkmal_id, key, name, description, kat_key, kat_name, sort_order, device_override, workspace_default, optionen, ) in rows: if device_override is not None: aktiv, quelle, quelle_id = device_override, "geraet", None elif merkmal_id in overrides: aktiv, quelle, quelle_id = overrides[merkmal_id] else: aktiv, quelle, quelle_id = workspace_default, "workspace", None ergebnis.append({ "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", "quelle": quelle, "quelle_id": str(quelle_id) if quelle_id is not None else None, "optionen": optionen, }) return ergebnis 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 def fetch_organisationseinheiten(organization_id: str): """ Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum wird im Kundenportal-Template aus parent_id aufgebaut, nicht hier. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, parent_id, name, ist_standard FROM organisationseinheiten WHERE organization_id = %s ORDER BY name """, (organization_id,), ) return [ { "id": str(oe_id), "parent_id": str(parent_id) if parent_id is not None else None, "name": name, "ist_standard": ist_standard, } for oe_id, parent_id, name, ist_standard in cur.fetchall() ] def create_organisationseinheit(organization_id: str, name: str, parent_id: str | None): """ Legt eine neue OE an. Gibt None zurück, wenn parent_id gesetzt ist, aber nicht zur selben Organisation gehört (verhindert Cross-Org- Verschachtelung). """ oe_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: if parent_id is not None: cur.execute( "SELECT id FROM organisationseinheiten WHERE id = %s AND organization_id = %s", (parent_id, organization_id), ) if cur.fetchone() is None: return None cur.execute( """ INSERT INTO organisationseinheiten (id, organization_id, parent_id, name) VALUES (%s, %s, %s, %s) """, (oe_id, organization_id, parent_id, name), ) return {"id": str(oe_id), "parent_id": parent_id, "name": name, "ist_standard": False} def update_organisationseinheit(oe_id: str, name: str, parent_id: str | None): """ Benennt um und/oder verschiebt eine OE. Gibt None zurück, wenn die OE nicht existiert, parent_id nicht zur selben Organisation gehört, oder die Verschiebung einen Zyklus im Baum erzeugen würde (OE würde zu ihrem eigenen Nachfahren). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT organization_id FROM organisationseinheiten WHERE id = %s", (oe_id,), ) row = cur.fetchone() if row is None: return None organization_id = row[0] if parent_id is not None: if parent_id == oe_id: return None cur.execute( "SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,), ) parent_by_id = { str(r[0]): (str(r[1]) if r[1] is not None else None) for r in cur.fetchall() } if parent_id not in parent_by_id: return None current = parent_id while current is not None: if current == oe_id: return None current = parent_by_id.get(current) cur.execute( """ UPDATE organisationseinheiten SET name = %s, parent_id = %s WHERE id = %s RETURNING id, parent_id, name, ist_standard """, (name, parent_id, oe_id), ) updated = cur.fetchone() return { "id": str(updated[0]), "parent_id": str(updated[1]) if updated[1] is not None else None, "name": updated[2], "ist_standard": updated[3], } def delete_organisationseinheit(oe_id: str): """ Löscht eine OE. Gibt (True, None) bei Erfolg zurück, sonst (False, Fehlermeldung) - die Standard-OE, OEs mit Kind-OEs und OEs mit zugeordneten Geräten sind geschützt (freundliche Fehlermeldung statt dem rohen DB-RESTRICT-Fehler). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT ist_standard FROM organisationseinheiten WHERE id = %s", (oe_id,), ) row = cur.fetchone() if row is None: return False, "Die OE wurde nicht gefunden." if row[0]: return False, "Die Standard-OE 'Neue Geräte' kann nicht gelöscht werden." cur.execute("SELECT count(*) FROM organisationseinheiten WHERE parent_id = %s", (oe_id,)) if cur.fetchone()[0] > 0: return False, "Die OE hat noch untergeordnete OEs - diese zuerst verschieben oder löschen." cur.execute("SELECT count(*) FROM devices WHERE oe_id = %s", (oe_id,)) if cur.fetchone()[0] > 0: return False, "Der OE sind noch Geräte zugeordnet - diese zuerst verschieben." cur.execute("DELETE FROM organisationseinheiten WHERE id = %s", (oe_id,)) return True, None def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict): """Setzt die OE-weite Auswahl eines katalogfähigen Merkmals (Upsert in oe_merkmale), analog zu set_auftrag_selection.""" 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 cur.execute( """ INSERT INTO oe_merkmale (oe_id, merkmal_id, aktiv, optionen) VALUES (%s, %s, %s, %s) ON CONFLICT (oe_id, merkmal_id) DO UPDATE SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen """, (oe_id, merkmal_row[0], aktiv, Jsonb(optionen)), ) return True def clear_oe_auswahl(oe_id: str, merkmal_key: str): """Entfernt eine explizite OE-Einstellung wieder komplett (fällt danach auf die nächste Schicht zurück, statt nur auf true/false zu wechseln).""" 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 cur.execute( "DELETE FROM oe_merkmale WHERE oe_id = %s AND merkmal_id = %s", (oe_id, merkmal_row[0]), ) return True def fetch_oe_merkmale_listing(oe_id: str): """ Für den OE-Merkmale-Editor im Kundenportal: alle katalogfähigen Merkmale mit dem aktuellen, expliziten Stand dieser OE - aktiv (True), inaktiv (False) oder nicht festgelegt (None, keine oe_merkmale-Zeile). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.key, m.name, m.description, k.key, k.name, k.sort_order, om.aktiv FROM merkmale m LEFT JOIN kategorien k ON k.id = m.kategorie_id LEFT JOIN oe_merkmale om ON om.merkmal_id = m.id AND om.oe_id = %s WHERE m.im_auftragskatalog ORDER BY k.sort_order NULLS LAST, m.name """, (oe_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 ), "aktiv": aktiv, } for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall() ] def move_device_to_oe(device_id: str, oe_id: str): """ Verschiebt ein Gerät in eine andere OE derselben Organisation. Gibt False zurück, wenn Gerät/OE nicht existieren oder zu unterschiedlichen Organisationen gehören. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET oe_id = %s WHERE id = %s AND organization_id = (SELECT organization_id FROM organisationseinheiten WHERE id = %s) RETURNING id """, (oe_id, device_id, oe_id), ) return cur.fetchone() is not None def fetch_gruppen(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT id, name FROM gruppen WHERE organization_id = %s ORDER BY name", (organization_id,), ) return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()] def create_gruppe(organization_id: str, name: str): gruppe_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO gruppen (id, organization_id, name) VALUES (%s, %s, %s)", (gruppe_id, organization_id, name), ) return {"id": str(gruppe_id), "name": name} def update_gruppe(gruppe_id: str, name: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "UPDATE gruppen SET name = %s WHERE id = %s RETURNING id, name", (name, gruppe_id), ) row = cur.fetchone() if row is None: return None return {"id": str(row[0]), "name": row[1]} def delete_gruppe(gruppe_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute("DELETE FROM gruppen WHERE id = %s RETURNING id", (gruppe_id,)) return cur.fetchone() is not None def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen: dict): 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 cur.execute( """ INSERT INTO gruppen_merkmale (gruppe_id, merkmal_id, aktiv, optionen) VALUES (%s, %s, %s, %s) ON CONFLICT (gruppe_id, merkmal_id) DO UPDATE SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen """, (gruppe_id, merkmal_row[0], aktiv, Jsonb(optionen)), ) return True def clear_gruppe_auswahl(gruppe_id: str, merkmal_key: str): 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 cur.execute( "DELETE FROM gruppen_merkmale WHERE gruppe_id = %s AND merkmal_id = %s", (gruppe_id, merkmal_row[0]), ) return True def fetch_gruppe_merkmale_listing(gruppe_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.key, m.name, m.description, k.key, k.name, k.sort_order, gm.aktiv FROM merkmale m LEFT JOIN kategorien k ON k.id = m.kategorie_id LEFT JOIN gruppen_merkmale gm ON gm.merkmal_id = m.id AND gm.gruppe_id = %s WHERE m.im_auftragskatalog ORDER BY k.sort_order NULLS LAST, m.name """, (gruppe_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 ), "aktiv": aktiv, } for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall() ] def add_device_to_gruppe(device_id: str, gruppe_id: str): """ Fügt ein Gerät einer Gruppe hinzu (idempotent). Gibt False zurück, wenn Gerät und Gruppe nicht zur selben Organisation gehören. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.organization_id, g.organization_id FROM devices d, gruppen g WHERE d.id = %s AND g.id = %s """, (device_id, gruppe_id), ) row = cur.fetchone() if row is None or row[0] != row[1]: return False cur.execute( """ INSERT INTO device_gruppen (device_id, gruppe_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (device_id, gruppe_id), ) return True def remove_device_from_gruppe(device_id: str, gruppe_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "DELETE FROM device_gruppen WHERE device_id = %s AND gruppe_id = %s", (device_id, gruppe_id), ) return True def fetch_device_gruppen(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT g.id, g.name FROM device_gruppen dg JOIN gruppen g ON g.id = dg.gruppe_id WHERE dg.device_id = %s ORDER BY g.name """, (device_id,), ) return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()] def fetch_devices_for_organization(organization_id: str): """ Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011): alle Geräte einer Organisation, unabhängig von Bereitstellungsvorlagen- Zuweisung. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, d.deprovisioned_at, d.oe_id, oe.name FROM devices d LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id WHERE d.organization_id = %s AND d.archived_at IS NULL ORDER BY d.hostname NULLS LAST, d.created_at """, (organization_id,), ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "agent_last_checkin": ( last_checkin.isoformat() if last_checkin is not None else None ), "deprovisioned_at": ( deprovisioned_at.isoformat() if deprovisioned_at is not None else None ), "oe_id": str(oe_id) if oe_id is not None else None, "oe_name": oe_name, } for device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name in cur.fetchall() ] def fetch_all_devices(): """ Organisationsübergreifende Geräteliste für den Kundenplattform-Admin- Bereich (Verwaltung -> Geräte) - Pendant zu fetch_devices_for_organization, ohne Org-Filter, mit Organisationsname gejoint. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, d.organization_id, o.name, d.deprovisioned_at, d.oe_id, oe.name FROM devices d JOIN organizations o ON o.id = d.organization_id LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id WHERE d.archived_at IS NULL ORDER BY o.name, d.hostname NULLS LAST, d.created_at """ ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "agent_last_checkin": ( last_checkin.isoformat() if last_checkin is not None else None ), "organization_id": str(organization_id), "organization_name": organization_name, "deprovisioned_at": ( deprovisioned_at.isoformat() if deprovisioned_at is not None else None ), "oe_id": str(oe_id) if oe_id is not None else None, "oe_name": oe_name, } for ( device_id, hostname, fingerprint, last_checkin, organization_id, organization_name, deprovisioned_at, oe_id, oe_name, ) in cur.fetchall() ] def fetch_latest_hardware_snapshot(device_id: str): """ Für die Kundenportal-Aktion "Hardware-Informationen": neuester hardware_snapshots-Eintrag des Geräts (mehrere Snapshots über die Zeit sind möglich, siehe store_hardware_snapshot() - hier interessiert nur der aktuelle Stand) plus dessen Netzwerk-Interfaces und Storage-Devices. None, falls das Gerät noch nie aktiviert wurde und somit noch keinen Snapshot hat. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, collected_at, architecture, manufacturer, product_name, product_version, system_uuid, system_serial, board_vendor, board_name, board_serial, bios_vendor, bios_version, boot_mode, secure_boot, tpm_version, cpu_model, cpu_logical_count, memory_bytes FROM hardware_snapshots WHERE device_id = %s ORDER BY collected_at DESC LIMIT 1 """, (device_id,), ) row = cur.fetchone() if row is None: return None ( snapshot_id, collected_at, architecture, manufacturer, product_name, product_version, system_uuid, system_serial, board_vendor, board_name, board_serial, bios_vendor, bios_version, boot_mode, secure_boot, tpm_version, cpu_model, cpu_logical_count, memory_bytes, ) = row cur.execute( "SELECT name, type, mac_address FROM network_interfaces WHERE hardware_snapshot_id = %s ORDER BY name", (snapshot_id,), ) network_interfaces = [ {"name": name, "type": type_, "mac_address": mac_address} for name, type_, mac_address in cur.fetchall() ] cur.execute( "SELECT name, model, serial, size_bytes, transport FROM storage_devices WHERE hardware_snapshot_id = %s ORDER BY name", (snapshot_id,), ) storage_devices = [ { "name": name, "model": model, "serial": serial, "size_bytes": size_bytes, "transport": transport, } for name, model, serial, size_bytes, transport in cur.fetchall() ] return { "collected_at": collected_at.isoformat(), "architecture": architecture, "manufacturer": manufacturer, "product_name": product_name, "product_version": product_version, "system_uuid": system_uuid, "system_serial": system_serial, "board_vendor": board_vendor, "board_name": board_name, "board_serial": board_serial, "bios_vendor": bios_vendor, "bios_version": bios_version, "boot_mode": boot_mode, "secure_boot": secure_boot, "tpm_version": tpm_version, "cpu_model": cpu_model, "cpu_logical_count": cpu_logical_count, "memory_bytes": memory_bytes, "network_interfaces": network_interfaces, "storage_devices": storage_devices, } def fetch_device_events(device_id: str): """ Für die Kundenportal-Aktion "Installations-/Aktions-Log": alle bisher vom Agenten/Portal gemeldeten device_merkmal_events des Geräts, neueste zuerst. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT e.occurred_at, m.key, m.name, e.event_type, e.detail FROM device_merkmal_events e JOIN merkmale m ON m.id = e.merkmal_id WHERE e.device_id = %s ORDER BY e.occurred_at DESC """, (device_id,), ) return [ { "occurred_at": occurred_at.isoformat(), "merkmal": merkmal_key, "merkmal_name": merkmal_name, "event_type": event_type, "detail": detail, } for occurred_at, merkmal_key, merkmal_name, event_type, detail in cur.fetchall() ] def deprovision_device(device_id: str) -> bool: """ Serverseitiger Entzug (siehe Kundenportal-Geräteaktionen-Plan): macht das aktuelle Agent-Secret ungültig, ohne den Geräte-Datensatz oder dessen Historie anzutasten. Der Agent auf dem Gerät selbst wird nicht aktiv kontaktiert - er bekommt beim nächsten Check-in schlicht 401 und bleibt bis zur Reprovisionierung untätig liegen (siehe agent.py Self-Heal). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET agent_secret_hash = NULL, agent_secret_issued_at = NULL, deprovisioned_at = CURRENT_TIMESTAMP WHERE id = %s AND archived_at IS NULL RETURNING id """, (device_id,), ) return cur.fetchone() is not None def reprovision_device(device_id: str) -> bool: """ Hebt die Deprovisionierung auf. Erzeugt bewusst kein neues Agent-Secret - das holt sich der Agent beim nächsten Poll-Zyklus selbst über den bestehenden /api/v1/agent/bootstrap-Endpunkt (401 auf checkin loest im Agenten einen Re-Bootstrap aus, siehe agent.py). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET deprovisioned_at = NULL WHERE id = %s AND archived_at IS NULL RETURNING id """, (device_id,), ) return cur.fetchone() is not None def archive_device(device_id: str) -> bool: """ Kundenportal-Aktion "Gerät löschen": Soft Delete. Das Gerät verschwindet aus fetch_devices_for_organization()/fetch_all_devices(), Datensatz und Historie (hardware_snapshots, device_merkmal_events) bleiben in der DB erhalten. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET archived_at = CURRENT_TIMESTAMP WHERE id = %s AND archived_at IS NULL RETURNING id """, (device_id,), ) return cur.fetchone() is not None ORGANIZATION_KONTAKTFELDER = ("kontakt_name", "adresse", "telefon", "kontakt_email", "notizen") def fetch_organizations(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen FROM organizations ORDER BY name """ ) return [ { "id": str(org_id), "name": name, "created_at": created_at.isoformat(), "kontakt_name": kontakt_name, "adresse": adresse, "telefon": telefon, "kontakt_email": kontakt_email, "notizen": notizen, } for ( org_id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen, ) in cur.fetchall() ] def fetch_organization(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, name, created_at, kontakt_name, adresse, telefon, kontakt_email, notizen FROM organizations WHERE id = %s """, (organization_id,), ) row = cur.fetchone() if row is None: return None return { "id": str(row[0]), "name": row[1], "created_at": row[2].isoformat(), "kontakt_name": row[3], "adresse": row[4], "telefon": row[5], "kontakt_email": row[6], "notizen": row[7], } def create_organization(name: str): organization_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO organizations (id, name) VALUES (%s, %s)", (organization_id, name), ) # Jede Organisation bekommt von Geburt an eine Standard-OE (siehe # Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte # Geraete, geschuetzt vor Loeschen (siehe delete_organisationseinheit). cur.execute( """ INSERT INTO organisationseinheiten (id, organization_id, parent_id, name, ist_standard) VALUES (%s, %s, NULL, %s, TRUE) """, (uuid4(), organization_id, STANDARD_OE_NAME), ) return {"id": str(organization_id), "name": name} def update_organization(organization_id: str, name: str, kontaktfelder: dict): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE organizations SET name = %s, kontakt_name = %s, adresse = %s, telefon = %s, kontakt_email = %s, notizen = %s WHERE id = %s RETURNING id, name """, ( name, kontaktfelder.get("kontakt_name"), kontaktfelder.get("adresse"), kontaktfelder.get("telefon"), kontaktfelder.get("kontakt_email"), kontaktfelder.get("notizen"), 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: cur.execute( """ SELECT code, active, created_at FROM activation_codes WHERE organization_id = %s ORDER BY created_at DESC """, (organization_id,), ) return [ {"code": code, "active": active, "created_at": created_at.isoformat()} for code, active, created_at in cur.fetchall() ] def create_activation_code(organization_id: str): code = "-".join( secrets.token_hex(3).upper()[i:i + 3] for i in range(0, 6, 3) ) with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO activation_codes (code, organization_id) VALUES (%s, %s)", (code, organization_id), ) return {"code": code, "active": True} def fetch_merkmale(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog, m.kategorie_id, k.name FROM merkmale m LEFT JOIN kategorien k ON k.id = m.kategorie_id ORDER BY m.key """ ) return [ { "id": str(mid), "key": key, "name": name, "description": description, "im_auftragskatalog": im_auftragskatalog, "kategorie_id": str(kategorie_id) if kategorie_id else None, "kategorie_name": kategorie_name, } for ( mid, key, name, description, im_auftragskatalog, kategorie_id, kategorie_name, ) in cur.fetchall() ] def create_merkmal(key: str, name: str, description: str | None): merkmal_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO merkmale (id, key, name, description) VALUES (%s, %s, %s, %s)", (merkmal_id, key, name, description), ) return {"id": str(merkmal_id), "key": key, "name": name} def update_merkmal(merkmal_id: str, name: str, description: str | None, kategorie_id: str | None, im_auftragskatalog: bool): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE merkmale SET name = %s, description = %s, kategorie_id = %s, im_auftragskatalog = %s WHERE id = %s RETURNING id """, (name, description, kategorie_id, im_auftragskatalog, merkmal_id), ) return cur.fetchone() is not None def fetch_kategorien(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "SELECT id, key, name, description, sort_order FROM kategorien ORDER BY sort_order, name" ) return [ { "id": str(kid), "key": key, "name": name, "description": description, "sort_order": sort_order, } for kid, key, name, description, sort_order in cur.fetchall() ] def create_kategorie(key: str, name: str, description: str | None, sort_order: int): kategorie_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO kategorien (id, key, name, description, sort_order) VALUES (%s, %s, %s, %s, %s) """, (kategorie_id, key, name, description, sort_order), ) return {"id": str(kategorie_id), "key": key, "name": name} def fetch_workspaces(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id ORDER BY o.name NULLS FIRST, w.name """ ) return [ { "id": str(wid), "key": key, "name": name, "description": description, "organization_id": str(organization_id) if organization_id else None, "organization_name": organization_name, } for ( wid, key, name, description, organization_id, organization_name, ) in cur.fetchall() ] def create_workspace(key: str, name: str, description: str | None, organization_id: str | None): workspace_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO workspaces (id, organization_id, key, name, description) VALUES (%s, %s, %s, %s, %s) """, (workspace_id, organization_id, key, name, description), ) return {"id": str(workspace_id), "key": key, "name": name} def fetch_workspace_detail(workspace_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id WHERE w.id = %s """, (workspace_id,), ) row = cur.fetchone() if row is None: return None cur.execute( """ SELECT m.id, m.key, m.name FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id WHERE wm.workspace_id = %s ORDER BY m.key """, (workspace_id,), ) merkmale = [ {"id": str(mid), "key": key, "name": name} for mid, key, name in cur.fetchall() ] return { "id": str(row[0]), "key": row[1], "name": row[2], "description": row[3], "organization_id": str(row[4]) if row[4] else None, "organization_name": row[5], "merkmale": merkmale, } def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO workspace_merkmale (workspace_id, merkmal_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (workspace_id, merkmal_id), ) def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s", (workspace_id, merkmal_id), ) def fetch_backends(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute("SELECT id, key, name, installer_type, version FROM backends ORDER BY key") return [ {"id": str(bid), "key": key, "name": name, "installer_type": installer_type, "version": version} for bid, key, name, installer_type, version in cur.fetchall() ] def fetch_blueprints(): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.key, b.key, bp.ansible_role FROM blueprints bp JOIN merkmale m ON m.id = bp.merkmal_id JOIN backends b ON b.id = bp.backend_id ORDER BY m.key, b.key """ ) return [ {"merkmal": merkmal_key, "backend": backend_key, "ansible_role": ansible_role} for merkmal_key, backend_key, ansible_role in cur.fetchall() ] def fetch_unassigned_devices(organization_id: str): """ Geräte einer Organisation ohne Bereitstellungsvorlagen-Zuweisung - die Kandidaten für die Neugerät-Bestätigung (ADR-0008) im Flows-Bereich. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT d.id, d.hostname, d.device_fingerprint, d.created_at FROM devices d LEFT JOIN assignments a ON a.device_id = d.id WHERE d.organization_id = %s AND a.id IS NULL ORDER BY d.created_at """, (organization_id,), ) return [ { "id": str(device_id), "hostname": hostname, "device_fingerprint": fingerprint, "created_at": created_at.isoformat(), } for device_id, hostname, fingerprint, created_at in cur.fetchall() ] def fetch_enrollment_sessions(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT es.id, bv.label, es.max_devices, es.devices_used, es.expires_at, es.revoked_at, es.created_at FROM enrollment_sessions es JOIN bereitstellungsvorlagen bv ON bv.id = es.bereitstellungsvorlage_id WHERE es.organization_id = %s ORDER BY es.created_at DESC """, (organization_id,), ) return [ { "id": str(sid), "bereitstellungsvorlage_label": label, "max_devices": max_devices, "devices_used": devices_used, "expires_at": expires_at.isoformat(), "revoked_at": revoked_at.isoformat() if revoked_at else None, "created_at": created_at.isoformat(), } for ( sid, label, max_devices, devices_used, expires_at, revoked_at, created_at, ) in cur.fetchall() ] def create_enrollment_session(organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str): session_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ INSERT INTO enrollment_sessions (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at) VALUES (%s, %s, %s, %s, %s) """, (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at), ) return {"id": str(session_id)} def revoke_enrollment_session(session_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE enrollment_sessions SET revoked_at = CURRENT_TIMESTAMP WHERE id = %s AND revoked_at IS NULL RETURNING id """, (session_id,), ) return cur.fetchone() is not None def recharge_enrollment_session(session_id: str, additional_devices: int, expires_at: str | None): """ Phase 5 (Kundenplattform-Selfservice): eine bereits ausgeteilte, personalisierte Kunden-ISO bettet die Enrollment-Session-Id fest als Aktivierungscode ein (siehe build_customer_iso.sh) - Kontingent/ Gueltigkeit dieser Session zu erhoehen macht die ISO weiter nutzbar, ohne dass eine neue gebaut/neu verteilt werden muss. additional_devices wird auf max_devices addiert (nicht ersetzt - "aufladen", nicht "neu setzen"), expires_at ersetzt den bisherigen Wert, wenn angegeben. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE enrollment_sessions SET max_devices = max_devices + %s, expires_at = COALESCE(%s, expires_at) WHERE id = %s AND revoked_at IS NULL RETURNING id """, (additional_devices, expires_at, session_id), ) return cur.fetchone() is not None def iso_build_row_to_dict(row) -> dict: (build_id, organization_id, status, output_filename, error_message, created_at, started_at, finished_at, expires_at) = row return { "id": str(build_id), "organization_id": str(organization_id), "status": status, "output_filename": output_filename, "error_message": error_message, "created_at": created_at.isoformat(), "started_at": started_at.isoformat() if started_at else None, "finished_at": finished_at.isoformat() if finished_at else None, "expires_at": expires_at.isoformat() if expires_at else None, } def fetch_iso_build(build_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, organization_id, status, output_filename, error_message, created_at, started_at, finished_at, expires_at FROM iso_builds WHERE id = %s """, (build_id,), ) row = cur.fetchone() return iso_build_row_to_dict(row) if row else None def fetch_iso_builds(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, organization_id, status, output_filename, error_message, created_at, started_at, finished_at, expires_at FROM iso_builds WHERE organization_id = %s ORDER BY created_at DESC """, (organization_id,), ) return [iso_build_row_to_dict(row) for row in cur.fetchall()] def create_iso_build(organization_id: str) -> dict: build_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "INSERT INTO iso_builds (id, organization_id) VALUES (%s, %s)", (build_id, organization_id), ) return {"id": str(build_id), "status": "pending"} def update_iso_build( build_id, status: str, output_filename: str | None = None, error_message: str | None = None, mark_started: bool = False, mark_finished: bool = False, ): # expires_at nur bei einem tatsaechlich erfolgreichen Abschluss setzen - # ein "failed"-Build hat nichts zum Downloaden, das ablaufen koennte # (dessen Zeile raeumt weiterhin ausschliesslich cleanup_old_iso_builds() # beim naechsten Bau derselben Organisation auf). setzt_expiry = mark_finished and status == "completed" with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( f""" UPDATE iso_builds SET status = %s, output_filename = COALESCE(%s, output_filename), error_message = %s {", started_at = CURRENT_TIMESTAMP" if mark_started else ""} {", finished_at = CURRENT_TIMESTAMP" if mark_finished else ""} {f", expires_at = CURRENT_TIMESTAMP + INTERVAL '{ISO_BUILD_EXPIRY_DAYS} days'" if setzt_expiry else ""} WHERE id = %s """, (status, output_filename, error_message, build_id), ) def cleanup_old_iso_builds(organization_id: str, keep_build_id) -> None: """ Loescht Ausgabedateien + Zeilen abgeschlossener frueherer Baeulaeufe derselben Organisation (Speicherplatz auf anode ist begrenzt) - eine neu erzeugte Kunden-ISO ersetzt die vorherige, ein erneuter Download der alten ergibt fuer den Kunden ohnehin keinen Sinn mehr. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, output_filename FROM iso_builds WHERE organization_id = %s AND id != %s AND status IN ('completed', 'failed') """, (organization_id, keep_build_id), ) old_builds = cur.fetchall() for old_id, output_filename in old_builds: if output_filename: (ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True) cur.execute( """ DELETE FROM iso_builds WHERE organization_id = %s AND id != %s AND status IN ('completed', 'failed') """, (organization_id, keep_build_id), ) def cleanup_expired_iso_builds() -> int: """ Zeitbasierte Ergaenzung zu cleanup_old_iso_builds(): loescht Datei + Zeile jedes abgeschlossenen Builds, dessen expires_at in der Vergangenheit liegt - unabhaengig davon, ob die Organisation je erneut baut (die bisherige Logik greift nur beim naechsten Bau derselben Org). Laeuft periodisch ueber run_iso_build_expiry_sweep(). Gibt die Anzahl geloeschter Builds zurueck (fuer Logging/Tests). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT id, output_filename FROM iso_builds WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP """ ) abgelaufene_builds = cur.fetchall() for build_id, output_filename in abgelaufene_builds: if output_filename: (ISO_BUILD_OUTPUT_DIR / output_filename).unlink(missing_ok=True) cur.execute( """ DELETE FROM iso_builds WHERE status = 'completed' AND expires_at IS NOT NULL AND expires_at < CURRENT_TIMESTAMP """ ) return len(abgelaufene_builds) def run_iso_build_expiry_sweep() -> None: """ Endlosschleife in einem Daemon-Thread (siehe on_startup unten) - stuendlich abgelaufene ISO-Builds raeumen. Ein Fehler in einem Durchlauf (z.B. kurzzeitig nicht erreichbare DB) darf die Schleife nicht beenden, sonst liefe der Sweep fuer den Rest der Prozesslaufzeit gar nicht mehr. """ while True: time.sleep(ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS) try: anzahl = cleanup_expired_iso_builds() if anzahl: print(f"[iso-build-expiry] {anzahl} abgelaufene(n) ISO-Build(s) geloescht.") except Exception as exc: print(f"[iso-build-expiry] Sweep-Fehler (naechster Versuch in {ISO_BUILD_EXPIRY_SWEEP_INTERVAL_SECONDS}s): {exc}") def run_iso_build(build_id, organization_id: str, activation_code: str, wifi_ssid: str | None, wifi_psk: str | None) -> None: output_path = ISO_BUILD_OUTPUT_DIR / f"{build_id}.iso" update_iso_build(build_id, status="running", mark_started=True) try: result = subprocess.run( [ "bash", f"{TUXFLOTTE_INSTALLER_DIR}/scripts/build_customer_iso.sh", MINT_ISO_PATH, str(output_path), activation_code, wifi_ssid or "", wifi_psk or "", ], cwd=TUXFLOTTE_INSTALLER_DIR, capture_output=True, text=True, timeout=ISO_BUILD_TIMEOUT_SECONDS, ) except subprocess.TimeoutExpired: output_path.unlink(missing_ok=True) update_iso_build(build_id, status="failed", error_message="Zeitüberschreitung beim ISO-Bau.", mark_finished=True) return except Exception as exc: output_path.unlink(missing_ok=True) update_iso_build(build_id, status="failed", error_message=str(exc)[:4000], mark_finished=True) return if result.returncode != 0 or not output_path.is_file(): output_path.unlink(missing_ok=True) error_tail = (result.stderr or result.stdout or "unbekannter Fehler")[-4000:] update_iso_build(build_id, status="failed", error_message=error_tail, mark_finished=True) return update_iso_build(build_id, status="completed", output_filename=output_path.name, mark_finished=True) cleanup_old_iso_builds(organization_id, build_id) @app.get("/health") def health(): return { "status": "ok", "service": "provisioning-server", "version": "0.1.0" } @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 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, payload.hostname, ) hardware_snapshot_id = store_hardware_snapshot( device["id"], 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", "success": success, "remote_ip": request.client.host if request.client else None, "activation_code": payload.activation_code, "hostname": payload.hostname, "machine_id": payload.machine_id, "client_version": payload.client_version }) if not success: return { "success": False, "error": "invalid_activation_code", "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, "device": { "id": device["id"], "fingerprint": device["device_fingerprint"], "hostname": device["hostname"], "registration_status": ( "registered" if device_created else "existing" ), "hardware_snapshot_id": hardware_snapshot_id, }, "customer": { "id": organization_id, "organization_id": organization_id, "name": organization_name, }, "templates": templates, } @app.post("/api/v1/templates/{template_id}/resolve") def resolve_template(template_id: str, payload: ResolveRequest): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT bv.workspace_id, bv.backend_id, w.key, b.key, bv.disk_encryption, bv.partitioning, bv.secure_boot_required FROM bereitstellungsvorlagen bv JOIN workspaces w ON w.id = bv.workspace_id JOIN backends b ON b.id = bv.backend_id WHERE bv.id = %s """, (template_id,), ) template_row = cur.fetchone() if template_row is None: return { "success": False, "error": "template_not_found", "message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden." } ( workspace_id, backend_id, workspace_key, backend_key, disk_encryption, partitioning, secure_boot_required, ) = template_row cur.execute( """ SELECT m.key, bp.ansible_role FROM workspace_merkmale wm JOIN merkmale m ON m.id = wm.merkmal_id JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s WHERE wm.workspace_id = %s """, (backend_id, workspace_id), ) blueprints = [ {"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall() ] cur.execute( """ INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id) VALUES (%s, %s, %s) ON CONFLICT (device_id) DO UPDATE SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id """, (uuid4(), payload.device_id, template_id), ) return { "success": True, "runtime_blueprint": { "workspace_id": workspace_key, "backend_id": backend_key, "blueprints": blueprints, "installation_directives": { "disk_encryption": disk_encryption, "partitioning": partitioning, "secure_boot_required": secure_boot_required, }, }, } @app.get("/installers/fedora-workstation/ks.cfg") def get_fedora_kickstart(): return FileResponse( "installers/fedora-workstation/ks.cfg", media_type="text/plain" ) @app.post("/api/v1/agent/bootstrap") def agent_bootstrap(payload: AgentBootstrapRequest): agent_secret = secrets.token_urlsafe(32) # deprovisioned_at wird hier bewusst mitgeprueft: der Agent ruft diesen # Endpoint auch selbststaendig bei 401 auf checkin auf (siehe agent.py # Self-Heal). Ohne dieses Gate wuerde ein per Kundenportal # deprovisioniertes Geraet sich beim naechsten Poll-Zyklus einfach # selbst ein neues Secret holen und die De-Provisionierung waere # wirkungslos - das Gate macht reprovision_device() (Loeschen von # deprovisioned_at) zur tatsaechlichen Voraussetzung fuer den Erfolg # dieses Aufrufs. existing = None with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ UPDATE devices SET agent_secret_hash = %s, agent_secret_issued_at = %s WHERE id = %s AND deprovisioned_at IS NULL RETURNING id """, ( hash_agent_secret(agent_secret), datetime.now(timezone.utc), payload.device_id, ), ) updated = cur.fetchone() if updated is None: cur.execute( "SELECT deprovisioned_at FROM devices WHERE id = %s", (payload.device_id,), ) existing = cur.fetchone() if updated is None: if existing is not None and existing[0] is not None: return { "success": False, "error": "device_deprovisioned", "message": "Das Gerät ist deprovisioniert und muss zuerst über das Kundenportal reprovisioniert werden.", } return { "success": False, "error": "device_not_found", "message": "Das angegebene Gerät wurde nicht gefunden.", } return {"success": True, "agent_secret": agent_secret} @app.post("/api/v1/agent/checkin") def agent_checkin( payload: AgentCheckinRequest, authorization: str | None = Header(default=None), ): if authorization is None or not authorization.startswith("Bearer "): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Authorization-Header.", } provided_secret = authorization.removeprefix("Bearer ") if not verify_agent_secret(payload.device_id, provided_secret): return { "success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret.", } with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( "UPDATE devices SET agent_last_checkin = %s WHERE id = %s", (datetime.now(timezone.utc), payload.device_id), ) return { "success": True, "blueprints": fetch_assigned_blueprints(payload.device_id), "auftraege": fetch_auftragskatalog_state(payload.device_id), "ansible_repo": ANSIBLE_CONTENT_REPO, "poll_interval_seconds": AGENT_POLL_INTERVAL_SECONDS, } @app.post("/api/v1/agent/report") def agent_report( payload: AgentReportRequest, authorization: str | None = Header(default=None), ): if authorization is None or not authorization.startswith("Bearer "): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Authorization-Header.", } provided_secret = authorization.removeprefix("Bearer ") if not verify_agent_secret(payload.device_id, provided_secret): return { "success": False, "error": "unauthorized", "message": "Ungültiges Agent-Secret.", } for entry in payload.results: if entry.event_type not in AGENT_REPORTABLE_EVENT_TYPES: return { "success": False, "error": "invalid_event_type", "message": f"Kein vom Agenten meldbarer event_type: {entry.event_type}", } with get_database_connection() as conn: with conn.cursor() as cur: merkmal_ids = {} for entry in payload.results: if entry.merkmal in merkmal_ids: continue cur.execute( "SELECT id FROM merkmale WHERE key = %s", (entry.merkmal,), ) merkmal_row = cur.fetchone() if merkmal_row is None: return { "success": False, "error": "unknown_merkmal", "message": f"Unbekanntes Merkmal: {entry.merkmal}", } merkmal_ids[entry.merkmal] = merkmal_row[0] for entry in payload.results: cur.execute( """ INSERT INTO device_merkmal_events ( id, device_id, merkmal_id, event_type, detail ) VALUES (%s, %s, %s, %s, %s) """, ( uuid4(), payload.device_id, merkmal_ids[entry.merkmal], entry.event_type, Jsonb(entry.detail) if entry.detail is not None else None, ), ) return {"success": True} @app.get("/api/v1/organizations/{organization_id}/devices") def get_organization_devices( organization_id: str, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "devices": fetch_devices_for_organization(organization_id)} @app.get("/api/v1/devices") def list_all_devices(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "devices": fetch_all_devices()} @app.get("/api/v1/devices/{device_id}/hardware") def get_device_hardware(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } snapshot = fetch_latest_hardware_snapshot(device_id) if snapshot is None: return { "success": False, "error": "no_snapshot", "message": "Für dieses Gerät liegt noch kein Hardware-Snapshot vor.", } return {"success": True, "hardware": snapshot} @app.get("/api/v1/devices/{device_id}/events") def get_device_events(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "events": fetch_device_events(device_id)} @app.post("/api/v1/devices/{device_id}/deprovision") def deprovision(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } if not deprovision_device(device_id): return { "success": False, "error": "device_not_found", "message": "Das angegebene Gerät wurde nicht gefunden.", } return {"success": True} @app.post("/api/v1/devices/{device_id}/reprovision") def reprovision(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } if not reprovision_device(device_id): return { "success": False, "error": "device_not_found", "message": "Das angegebene Gerät wurde nicht gefunden.", } return {"success": True} @app.post("/api/v1/devices/{device_id}/archive") def archive(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } if not archive_device(device_id): return { "success": False, "error": "device_not_found", "message": "Das angegebene Gerät wurde nicht gefunden.", } return {"success": True} @app.get("/api/v1/organizations") def list_organizations(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "organizations": fetch_organizations()} @app.post("/api/v1/organizations") def create_organization_endpoint( payload: CreateOrganizationRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "organization": create_organization(payload.name)} @app.get("/api/v1/organizations/{organization_id}") def get_organization_endpoint(organization_id: str, 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 = fetch_organization(organization_id) if organization is None: return {"success": False, "error": "organization_not_found", "message": "Die Organisation wurde nicht gefunden."} return {"success": True, "organization": organization} @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.", } kontaktfelder = {feld: getattr(payload, feld) for feld in ORGANIZATION_KONTAKTFELDER} organization = update_organization(organization_id, payload.name, kontaktfelder) 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, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "activation_codes": fetch_activation_codes(organization_id)} @app.post("/api/v1/organizations/{organization_id}/activation-codes") def create_activation_code_endpoint( organization_id: str, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return { "success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token.", } return {"success": True, "activation_code": create_activation_code(organization_id)} @app.get("/api/v1/merkmale") def list_merkmale(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "merkmale": fetch_merkmale()} @app.post("/api/v1/merkmale") def create_merkmal_endpoint(payload: CreateMerkmalRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "merkmal": create_merkmal(payload.key, payload.name, payload.description)} @app.patch("/api/v1/merkmale/{merkmal_id}") def update_merkmal_endpoint( merkmal_id: str, payload: UpdateMerkmalRequest, authorization: str | None = Header(default=None) ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} updated = update_merkmal( merkmal_id, payload.name, payload.description, payload.kategorie_id, payload.im_auftragskatalog ) if not updated: return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."} return {"success": True} @app.get("/api/v1/kategorien") def list_kategorien(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "kategorien": fetch_kategorien()} @app.post("/api/v1/kategorien") def create_kategorie_endpoint(payload: CreateKategorieRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return { "success": True, "kategorie": create_kategorie(payload.key, payload.name, payload.description, payload.sort_order), } @app.get("/api/v1/workspaces") def list_workspaces(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "workspaces": fetch_workspaces()} @app.post("/api/v1/workspaces") def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return { "success": True, "workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id), } @app.get("/api/v1/workspaces/{workspace_id}") def get_workspace(workspace_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} workspace = fetch_workspace_detail(workspace_id) if workspace is None: return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} return {"success": True, "workspace": workspace} @app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}") def add_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} add_merkmal_to_workspace(workspace_id, merkmal_id) return {"success": True} @app.delete("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}") def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} remove_merkmal_from_workspace(workspace_id, merkmal_id) return {"success": True} @app.get("/api/v1/backends") def list_backends(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "backends": fetch_backends()} @app.get("/api/v1/blueprints") def list_blueprints(authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "blueprints": fetch_blueprints()} @app.get("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen") def list_bereitstellungsvorlagen(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "bereitstellungsvorlagen": fetch_templates(organization_id)} @app.post("/api/v1/organizations/{organization_id}/bereitstellungsvorlagen") def create_bereitstellungsvorlage_endpoint( organization_id: str, payload: CreateBereitstellungsvorlageRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not payload.label.strip(): return {"success": False, "error": "invalid_request", "message": "label darf nicht leer sein."} vorlage = create_bereitstellungsvorlage( organization_id, payload.workspace_id, payload.backend_id, payload.label, payload.is_default, payload.disk_encryption, payload.root_filesystem, payload.secure_boot_required, ) return {"success": True, "bereitstellungsvorlage": vorlage} @app.get("/api/v1/organizations/{organization_id}/devices/unassigned") def list_unassigned_devices(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "devices": fetch_unassigned_devices(organization_id)} @app.get("/api/v1/organizations/{organization_id}/enrollment-sessions") def list_enrollment_sessions(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "enrollment_sessions": fetch_enrollment_sessions(organization_id)} @app.post("/api/v1/organizations/{organization_id}/enrollment-sessions") def create_enrollment_session_endpoint( organization_id: str, payload: CreateEnrollmentSessionRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} session = create_enrollment_session( organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at ) return {"success": True, "enrollment_session": session} @app.post("/api/v1/enrollment-sessions/{session_id}/revoke") def revoke_enrollment_session_endpoint(session_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} revoked = revoke_enrollment_session(session_id) if not revoked: return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist bereits widerrufen."} return {"success": True} @app.patch("/api/v1/enrollment-sessions/{session_id}") def recharge_enrollment_session_endpoint( session_id: str, payload: RechargeEnrollmentSessionRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if payload.additional_devices < 0: return {"success": False, "error": "invalid_request", "message": "additional_devices darf nicht negativ sein."} recharged = recharge_enrollment_session(session_id, payload.additional_devices, payload.expires_at) if not recharged: return {"success": False, "error": "not_found", "message": "Enrollment Session wurde nicht gefunden oder ist widerrufen."} return {"success": True} @app.get("/api/v1/organizations/{organization_id}/iso-builds") def list_iso_builds(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "iso_builds": fetch_iso_builds(organization_id)} @app.post("/api/v1/organizations/{organization_id}/iso-builds") def create_iso_build_endpoint( organization_id: str, payload: CreateIsoBuildRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not payload.activation_code.strip(): return {"success": False, "error": "invalid_request", "message": "activation_code darf nicht leer sein."} ISO_BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) iso_build = create_iso_build(organization_id) thread = threading.Thread( target=run_iso_build, args=(UUID(iso_build["id"]), organization_id, payload.activation_code, payload.wifi_ssid, payload.wifi_psk), daemon=True, ) thread.start() return {"success": True, "iso_build": iso_build} @app.get("/api/v1/iso-builds/{build_id}") def get_iso_build_endpoint(build_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} iso_build = fetch_iso_build(build_id) if iso_build is None: return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."} return {"success": True, "iso_build": iso_build} @app.get("/api/v1/iso-builds/{build_id}/download") def download_iso_build(build_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} iso_build = fetch_iso_build(build_id) if iso_build is None: return {"success": False, "error": "not_found", "message": "ISO-Build wurde nicht gefunden."} if iso_build["status"] != "completed" or not iso_build["output_filename"]: return {"success": False, "error": "not_ready", "message": "Der ISO-Build ist noch nicht abgeschlossen."} output_path = ISO_BUILD_OUTPUT_DIR / iso_build["output_filename"] if not output_path.is_file(): return {"success": False, "error": "file_missing", "message": "Die ISO-Datei wurde nicht gefunden."} return FileResponse( output_path, media_type="application/octet-stream", filename="tuxflotte-installationsmedium.iso", ) @app.get("/api/v1/devices/{device_id}/auftragskatalog") def get_device_auftragskatalog( device_id: str, authorization: str | None = Header(default=None), ): if not require_service_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_service_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_service_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} # --- Organisationseinheiten (OEs) --- @app.get("/api/v1/organizations/{organization_id}/organisationseinheiten") def list_organisationseinheiten(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "organisationseinheiten": fetch_organisationseinheiten(organization_id)} @app.post("/api/v1/organizations/{organization_id}/organisationseinheiten") def create_organisationseinheit_endpoint( organization_id: str, payload: CreateOrganisationseinheitRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} oe = create_organisationseinheit(organization_id, payload.name, payload.parent_id) if oe is None: return { "success": False, "error": "invalid_parent", "message": "Die übergeordnete OE wurde nicht gefunden oder gehört zu einer anderen Organisation.", } return {"success": True, "organisationseinheit": oe} @app.patch("/api/v1/organisationseinheiten/{oe_id}") def update_organisationseinheit_endpoint( oe_id: str, payload: UpdateOrganisationseinheitRequest, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} oe = update_organisationseinheit(oe_id, payload.name, payload.parent_id) if oe is None: return { "success": False, "error": "invalid_move", "message": "OE nicht gefunden, übergeordnete OE gehört zu einer anderen Organisation, oder das würde einen Zyklus erzeugen.", } return {"success": True, "organisationseinheit": oe} @app.delete("/api/v1/organisationseinheiten/{oe_id}") def delete_organisationseinheit_endpoint(oe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} deleted, fehler = delete_organisationseinheit(oe_id) if not deleted: return {"success": False, "error": "delete_blocked", "message": fehler} return {"success": True} @app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select") def select_oe_merkmal( oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None), ): # Kein Payload-Body wie beim geräteweisen select (siehe # AuftragSelectRequest/OPTIONEN_FELDER in kundenplattform) - die # OE-/Gruppen-Editoren bieten keine Merkmal-Optionen an, nur # aktiv/inaktiv/nicht festgelegt. if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not set_oe_auswahl(oe_id, merkmal_key, True, {}): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect") def deselect_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not set_oe_auswahl(oe_id, merkmal_key, False, {}): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset") def unset_oe_merkmal(oe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not clear_oe_auswahl(oe_id, merkmal_key): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.get("/api/v1/organisationseinheiten/{oe_id}/merkmale") def get_oe_merkmale(oe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "merkmale": fetch_oe_merkmale_listing(oe_id)} @app.post("/api/v1/devices/{device_id}/oe") def move_device_to_oe_endpoint(device_id: str, payload: MoveDeviceOeRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not move_device_to_oe(device_id, payload.oe_id): return { "success": False, "error": "invalid_move", "message": "Gerät oder OE nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.", } return {"success": True} # --- Gruppen --- @app.get("/api/v1/organizations/{organization_id}/gruppen") def list_gruppen(organization_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "gruppen": fetch_gruppen(organization_id)} @app.post("/api/v1/organizations/{organization_id}/gruppen") def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "gruppe": create_gruppe(organization_id, payload.name)} @app.patch("/api/v1/gruppen/{gruppe_id}") def update_gruppe_endpoint(gruppe_id: str, payload: UpdateGruppeRequest, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} gruppe = update_gruppe(gruppe_id, payload.name) if gruppe is None: return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."} return {"success": True, "gruppe": gruppe} @app.delete("/api/v1/gruppen/{gruppe_id}") def delete_gruppe_endpoint(gruppe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not delete_gruppe(gruppe_id): return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."} return {"success": True} @app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select") def select_gruppe_merkmal( gruppe_id: str, merkmal_key: str, authorization: str | None = Header(default=None), ): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not set_gruppe_auswahl(gruppe_id, merkmal_key, True, {}): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect") def deselect_gruppe_merkmal(gruppe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not set_gruppe_auswahl(gruppe_id, merkmal_key, False, {}): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset") def unset_gruppe_merkmal(gruppe_id: str, merkmal_key: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not clear_gruppe_auswahl(gruppe_id, merkmal_key): return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} return {"success": True} @app.get("/api/v1/gruppen/{gruppe_id}/merkmale") def get_gruppe_merkmale(gruppe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "merkmale": fetch_gruppe_merkmale_listing(gruppe_id)} @app.get("/api/v1/devices/{device_id}/gruppen") def get_device_gruppen(device_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} return {"success": True, "gruppen": fetch_device_gruppen(device_id)} @app.post("/api/v1/devices/{device_id}/gruppen/{gruppe_id}") def add_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} if not add_device_to_gruppe(device_id, gruppe_id): return { "success": False, "error": "invalid_membership", "message": "Gerät oder Gruppe nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.", } return {"success": True} @app.delete("/api/v1/devices/{device_id}/gruppen/{gruppe_id}") def remove_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} remove_device_from_gruppe(device_id, gruppe_id) return {"success": True}