Files
Onyscribe-Admin/app/services/onyva_login.py
RubenRWU 7d8c226da2 Onyscribe Admin: Initialer Stand
Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs),
OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant),
Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich.
Runbook: docs/RUNBOOK_PARALLELTEST.md

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 15:30:26 +02:00

313 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"
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)
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']}")