Org-Ressourcen: Tabellen, API und Auftrag org-ressourcen im Check-in (Migration 0062)

Ressourcen (Mail, Nextcloud, ownCloud, WebDAV, SMB, Web-Proxy) gehoeren zur
Organisation und wirken fuer die ganze Org, OEs samt Unter-OEs oder
Geraetegruppen. Keine Zugangsdaten: unbekannte Felder werden verworfen.
Der Check-in schickt die fuer das Geraet geltenden Ressourcen (spezifisch
zuerst) als Auftrag org-ressourcen, vorerst nur fuer Organisationen mit
Ausrollstufe vorab (ORG_RESSOURCEN_NUR_VORAB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 12:57:38 +02:00
1 parent 413d9a4795
commit ed145be3d2
2 files changed
+486 -3

No files matched your search

+437 -3
View File
@@ -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):