#!/usr/bin/env python3 """Einmaliger Datenmigrationsschritt fuer TEIL C, Phase 1 des Umsetzungsauftrag_Sonnet5.md ("Rollenmodell bereinigen"). Betreiberentscheidung (Phase 0.2): jeder Benutzer mit einer Zeile in tenant_admins wird zu is_admin=1 befoerdert (Voll-Admin), STATT zum normalen Benutzer degradiert zu werden. Muss AUSGEFUEHRT WERDEN, BEVOR die Phase-2-Codeaenderungen (TenantScope-Entfernung) deployt werden -- siehe C.4: wird TenantScope entfernt, ohne vorher jeden tenant_admins-Eintrag explizit auf is_admin=1 zu heben, waere die Rechteausweitung fuer diese Benutzer zwar am Ende dieselbe, aber implizit und ohne Audit-Spur zum Zeitpunkt der eigentlichen Entscheidung. Nach diesem Schritt verhaelt sich die Anwendung noch exakt wie vorher (Phase 1 ist bewusst noch KEINE Codeaenderung) -- ein bereits is_admin=1 gesetzter Benutzer sieht/kann nichts anderes als vorher, ein bisheriger reiner Mandanten-Admin sieht ab sofort (noch ueber den unveraenderten is_any_admin-Pfad) alle Mandanten statt nur seiner/seine eigenen. Das ist die in Phase 0.2 getroffene, gewollte Entscheidung -- keine Nebenwirkung. Idempotent: bereits befoerderte oder inzwischen geloeschte/deaktivierte Benutzer werden uebersprungen; mehrfacher Aufruf ist gefahrlos. Aufruf (vor dem Wartungsfenster aus Phase 2, mit noch unveraendertem Code): /bin/python scripts/promote_tenant_admins.py [--dry-run] """ from __future__ import annotations import argparse import asyncio import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.db import close_db, get_db, init_db # noqa: E402 from app.security.audit import write_audit_event # noqa: E402 async def main(dry_run: bool) -> None: await init_db() conn = get_db() try: cursor = await conn.execute( "SELECT DISTINCT ta.user_id, u.username, u.is_admin " "FROM tenant_admins ta JOIN users u ON u.id = ta.user_id " "WHERE u.deleted_at IS NULL " "ORDER BY u.username" ) rows = await cursor.fetchall() if not rows: print("Keine tenant_admins-Eintraege vorhanden -- nichts zu tun.") return to_promote = [(uid, uname) for uid, uname, is_admin in rows if not is_admin] already_admin = [uname for _uid, uname, is_admin in rows if is_admin] print(f"Gefunden: {len(rows)} Benutzer mit mind. einer tenant_admins-Zeile.") if already_admin: print(f" Bereits is_admin=1 (unveraendert): {', '.join(already_admin)}") if not to_promote: print("Alle betroffenen Benutzer sind bereits is_admin=1 -- nichts zu tun.") return print(f" Wird auf is_admin=1 befoerdert: {', '.join(u for _i, u in to_promote)}") if dry_run: print("--dry-run: keine Aenderung vorgenommen.") return for user_id, username in to_promote: await conn.execute("UPDATE users SET is_admin = 1 WHERE id = ?", (user_id,)) await write_audit_event( conn, event_type="tenant_admin_promoted_to_admin", user_id=user_id, client_ip=None, details={ "reason": ( "Umsetzungsauftrag_Sonnet5.md Teil C, Phase 0.2/1: " "Rueckbau der Mandantenfaehigkeit -- bestehende " "tenant_admins-Zuweisung wird zu vollem is_admin=1 " "befoerdert (Betreiberentscheidung, keine Degradierung)." ), "username": username, }, ) await conn.commit() print(f"{len(to_promote)} Benutzer befoerdert, je ein Audit-Event geschrieben.") finally: await close_db() if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--dry-run", action="store_true", help="Nur anzeigen, was passieren wuerde, nichts schreiben." ) args = parser.parse_args() asyncio.run(main(args.dry_run))