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>
This commit is contained in:
312
app/services/onyva_login.py
Normal file
312
app/services/onyva_login.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""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']}")
|
||||
Reference in New Issue
Block a user