diff --git a/app.py b/app.py index 4e71f6e..3155335 100644 --- a/app.py +++ b/app.py @@ -394,6 +394,15 @@ class CreateGruppeRequest(BaseModel): name: str +class OrgRessourceRequest(BaseModel): + typ: str + name: str + daten: dict = {} + ganze_organisation: bool = True + oe_ids: list[str] = [] + gruppe_ids: list[str] = [] + + class UpdateGruppeRequest(BaseModel): name: str @@ -1576,6 +1585,8 @@ def fetch_auftragskatalog_state(device_id: str): standardbrowser_row = cur.fetchone() standardbrowser = standardbrowser_row[0] if standardbrowser_row else None + ressourcen = fetch_geraete_ressourcen(cur, device_id) + overrides = resolve_katalog_overrides(device_id) ergebnis = [] @@ -1603,8 +1614,367 @@ def fetch_auftragskatalog_state(device_id: str): "optionen": {"browser": standardbrowser} if standardbrowser else {}, }) + # Org-Ressourcen (siehe ORG_RESSOURCEN_AUFTRAG): wie beim Standardbrowser + # immer mitschicken, auch als "absent", damit die Rolle nach dem Löschen + # der letzten Ressource aufräumt. Nicht freigeschaltet: gar nicht. + if ressourcen is not None: + ergebnis.append({ + "merkmal": ORG_RESSOURCEN_AUFTRAG, + "ansible_role": ORG_RESSOURCEN_AUFTRAG, + "state": "present" if ressourcen else "absent", + "optionen": {"ressourcen": ressourcen} if ressourcen else {}, + }) + return ergebnis +# Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md, +# Migration 0062): lokale Dienste einer Organisation, die App-Rollen zum +# Vorbefüllen nutzen. Der Check-in schickt die für ein Gerät geltenden +# Ressourcen als eigenen Auftrag "org-ressourcen" mit (wie den +# Standardbrowser: kein Katalog-Merkmal, keine Ereigniszeile, der Agent +# reicht ihn ungesehen an Ansible weiter). App-Rollen lesen sie im selben +# Lauf aus tuxflotte_auftrag_optionen['org-ressourcen']. +ORG_RESSOURCEN_AUFTRAG = "org-ressourcen" + +# Freischaltung: solange noch keine App-Rolle die Ressourcen nutzt, gelten +# Auftrag und Kunden-Maske nur für Organisationen mit Ausrollstufe "vorab" +# (Testorganisationen). Sonst bekäme jedes Kundengerät einen zusätzlichen +# Ansible-Lauf für eine Rolle, die sein stabiler Stand noch nicht kennt, und +# Kunden eine Maske ohne Wirkung. Auf False stellen, sobald die erste +# nutzende Rolle in ansible-content-stabil freigegeben ist. +ORG_RESSOURCEN_NUR_VORAB = True + +# Feldtypen: "text", "url", "port", "auswahl" (mit "werte"), "liste" +# (kommagetrennt). Pflichtfelder mit "pflicht". Felder für Zugangsdaten gibt +# es absichtlich nicht - die Ressourcen landen unverschlüsselt in +# /etc/tuxflotte/org-ressourcen.json und sind für jeden Benutzer lesbar. +ORG_RESSOURCEN_TYPEN = { + "mail": { + "name": "E-Mail (IMAP/SMTP)", + "felder": [ + {"key": "mail_domain", "label": "Maildomäne (Teil nach dem @, z. B. schule.de)", "typ": "text", "pflicht": True}, + {"key": "imap_server", "label": "IMAP-Server", "typ": "text", "pflicht": True}, + {"key": "imap_port", "label": "IMAP-Port", "typ": "port", "standard": 993}, + {"key": "imap_verschluesselung", "label": "IMAP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"}, + {"key": "smtp_server", "label": "SMTP-Server", "typ": "text", "pflicht": True}, + {"key": "smtp_port", "label": "SMTP-Port", "typ": "port", "standard": 465}, + {"key": "smtp_verschluesselung", "label": "SMTP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"}, + {"key": "benutzername", "label": "Anmeldename", "typ": "auswahl", "werte": ["mailadresse", "lokalteil"], "standard": "mailadresse"}, + ], + }, + "nextcloud": { + "name": "Nextcloud", + "felder": [ + {"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True}, + ], + }, + "owncloud": { + "name": "ownCloud", + "felder": [ + {"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True}, + ], + }, + "webdav": { + "name": "WebDAV-Freigabe", + "felder": [ + {"key": "url", "label": "Adresse (z. B. https://server/remote.php/dav/files/)", "typ": "url", "pflicht": True}, + ], + }, + "smb": { + "name": "Windows-Freigabe (SMB/CIFS)", + "felder": [ + {"key": "server", "label": "Server (Name oder IP)", "typ": "text", "pflicht": True}, + {"key": "freigabe", "label": "Freigabename", "typ": "text", "pflicht": True}, + {"key": "domaene", "label": "Domäne/Arbeitsgruppe (optional)", "typ": "text"}, + ], + }, + "proxy": { + "name": "Web-Proxy", + "felder": [ + {"key": "server", "label": "Proxy-Server (Name oder IP)", "typ": "text", "pflicht": True}, + {"key": "port", "label": "Port", "typ": "port", "standard": 3128}, + {"key": "ausnahmen", "label": "Ohne Proxy (kommagetrennt, z. B. localhost,.schule.local)", "typ": "liste"}, + ], + }, +} + +# Name/Host: Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich (IPv6 in +# eckigen Klammern). Freigabename zusätzlich Leerzeichen und $. +_RESSOURCE_HOST_RE = re.compile(r"^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9._-]+)$") +_RESSOURCE_FREIGABE_RE = re.compile(r"^[A-Za-z0-9 ._$-]+$") + + +def pruefe_ressource(typ: str, daten: dict) -> tuple[dict | None, str | None]: + """Prüft die Felder einer Ressource gegen ORG_RESSOURCEN_TYPEN. Liefert + (bereinigte Daten, None) oder (None, Fehlermeldung). Unbekannte Felder + werden verworfen, damit auch keine Zugangsdaten hineinrutschen.""" + + definition = ORG_RESSOURCEN_TYPEN.get(typ) + if definition is None: + return None, f"Unbekannter Ressourcentyp: {typ}" + + ergebnis = {} + for feld in definition["felder"]: + roh = daten.get(feld["key"]) + wert = roh.strip() if isinstance(roh, str) else roh + if wert in (None, ""): + if feld.get("pflicht"): + return None, f"Bitte „{feld['label']}“ ausfüllen." + if "standard" in feld: + ergebnis[feld["key"]] = feld["standard"] + continue + + if feld["typ"] == "port": + try: + wert = int(wert) + except (TypeError, ValueError): + return None, f"„{feld['label']}“ muss eine Zahl sein." + if not 1 <= wert <= 65535: + return None, f"„{feld['label']}“ muss zwischen 1 und 65535 liegen." + elif feld["typ"] == "auswahl": + if wert not in feld["werte"]: + return None, f"Ungültiger Wert für „{feld['label']}“." + elif feld["typ"] == "url": + teile = urlparse(str(wert)) + if teile.scheme not in ("http", "https") or not teile.hostname: + return None, f"„{feld['label']}“ muss mit http:// oder https:// beginnen." + if teile.username or teile.password: + return None, "Zugangsdaten gehören nicht in die Adresse." + wert = str(wert).rstrip("/") + elif feld["typ"] == "liste": + eintraege = [e.strip() for e in str(wert).split(",") if e.strip()] + if any(not _RESSOURCE_HOST_RE.match(e.lstrip("*")) for e in eintraege): + return None, f"„{feld['label']}“ enthält ungültige Einträge." + wert = eintraege + else: + wert = str(wert) + muster = _RESSOURCE_FREIGABE_RE if feld["key"] == "freigabe" else _RESSOURCE_HOST_RE + if feld["key"] in ("mail_domain", "imap_server", "smtp_server", "server", "freigabe", "domaene") and not muster.match(wert): + return None, f"„{feld['label']}“ enthält ungültige Zeichen." + + ergebnis[feld["key"]] = wert + + return ergebnis, None + + +def _ist_uuid(wert) -> bool: + try: + UUID(str(wert)) + except ValueError: + return False + return True + + +def org_ressourcen_freigeschaltet(cur, organization_id) -> bool: + if not ORG_RESSOURCEN_NUR_VORAB: + return True + cur.execute("SELECT ausrollstufe FROM organizations WHERE id = %s", (organization_id,)) + row = cur.fetchone() + return bool(row) and row[0] == "vorab" + + +def _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids) -> str | None: + if oe_ids: + cur.execute( + "SELECT count(*) FROM organisationseinheiten WHERE organization_id = %s AND id::text = ANY(%s)", + (organization_id, oe_ids), + ) + if cur.fetchone()[0] != len(set(oe_ids)): + return "Unbekannte Organisationseinheit." + if gruppe_ids: + cur.execute( + "SELECT count(*) FROM gruppen WHERE organization_id = %s AND id::text = ANY(%s)", + (organization_id, gruppe_ids), + ) + if cur.fetchone()[0] != len(set(gruppe_ids)): + return "Unbekannte Gruppe." + return None + + +def fetch_org_ressourcen(organization_id: str) -> dict: + if not _ist_uuid(organization_id): + return {"freigeschaltet": False, "ressourcen": []} + with get_database_connection() as conn: + with conn.cursor() as cur: + freigeschaltet = org_ressourcen_freigeschaltet(cur, organization_id) + cur.execute( + """ + SELECT id, typ, name, daten, ganze_organisation + FROM org_ressourcen WHERE organization_id = %s + ORDER BY typ, name + """, + (organization_id,), + ) + zeilen = cur.fetchall() + cur.execute( + """ + SELECT z.ressource_id, z.oe_id, oe.name, z.gruppe_id, g.name + FROM org_ressourcen_ziele z + JOIN org_ressourcen r ON r.id = z.ressource_id + LEFT JOIN organisationseinheiten oe ON oe.id = z.oe_id + LEFT JOIN gruppen g ON g.id = z.gruppe_id + WHERE r.organization_id = %s + ORDER BY oe.name, g.name + """, + (organization_id,), + ) + ziele = {} + for ressource_id, oe_id, oe_name, gruppe_id, gruppe_name in cur.fetchall(): + eintrag = ziele.setdefault(ressource_id, {"oes": [], "gruppen": []}) + if oe_id is not None: + eintrag["oes"].append({"id": str(oe_id), "name": oe_name}) + else: + eintrag["gruppen"].append({"id": str(gruppe_id), "name": gruppe_name}) + + ressourcen = [] + for ressource_id, typ, name, daten, ganze_organisation in zeilen: + z = ziele.get(ressource_id, {"oes": [], "gruppen": []}) + ressourcen.append({ + "id": str(ressource_id), + "typ": typ, + "typ_name": ORG_RESSOURCEN_TYPEN.get(typ, {}).get("name", typ), + "name": name, + "daten": daten, + "ganze_organisation": ganze_organisation, + "oes": z["oes"], + "gruppen": z["gruppen"], + }) + + return {"freigeschaltet": freigeschaltet, "ressourcen": ressourcen} + + +def speichere_org_ressource( + organization_id: str, ressource_id: str | None, typ: str, name: str, daten: dict, + ganze_organisation: bool, oe_ids: list[str], gruppe_ids: list[str], +) -> tuple[dict | None, str | None]: + """Legt eine Ressource an (ressource_id None) oder ersetzt sie komplett + (Felder und Ziele). Liefert ({"id": …}, None) oder (None, Fehler).""" + + if not all(_ist_uuid(x) for x in [organization_id, *([ressource_id] if ressource_id else []), *(oe_ids or []), *(gruppe_ids or [])]): + return None, "Ungültige Kennung." + name = (name or "").strip() + if not name: + return None, "Bitte einen Namen angeben." + bereinigt, fehler = pruefe_ressource(typ, daten or {}) + if fehler: + return None, fehler + oe_ids = [] if ganze_organisation else list(dict.fromkeys(oe_ids or [])) + gruppe_ids = [] if ganze_organisation else list(dict.fromkeys(gruppe_ids or [])) + + with get_database_connection() as conn: + with conn.cursor() as cur: + if fehler := _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids): + return None, fehler + try: + if ressource_id is None: + ressource_id = str(uuid4()) + cur.execute( + """ + INSERT INTO org_ressourcen (id, organization_id, typ, name, daten, ganze_organisation) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (ressource_id, organization_id, typ, name, Jsonb(bereinigt), ganze_organisation), + ) + else: + cur.execute( + """ + UPDATE org_ressourcen + SET typ = %s, name = %s, daten = %s, ganze_organisation = %s, updated_at = now() + WHERE id = %s AND organization_id = %s + """, + (typ, name, Jsonb(bereinigt), ganze_organisation, ressource_id, organization_id), + ) + if cur.rowcount == 0: + return None, "Die Ressource wurde nicht gefunden." + cur.execute("DELETE FROM org_ressourcen_ziele WHERE ressource_id = %s", (ressource_id,)) + except psycopg.errors.UniqueViolation: + conn.rollback() + return None, "Eine Ressource mit diesem Namen gibt es schon." + + for oe_id in oe_ids: + cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, oe_id) VALUES (%s, %s)", (ressource_id, oe_id)) + for gruppe_id in gruppe_ids: + cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, gruppe_id) VALUES (%s, %s)", (ressource_id, gruppe_id)) + + return {"id": ressource_id}, None + + +def loesche_org_ressource(organization_id: str, ressource_id: str) -> bool: + if not (_ist_uuid(organization_id) and _ist_uuid(ressource_id)): + return False + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM org_ressourcen WHERE id = %s AND organization_id = %s", + (ressource_id, organization_id), + ) + return cur.rowcount > 0 + + +def fetch_geraete_ressourcen(cur, device_id) -> list[dict] | None: + """Ressourcen, die für ein Gerät gelten - None, wenn sie für seine + Organisation (noch) nicht freigeschaltet sind. Eine Ressource gilt, wenn + sie für die ganze Organisation wirkt, eine ihrer OEs die OE des Geräts + oder eine darüberliegende ist, oder das Gerät in einer ihrer Gruppen ist. + + Sortiert vom Spezifischsten zum Allgemeinsten (Gruppe, OE nahe am Gerät, + OE weiter oben, ganze Organisation), innerhalb gleicher Stufe nach Name: + Rollen, die nur eine Ressource eines Typs brauchen (Web-Proxy), nehmen + die erste.""" + + cur.execute("SELECT organization_id, oe_id FROM devices WHERE id = %s", (device_id,)) + row = cur.fetchone() + if row is None: + return None + organization_id, oe_id = row + if not org_ressourcen_freigeschaltet(cur, organization_id): + return None + + # Abstand jeder OE der Kette zur Geräte-OE (0 = eigene OE), wie in + # resolve_katalog_overrides() in Python verfolgt. + abstand_by_oe = {} + if oe_id is not None: + cur.execute("SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,)) + parent_by_id = {r[0]: r[1] for r in cur.fetchall()} + current, abstand = oe_id, 0 + while current is not None and current not in abstand_by_oe: + abstand_by_oe[current] = abstand + current, abstand = parent_by_id.get(current), abstand + 1 + + cur.execute("SELECT gruppe_id FROM device_gruppen WHERE device_id = %s", (device_id,)) + gruppen = {r[0] for r in cur.fetchall()} + + cur.execute( + """ + SELECT r.id, r.typ, r.name, r.daten, r.ganze_organisation, z.oe_id, z.gruppe_id + FROM org_ressourcen r + LEFT JOIN org_ressourcen_ziele z ON z.ressource_id = r.id + WHERE r.organization_id = %s + """, + (organization_id,), + ) + + # Rang je Ressource: kleiner = spezifischer. Gruppe 0, OE 1 + Abstand, + # ganze Organisation 1000. + beste = {} + for ressource_id, typ, name, daten, ganze_organisation, z_oe, z_gruppe in cur.fetchall(): + kandidaten = [] + if ganze_organisation: + kandidaten.append((1000, "organisation")) + if z_gruppe is not None and z_gruppe in gruppen: + kandidaten.append((0, "gruppe")) + if z_oe is not None and z_oe in abstand_by_oe: + kandidaten.append((1 + abstand_by_oe[z_oe], "oe")) + for rang, quelle in kandidaten: + if ressource_id not in beste or rang < beste[ressource_id][0]: + beste[ressource_id] = (rang, quelle, typ, name, daten) + + return [ + {"id": str(rid), "typ": typ, "name": name, "quelle": quelle, **daten} + for rid, (rang, quelle, typ, name, daten) in sorted(beste.items(), key=lambda e: (e[1][0], e[1][3])) + ] + + def fetch_auftragskatalog_listing(device_id: str): """ Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um @@ -5770,9 +6140,13 @@ def agent_report( with conn.cursor() as cur: merkmal_ids = {} - # Der Standardbrowser-Auftrag ist kein Merkmal und hat keine - # Ereigniszeile (siehe STANDARDBROWSER_AUFTRAG). - payload.results = [e for e in payload.results if e.merkmal != STANDARDBROWSER_AUFTRAG] + # Standardbrowser- und Org-Ressourcen-Auftrag sind keine Merkmale + # und haben keine Ereigniszeile (siehe STANDARDBROWSER_AUFTRAG, + # ORG_RESSOURCEN_AUFTRAG). + payload.results = [ + e for e in payload.results + if e.merkmal not in (STANDARDBROWSER_AUFTRAG, ORG_RESSOURCEN_AUFTRAG) + ] for entry in payload.results: if entry.merkmal in merkmal_ids: @@ -7245,6 +7619,66 @@ def list_gruppen(organization_id: str, authorization: str | None = Header(defaul return {"success": True, "gruppen": fetch_gruppen(organization_id)} +@app.get("/api/v1/org-ressourcen/typen") +def list_org_ressourcen_typen(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, "typen": ORG_RESSOURCEN_TYPEN} + + +@app.get("/api/v1/organizations/{organization_id}/ressourcen") +def list_org_ressourcen(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, **fetch_org_ressourcen(organization_id)} + + +@app.post("/api/v1/organizations/{organization_id}/ressourcen") +def create_org_ressource(organization_id: str, payload: OrgRessourceRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + ergebnis, fehler = speichere_org_ressource( + organization_id, None, payload.typ, payload.name, payload.daten, + payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids, + ) + if fehler: + return {"success": False, "error": "invalid", "message": fehler} + + return {"success": True, "ressource": ergebnis} + + +@app.put("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}") +def update_org_ressource( + organization_id: str, ressource_id: str, payload: OrgRessourceRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + ergebnis, fehler = speichere_org_ressource( + organization_id, ressource_id, payload.typ, payload.name, payload.daten, + payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids, + ) + if fehler: + return {"success": False, "error": "invalid", "message": fehler} + + return {"success": True, "ressource": ergebnis} + + +@app.delete("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}") +def delete_org_ressource(organization_id: str, ressource_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 loesche_org_ressource(organization_id, ressource_id): + return {"success": False, "error": "not_found", "message": "Die Ressource wurde nicht gefunden."} + + return {"success": True} + + @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): diff --git a/migrations/0062_org_ressourcen.sql b/migrations/0062_org_ressourcen.sql new file mode 100644 index 0000000..be6c888 --- /dev/null +++ b/migrations/0062_org_ressourcen.sql @@ -0,0 +1,49 @@ +BEGIN; + +-- Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md, +-- Schritt 1): lokale Dienste einer Organisation (Mail, Nextcloud, ownCloud, +-- WebDAV, SMB, Web-Proxy), einmal zentral vom Kunden-Admin gepflegt. App- +-- Rollen auf dem Gerät nutzen sie zum Vorbefüllen (Server, Ports), der +-- Benutzer gibt nur noch Name und Passwort ein. +-- +-- Bewusst KEINE Zugangsdaten: der Server nimmt in daten keine Passwort- +-- oder Benutzer-Felder an (Typ-Definitionen in app.py, ORG_RESSOURCEN_TYPEN). +-- Die Ressource gehört zur Organisation, nicht zum Workspace, weil +-- Workspaces über Kundengruppen von mehreren Organisationen genutzt werden. + +CREATE TABLE org_ressourcen ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + typ TEXT NOT NULL + CHECK (typ IN ('mail', 'nextcloud', 'owncloud', 'webdav', 'smb', 'proxy')), + name TEXT NOT NULL, + daten JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Wirkung: ganze Organisation, sonst nur die Ziele aus + -- org_ressourcen_ziele (OE samt Unter-OEs, Gerätegruppe). Ohne Ziel und + -- ohne ganze Organisation wirkt die Ressource nirgends (gepflegt, aber + -- nicht verteilt). + ganze_organisation BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (organization_id, name) +); + +CREATE TABLE org_ressourcen_ziele ( + ressource_id UUID NOT NULL + REFERENCES org_ressourcen(id) + ON DELETE CASCADE, + oe_id UUID + REFERENCES organisationseinheiten(id) + ON DELETE CASCADE, + gruppe_id UUID + REFERENCES gruppen(id) + ON DELETE CASCADE, + CHECK ((oe_id IS NULL) <> (gruppe_id IS NULL)) +); + +CREATE UNIQUE INDEX org_ressourcen_ziele_oe ON org_ressourcen_ziele (ressource_id, oe_id) WHERE oe_id IS NOT NULL; +CREATE UNIQUE INDEX org_ressourcen_ziele_gruppe ON org_ressourcen_ziele (ressource_id, gruppe_id) WHERE gruppe_id IS NOT NULL; + +COMMIT;