""" Mandanten-Scoping: zentrale Stelle, die die Mandanten-Sichtbarkeit eines Principals (Super-Admin / Mandanten-Admin-Session / Mandanten-gebundenes API-Token, siehe app/auth/deps.py) in SQL-WHERE-Fragmente und Zugriffspruefungen uebersetzt. JEDER /admin-Endpunkt, der mandantengebundene Ressourcen (Hostgruppen, Hosts, Benutzergruppen, SSH-Keys, API-Tokens, Rollenvergaben, Audit-Log) liest oder schreibt, muss ueber TenantScope gehen -- damit die Isolation an genau einer Stelle durchgesetzt wird statt in jedem Endpunkt einzeln neu erfunden zu werden. Sicherheitsprinzip: eine Zugriffsverletzung ueber eine ID aus einem fremden Mandanten liefert 404 (nicht 403) -- ein Mandanten-Admin soll aus der Fehlerantwort nicht einmal ableiten koennen, DASS eine ID in einem anderen Mandanten existiert. """ from __future__ import annotations import aiosqlite from fastapi import HTTPException, status class TenantScope: def __init__(self, tenant_ids: set[int] | None): # tenant_ids is None -> Super-Admin, keine Einschraenkung. # tenant_ids ist Set -> nur diese Mandanten sichtbar/aenderbar # (leeres Set == gar kein Mandant -> nichts sichtbar). self.all_tenants = tenant_ids is None self.tenant_ids: set[int] = set(tenant_ids) if tenant_ids else set() def sql_filter(self, column: str) -> tuple[str, tuple]: """SQL-Fragment (mit fuehrendem ' AND ...') + Parameter-Tuple, das an eine bestehende WHERE-Klausel angehaengt werden kann.""" if self.all_tenants: return "", () if not self.tenant_ids: return " AND 0", () placeholders = ",".join("?" for _ in self.tenant_ids) return f" AND {column} IN ({placeholders})", tuple(self.tenant_ids) def check(self, tenant_id: int | None) -> None: """Wirft 404, wenn tenant_id ausserhalb des erlaubten Bereichs liegt.""" if self.all_tenants: return if tenant_id is None or tenant_id not in self.tenant_ids: raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden") def single_tenant_id(self) -> int | None: """Fuer Endpunkte, die zwingend GENAU einen Mandanten brauchen (z.B. beim Anlegen einer neuen Hostgruppe). None bei Super-Admin (muss dann explizit im Request mitgegeben werden), sonst der einzige erlaubte Mandant -- oder ein 400/409, falls der Aufrufer mehrere hat (in dieser Version hat ein Mandanten-Admin praktisch immer genau einen Mandanten, die Datenstruktur erlaubt aber theoretisch mehrere).""" if self.all_tenants or len(self.tenant_ids) != 1: return None return next(iter(self.tenant_ids)) async def resolve_host_group_tenant(conn: aiosqlite.Connection, host_group_id: int) -> int | None: cursor = await conn.execute("SELECT tenant_id FROM host_groups WHERE id = ?", (host_group_id,)) row = await cursor.fetchone() return row[0] if row else None async def resolve_host_tenant(conn: aiosqlite.Connection, host_id: int) -> int | None: cursor = await conn.execute( "SELECT hg.tenant_id FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id WHERE h.id = ?", (host_id,), ) row = await cursor.fetchone() return row[0] if row else None async def tenant_user_ids(conn: aiosqlite.Connection, tenant_id: int) -> set[int]: """Alle Benutzer, die zu diesem Mandanten gehoeren: Mandanten-Admins, Benutzer mit home_tenant_id = tenant_id, Mitglieder einer Benutzergruppe dieses Mandanten, sowie Benutzer mit einer direkten Rollenvergabe auf einer Hostgruppe dieses Mandanten. Wird sowohl fuer die Benutzer-Liste einer Mandanten-Admin-Sicht als auch fuer die Audit-Log-Filterung genutzt.""" cursor = await conn.execute( """ SELECT user_id FROM tenant_admins WHERE tenant_id = ? UNION SELECT id FROM users WHERE home_tenant_id = ? UNION SELECT ugm.user_id FROM user_group_members ugm JOIN user_groups ug ON ug.id = ugm.user_group_id WHERE ug.tenant_id = ? UNION SELECT uhr.user_id FROM user_hostgroup_roles uhr JOIN host_groups hg ON hg.id = uhr.host_group_id WHERE hg.tenant_id = ? """, (tenant_id, tenant_id, tenant_id, tenant_id), ) rows = await cursor.fetchall() return {r[0] for r in rows}