100 lines
5.3 KiB
Python
100 lines
5.3 KiB
Python
"""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},
|
||
]
|