Files
Onyscribe-Admin/app/services/tenants.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

96 lines
5.1 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.
"""Tenant-Lebenszyklus: anlegen, klonen, Secrets, Cut-over-Check."""
from __future__ import annotations
import re
from copy import deepcopy
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Deployment, Secret, Tenant, Version
from app.services import crypto, defaults, store
SLUG = re.compile(r"^[a-z0-9][a-z0-9-]{1,38}$")
def create(db: Session, tenant_id: str, name: str, clone_from: str | None, actor: str) -> Tenant:
tenant_id = tenant_id.strip().lower()
if not SLUG.match(tenant_id):
raise ValueError("ID: nur a-z, 0-9 und Bindestrich, 2–39 Zeichen.")
if db.get(Tenant, tenant_id):
raise ValueError(f"Tenant „{tenant_id}“ existiert bereits.")
t = Tenant(id=tenant_id, name=name.strip() or tenant_id, subdomain=tenant_id, domain_status="none", source="admin",
oidc={"mode": "managed"})
db.add(t)
db.flush()
if clone_from:
src = db.get(Tenant, clone_from)
cfg, files = store.content(db, src, "current")
cfg = deepcopy(cfg)
cfg.update({"id": tenant_id, "name": t.name, "title": f"{t.name} – Gesprächsprotokoll",
"description": f"Gesprächsprotokoll-Tool für {t.name}", "ai_detect_org": t.name, "org_short": t.name})
# Personenbezogene/mandantenspezifische Dateien nicht mitklonen
files = {p: s for p, s in files.items() if not p.startswith("namensliste")}
t.cpu, t.memory = src.cpu, src.memory
else:
cfg, files = defaults.skeleton_config(tenant_id, t.name), {}
db.add(Version(tenant_id=tenant_id, number=1, message="Angelegt" + (f" (Kopie von {clone_from})" if clone_from else ""),
author=actor, config=cfg, files=files))
t.current_version = 1
for key, val in (("SESSION_SECRET", defaults.new_token()), ("ADMIN_BUNDLE_TOKEN", defaults.new_token())):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=crypto.encrypt(val), source="manual"))
for key in ("OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "COST_INGEST_KEY"):
db.add(Secret(tenant_id=tenant_id, key=key, value_enc=None, source="manual"))
store.audit(db, actor, "tenant.create", tenant_id, f"Kopie von {clone_from}" if clone_from else "leer")
db.flush()
return t
def secret_rows(db: Session, tenant_id: str) -> list[dict]:
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
keys = list(dict.fromkeys(defaults.REQUIRED_SECRETS + [k for k in rows if k not in defaults.REQUIRED_SECRETS]))
out = []
for k in keys:
r = rows.get(k)
out.append({
"key": k, "set": bool(r and r.value_enc), "source": r.source if r else "",
"required": k in defaults.REQUIRED_SECRETS, "help": defaults.SECRET_HELP.get(k, ""),
"updated_at": r.updated_at.isoformat() if r and r.updated_at else None,
})
return out
def set_secret(db: Session, tenant_id: str, key: str, value: str | None, actor: str) -> None:
if not re.match(r"^[A-Z][A-Z0-9_]{1,60}$", key):
raise ValueError("Schlüssel: GROSSBUCHSTABEN_MIT_UNTERSTRICH")
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == key))
if r is None:
r = Secret(tenant_id=tenant_id, key=key)
db.add(r)
r.value_enc = crypto.encrypt(value) if value else None
r.source = "manual"
store.audit(db, actor, "secret.set" if value else "secret.clear", tenant_id, key)
def check_bundle_token(db: Session, tenant_id: str, token: str) -> bool:
r = db.scalar(select(Secret).where(Secret.tenant_id == tenant_id, Secret.key == "ADMIN_BUNDLE_TOKEN"))
return bool(r and r.value_enc and token and crypto.decrypt(r.value_enc) == token)
def cutover_checks(db: Session, t: Tenant, validation: dict | None) -> list[dict]:
secrets_ok = all(s["set"] for s in secret_rows(db, t.id) if s["required"])
imported = db.scalar(select(Version).where(Version.tenant_id == t.id, Version.author == "import")) is not None
dry = db.scalar(select(Deployment).where(Deployment.tenant_id == t.id, Deployment.kind == "dry-run",
Deployment.status == "success").order_by(Deployment.id.desc()))
return [
{"id": "content", "label": "Inhalt aus Git importiert & Hash-gleich verifiziert" if imported else "Inhalt in der Datenbank",
"ok": True},
{"id": "validate", "label": "Validierung (Core-Rendering von Web-UI und PDF)", "ok": bool(validation and validation.get("ok"))},
{"id": "secrets", "label": "Alle Pflicht-Secrets in der Admin-DB gesetzt", "ok": secrets_ok},
{"id": "oidc", "label": "OIDC-Client abgeglichen (Login-API)" if (t.oidc or {}).get("mode", "adopt") != "off" else "OIDC-Verwaltung aus",
"ok": (t.oidc or {}).get("mode", "adopt") == "off" or bool(((t.oidc or {}).get("last_sync") or {}).get("ok"))},
{"id": "domain", "label": "Domain verifiziert", "ok": t.domain_status == "verified"},
{"id": "dryrun", "label": "Dry-Run erfolgreich", "ok": dry is not None},
{"id": "published", "label": "Keine unveröffentlichten Änderungen", "ok": not t.draft},
]