"""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}, ]