"""Abgleich der OIDC-Clients mit der Developer-API von login.onyva.dev (`/api/v1`, siehe LoginSeite/docs/developer-api.md). Pro Tenant gibt es einen Soll-Zustand (`tenant.oidc`). Bei jedem Deploy wird der Ist-Zustand gelesen und bei Bedarf angelegt/angepasst: mode = off nichts anfassen mode = adopt (Standard für importierte Altclients) nur sicherstellen, dass die Redirect-URIs des Tenants enthalten sind mode = managed Redirect-URIs, auth_mode, totp_mode, allow_registration, backchannel_logout_uri exakt wie konfiguriert Freigaben (Memberships): `members` werden angelegt/aktualisiert; mit members_mode = exact werden zusätzliche Freigaben entfernt. Sicherheit: Lesen (GET) läuft immer, wenn ein Key da ist. SCHREIBEN nur mit apply=True – und apply erlaubt die Plattform nur, wenn OIDC_SYNC_LIVE=true oder DEPLOY_LIVE=true. Das `client_secret` ist nur bei Anlage/Rotation sichtbar und wird SOFORT verschlüsselt gespeichert. """ from __future__ import annotations import os import re import time from datetime import datetime, timezone from typing import Callable import httpx from sqlalchemy import select from app.db import session from app.models import Secret, Setting, Tenant from app.services import crypto, guard, store MODES = ("off", "adopt", "managed") AUTH_MODES = ("required", "optional", "disabled") TOTP_MODES = ("disabled", "optional", "required") ROLES = ("viewer", "editor", "admin") DEFAULTS = {"mode": "adopt", "auth_mode": "required", "totp_mode": "disabled", "allow_registration": False, "backchannel_logout_uri": None, "members": [], "members_mode": "add", "last_sync": None} class OnyvaError(RuntimeError): pass def key_present() -> bool: return bool(os.getenv("ONYVA_LOGIN_API_KEY", "").strip()) def writes_allowed() -> bool: return (os.getenv("OIDC_SYNC_LIVE", "false").lower() == "true" or os.getenv("DEPLOY_LIVE", "false").lower() == "true") and guard.auth_ok() def base_url(db) -> str: s = db.get(Setting, "onyva_login_api_base") if s and s.value: return s.value.rstrip("/") issuer = db.get(Setting, "oidc_issuer") return f"{(issuer.value if issuer and issuer.value else 'https://login.onyva.dev').rstrip('/')}/api/v1" class OnyvaLogin: """Dünner Client für /api/v1/apps. `http(method, url, headers, body) -> (status, data, headers)` ist in Tests ersetzbar.""" def __init__(self, base: str, key: str, http: Callable | None = None, sleep: Callable[[float], None] = time.sleep): self.base, self.key, self._http, self._sleep = base.rstrip("/"), key, http or self._real, sleep @staticmethod def _real(method, url, headers, body): r = httpx.request(method, url, headers=headers, json=body, timeout=30, follow_redirects=True) try: data = r.json() if r.content else None except ValueError: data = None return r.status_code, data, dict(r.headers) def _req(self, method: str, path: str, body: dict | None = None): for attempt in range(4): status, data, headers = self._http(method, f"{self.base}{path}", {"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}, body) if status == 429 and attempt < 3: # Rate-Limit: 120 Requests / 60 s pro Key self._sleep(min(int({k.lower(): v for k, v in headers.items()}.get("retry-after", 5)), 60)) continue if status == 401: raise OnyvaError("API-Key ungültig, widerrufen oder abgelaufen (401).") return status, data raise OnyvaError("Rate-Limit des Login-Systems (429) dauerhaft überschritten.") def _ok(self, method, path, body=None, ok=(200, 201, 204)): status, data = self._req(method, path, body) if status not in ok: detail = data.get("detail") if isinstance(data, dict) else data raise OnyvaError(f"{method} {path} → HTTP {status}: {str(detail)[:300]}") return data def list_apps(self) -> list[dict]: return (self._ok("GET", "/apps") or {}).get("apps", []) def get_app(self, cid: str) -> dict | None: status, data = self._req("GET", f"/apps/{cid}") if status == 404: return None if status != 200: raise OnyvaError(f"GET /apps/{cid} → HTTP {status}") return data def create_app(self, body: dict) -> dict: return self._ok("POST", "/apps", body, ok=(201, 200)) def patch_app(self, cid: str, body: dict) -> dict: return self._ok("PATCH", f"/apps/{cid}", body) def delete_app(self, cid: str) -> None: self._ok("DELETE", f"/apps/{cid}", ok=(204, 200)) def rotate_secret(self, cid: str) -> dict: return self._ok("POST", f"/apps/{cid}/rotate-secret") def memberships(self, cid: str) -> list[dict]: return (self._ok("GET", f"/apps/{cid}/memberships") or {}).get("memberships", []) def put_membership(self, cid: str, email: str, role: str) -> bool: status, _ = self._req("PUT", f"/apps/{cid}/memberships", {"user_email": email, "role": role}) if status == 404: return False # Nutzer existiert im Login nicht if status not in (200, 201): raise OnyvaError(f"PUT memberships → HTTP {status}") return True def delete_membership(self, cid: str, email: str) -> None: self._req("DELETE", f"/apps/{cid}/memberships/{email}") def client_from_env(db, http=None) -> OnyvaLogin | None: key = os.getenv("ONYVA_LOGIN_API_KEY", "").strip() return OnyvaLogin(base_url(db), key, http) if key else None # ── Soll-Zustand ──────────────────────────────────────────────────────────── def config_of(t: Tenant) -> dict: return {**DEFAULTS, **(t.oidc or {})} def validate_config(cfg: dict) -> dict: out = {**DEFAULTS, **cfg} if out["mode"] not in MODES: raise ValueError("mode: off | adopt | managed") if out["auth_mode"] not in AUTH_MODES: raise ValueError("auth_mode: required | optional | disabled") if out["totp_mode"] not in TOTP_MODES: raise ValueError("totp_mode: disabled | optional | required") if out["members_mode"] not in ("add", "exact"): raise ValueError("members_mode: add | exact") out["allow_registration"] = bool(out["allow_registration"]) out["backchannel_logout_uri"] = (out["backchannel_logout_uri"] or "").strip() or None if out["backchannel_logout_uri"] and not out["backchannel_logout_uri"].startswith("https://"): raise ValueError("Backchannel-Logout-URI muss https:// sein und öffentlich erreichbar") members, seen = [], set() for m in out["members"] or []: email, role = str(m.get("email", "")).strip().lower(), m.get("role", "viewer") if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): raise ValueError(f"Ungültige E-Mail: {email!r}") if role not in ROLES: raise ValueError(f"Rolle von {email}: viewer | editor | admin") if email not in seen: seen.add(email) members.append({"email": email, "role": role}) out["members"] = members out.pop("last_sync", None) return out def desired_redirects(base_domain: str, t: Tenant) -> list[str]: hosts = [f"{t.subdomain or t.id}.{base_domain}"] if t.custom_domain: hosts.append(t.custom_domain) return [f"https://{h}/auth/callback" for h in hosts] def diff_app(actual: dict, want: dict, mode: str) -> dict: """Welche PATCH-Felder wären nötig? adopt: nur fehlende URIs ergänzen; managed: alles exakt.""" changes: dict = {} have, need = list(actual.get("redirect_uris") or []), want["redirect_uris"] if mode == "adopt": missing = [u for u in need if u not in have] if missing: changes["redirect_uris"] = have + missing elif mode == "managed": if set(have) != set(need): changes["redirect_uris"] = need for f in ("auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri"): if actual.get(f) != want[f]: changes[f] = want[f] return changes def _secret(db, tenant_id: str, key: str) -> str | None: row = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key)) return crypto.decrypt(row.value_enc) if row and row.value_enc else None def _store_credentials(tenant_id: str, client_id: str | None, client_secret: str | None, actor="onyva-login-sync") -> None: """Sofort und in eigener Transaktion sichern – das Secret ist danach nicht mehr abrufbar.""" from app.services import tenants as tsvc with session() as db: if client_id: tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_ID", client_id, actor) if client_secret: tsvc.set_secret(db, tenant_id, "OIDC_CLIENT_SECRET", client_secret, actor) # ── Abgleich ──────────────────────────────────────────────────────────────── def sync_tenant(tenant_id: str, *, apply: bool, log: Callable[[str], None] = lambda m: None, client: OnyvaLogin | None = None, rotate: bool = False, actor: str = "system") -> dict: """Gleicht den OIDC-Client des Tenants ab. apply=False → nur lesen und Plan melden. Rückgabe: {ok, applied, skipped, client_id, actions:[{kind,detail}], warnings:[...], created, error}.""" rep = {"ok": True, "applied": False, "skipped": None, "client_id": None, "actions": [], "warnings": [], "created": False, "error": None} act = lambda kind, detail: (rep["actions"].append({"kind": kind, "detail": detail}), log(f"OIDC: {detail}")) # noqa: E731 warn = lambda msg: (rep["warnings"].append(msg), log(f"OIDC ⚠ {msg}")) # noqa: E731 if apply and not writes_allowed(): rep.update(ok=False, error="Schreibzugriffe auf das Login-System sind gesperrt (OIDC_SYNC_LIVE/DEPLOY_LIVE nicht aktiv).") return rep if apply and not guard.live_allowed(tenant_id): rep.update(ok=False, error=guard.denial(tenant_id)) return rep try: with session() as db: t = db.get(Tenant, tenant_id) cfg = config_of(t) base_domain = (db.get(Setting, "base_domain").value if db.get(Setting, "base_domain") else "example.de") want = {"name": f"Onyscribe – {t.name}", "redirect_uris": desired_redirects(base_domain, t), "auth_mode": cfg["auth_mode"], "totp_mode": cfg["totp_mode"], "allow_registration": cfg["allow_registration"], "backchannel_logout_uri": cfg["backchannel_logout_uri"]} cid, has_secret = _secret(db, tenant_id, "OIDC_CLIENT_ID"), bool(_secret(db, tenant_id, "OIDC_CLIENT_SECRET")) client = client or client_from_env(db) mode = cfg["mode"] if mode == "off": rep["skipped"] = "OIDC-Verwaltung ist für diesen Tenant aus." log("OIDC: Verwaltung aus – nichts zu tun") return rep if client is None: rep["skipped"] = "ONYVA_LOGIN_API_KEY nicht gesetzt." rep["ok"] = not apply # beim echten Deploy ist ein fehlender Key ein Fehler, im Dry-Run nur ein Hinweis rep["error"] = None if rep["ok"] else rep["skipped"] log("OIDC: (übersprungen – ONYVA_LOGIN_API_KEY nicht gesetzt)") return rep app = client.get_app(cid) if cid else None new_app = app is None verb = "" if apply else "würde " if app is None: if cid: warn(f"Client {cid} existiert im Login nicht mehr – es wird ein neuer angelegt.") body = {k: want[k] for k in ("name", "redirect_uris", "auth_mode", "totp_mode", "allow_registration", "backchannel_logout_uri")} act("create", f"{verb}App „{want['name']}“ anlegen (Redirect: {', '.join(want['redirect_uris'])}; auth_mode={want['auth_mode']})") if apply: created = client.create_app(body) cid = created["client_id"] _store_credentials(tenant_id, cid, created.get("client_secret"), actor) # ZUERST sichern rep["created"], has_secret = True, True log(f"OIDC: App angelegt → {cid} (Secret verschlüsselt gespeichert)") app = {"redirect_uris": want["redirect_uris"], "auth_mode": want["auth_mode"]} else: changes = diff_app(app, want, mode) if changes: act("patch", f"{verb}App {cid} anpassen: " + ", ".join(f"{k}={v}" for k, v in changes.items())) if apply: client.patch_app(cid, changes) else: log(f"OIDC: App {cid} ist abgeglichen") rep["client_id"] = cid if not has_secret: warn("Client-Secret ist nicht in der Admin-DB (beim Login nicht abrufbar) – eintragen oder „Secret rotieren“.") if rotate and cid: act("rotate", f"{verb}Secret von {cid} rotieren (altes sofort ungültig)") if apply: _store_credentials(tenant_id, None, client.rotate_secret(cid)["client_secret"], actor) # Freigaben members = cfg["members"] effective_auth = want["auth_mode"] if mode == "managed" else (app.get("auth_mode") or want["auth_mode"]) if (members or cfg["members_mode"] == "exact") and (cid or new_app): current = {} if new_app else {m["email"].lower(): m["role"] for m in client.memberships(cid)} for m in members: if current.get(m["email"]) != m["role"]: act("member_put", f"{verb}{m['email']} als {m['role']} freigeben") if apply and not client.put_membership(cid, m["email"], m["role"]): warn(f"{m['email']} existiert im Login nicht – Nutzer dort anlegen, dann erneut abgleichen.") if cfg["members_mode"] == "exact": for email in sorted(set(current) - {m["email"] for m in members}): act("member_delete", f"{verb}Freigabe von {email} entziehen") if apply: client.delete_membership(cid, email) elif effective_auth == "required" and not members: log("OIDC: auth_mode=required ohne gepflegte Freigaben – bestehende Freigaben bleiben unverändert") rep["applied"] = apply and bool(rep["actions"]) except OnyvaError as exc: rep.update(ok=False, error=str(exc)) log(f"OIDC ✘ {exc}") except Exception as exc: # noqa: BLE001 rep.update(ok=False, error=f"{type(exc).__name__}: {exc}") log(f"OIDC ✘ {rep['error']}") if apply: _record(tenant_id, rep) return rep def _record(tenant_id: str, rep: dict) -> None: with session() as db: t = db.get(Tenant, tenant_id) cfg = dict(t.oidc or {}) cfg["last_sync"] = {"ts": datetime.now(timezone.utc).isoformat(), "ok": rep["ok"], "created": rep["created"], "error": rep["error"], "actions": [a["detail"] for a in rep["actions"]], "warnings": rep["warnings"]} t.oidc = cfg store.audit(db, "system", "oidc.sync", tenant_id, "ok" if rep["ok"] else f"Fehler: {rep['error']}")