Files
Onyscribe-Admin/app/services/tenants.py

100 lines
5.3 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]:
from app.services import contract
t = db.get(Tenant, tenant_id)
cfg, _ = store.content(db, t, "current") if t else ({}, {})
required = [k for k in contract.required_secrets(cfg) if k not in ("CORTECS_API_KEY",)] # geteilte Secrets stehen unter Einstellungen
rows = {s.key: s for s in db.scalars(select(Secret).where(Secret.tenant_id == tenant_id))}
keys = list(dict.fromkeys(required + [k for k in rows if k not in required]))
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 required, "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},
]