Files
ssh-jumphost/scripts/promote_tenant_admins.py
2026-09-02 20:30:44 +02:00

100 lines
4.0 KiB
Python

#!/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):
<venv>/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))