313 lines
15 KiB
Python
313 lines
15 KiB
Python
"""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)
|
||
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']}")
|