"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6) sowie, seit Teil D Schritt 4, Achse B (Benutzergruppe × Zugangsdatensatz). Eine Rolle gilt fuer einen User NUR NOCH ueber seine Mitgliedschaft in einer Benutzergruppe (user_groups/user_group_members), der die Rolle auf der Hostgruppe vergeben wurde (group_hostgroup_roles). Der frueher zusaetzlich vorhandene Direktvergabe-Zweig ueber user_hostgroup_roles ist mit Teil D Schritt 4 entfallen (Umsetzungsauftrag_Sonnet5.md, D.3: "user_has_role() schrumpft auf einen Zweig"). Das ist erst SICHER, seit Teil D Schritt 3 (Migration 0017_personal_groups.sql) jeden Benutzer mit vormals direkten Vergaben in eine automatisch erzeugte persoenliche Gruppe mit identischen Vergaben gespiegelt hat -- siehe FORTSETZUNG_Teil_D.md Abschnitt 1a. Die Tabelle user_hostgroup_roles selbst existiert als Altlast weiter (Migration 0015 hat nur die drei toten Rollen daraus entfernt) und wird erst in Schritt 5 geleert/umbenannt; sie wird ab hier von keiner Leselogik mehr konsultiert. Achse B (group_ssh_key_grants, group_rdp_credential_grants, group_ssh_password_credential_grants, angelegt in Migration 0018_credential_group_grants.sql) beantwortet eine zweite, unabhaengige Frage: nicht "darf dieser Benutzer sich mit Host X verbinden" (Achse A, oben), sondern "WOMIT darf er sich anmelden". user_can_use_credential() und resolve_credential_for_user_on_host() sind die dafuer vorgesehenen zwei neuen Funktionen (D.3: "eine Pruefung 'darf dieser Benutzer diesen Zugangsdatensatz nutzen' und eine Auflösung 'welcher Satz gilt fuer diesen Benutzer auf diesem Host'").""" from __future__ import annotations from typing import Literal import aiosqlite CredentialKind = Literal["ssh_key", "rdp_credential", "ssh_password_credential"] # Achse-B-Freigabetabelle je Credential-Art (Migration 0018) -- # (Tabellenname, Spaltenname der Credential-ID). _GRANT_TABLE: dict[CredentialKind, tuple[str, str]] = { "ssh_key": ("group_ssh_key_grants", "ssh_key_id"), "rdp_credential": ("group_rdp_credential_grants", "rdp_credential_id"), "ssh_password_credential": ( "group_ssh_password_credential_grants", "ssh_password_credential_id", ), } # Host<->Credential-Zuordnungstabelle je Credential-Art (unveraendert seit # vor Teil D) -- (Tabellenname, Spaltenname der Credential-ID). ssh_key ist # n:m (mehrere Schluessel je Host moeglich); die beiden anderen sind laut # Schema 1:1 (PK auf host_id), Mehrdeutigkeit kann dort strukturell nicht # auftreten. _MAP_TABLE: dict[CredentialKind, tuple[str, str]] = { "ssh_key": ("host_ssh_key_map", "ssh_key_id"), "rdp_credential": ("host_rdp_credential_map", "rdp_credential_id"), "ssh_password_credential": ( "host_ssh_password_credential_map", "ssh_password_credential_id", ), } class AmbiguousCredentialError(Exception): """Mehr als ein fuer den Benutzer nutzbarer Zugangsdatensatz derselben Art an einem Host. Bewusst ein harter Fehler statt einer stillen Auswahl (Umsetzungsauftrag_Sonnet5.md D.3: "Bei mehreren Treffern lieber ein klarer Fehler ... als das heutige LIMIT 1 ohne ORDER BY"). Kann nur bei kind="ssh_key" auftreten (n:m-Zuordnung); bei den beiden anderen Arten ist die Host-Zuordnung strukturell 1:1.""" def __init__(self, *, host_id: int, kind: CredentialKind, credential_ids: list[int]): self.host_id = host_id self.kind = kind self.credential_ids = credential_ids super().__init__( f"Mehrdeutige Zugangsdaten (kind={kind}) fuer Host {host_id}: " f"mehrere fuer den Benutzer nutzbare Eintraege {credential_ids} -- " "keine automatische Auswahl." ) async def user_has_role( conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str ) -> bool: cursor = await conn.execute( """ SELECT 1 FROM group_hostgroup_roles ghr JOIN roles r ON r.id = ghr.role_id JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id WHERE ugm.user_id = ? AND ghr.host_group_id = ? AND r.name = ? AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) LIMIT 1 """, (user_id, host_group_id, role_name), ) row = await cursor.fetchone() return row is not None async def user_has_role_for_host( conn: aiosqlite.Connection, *, user_id: int, host_id: int, role_name: str ) -> bool: cursor = await conn.execute("SELECT host_group_id FROM hosts WHERE id = ?", (host_id,)) row = await cursor.fetchone() if row is None: return False return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name) async def user_host_group_ids_with_any_role( conn: aiosqlite.Connection, *, user_id: int, role_names: tuple[str, ...] ) -> set[int]: """Wie user_has_role(), aber fuer eine oder mehrere Rollen auf einmal und ueber ALLE Hostgruppen hinweg statt eine einzelne host_group_id zu pruefen -- fuer Uebersichtsabfragen (z.B. den Katalog, app/catalog/ routes.py), die sonst pro Hostgruppe/Host eine Einzelabfrage bräuchten. S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige Stelle, die RBAC ausserhalb hier nachbaut -- Aufrufer bauen sie nicht mehr selbst nach.""" if not role_names: return set() placeholders = ",".join("?" for _ in role_names) cursor = await conn.execute( f""" SELECT ghr.host_group_id FROM group_hostgroup_roles ghr JOIN roles r ON r.id = ghr.role_id JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id WHERE ugm.user_id = ? AND r.name IN ({placeholders}) AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) """, (user_id, *role_names), ) rows = await cursor.fetchall() return {row[0] for row in rows} async def user_can_use_credential( conn: aiosqlite.Connection, *, user_id: int, kind: CredentialKind, credential_id: int ) -> bool: """Achse B: darf dieser Benutzer diesen konkreten Zugangsdatensatz benutzen -- ueber die Mitgliedschaft in mindestens einer Benutzergruppe, der der Datensatz freigegeben wurde (group_*_grants, Migration 0018).""" table, col = _GRANT_TABLE[kind] cursor = await conn.execute( f""" SELECT 1 FROM {table} g JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id WHERE ugm.user_id = ? AND g.{col} = ? AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) LIMIT 1 """, (user_id, credential_id), ) row = await cursor.fetchone() return row is not None async def resolve_credential_for_user_on_host( conn: aiosqlite.Connection, *, user_id: int, host_id: int, kind: CredentialKind ) -> int | None: """Achse B: welcher Zugangsdatensatz der angegebenen Art gilt fuer diesen Benutzer auf diesem Host -- ersetzt die frueher benutzerblinden `LIMIT 1`-Abfragen (app/ssh_proxy/proxy.py, app/rdp_proxy/ws_tunnel.py). Liefert die Credential-ID, die (a) dem Host ueber die jeweilige host_*_map-Tabelle zugeordnet UND (b) mindestens einer Benutzergruppe dieses Users ueber Achse B freigegeben ist. None, wenn keine passt (kann zwei Ursachen haben: kein Credential dieser Art am Host ODER eines ist zugeordnet, aber keiner Gruppe des Benutzers freigegeben -- Aufrufer formulieren die Fehlermeldung entsprechend). Wirft AmbiguousCredentialError, wenn mehr als eine Credential-ID passt.""" map_table, map_col = _MAP_TABLE[kind] grant_table, grant_col = _GRANT_TABLE[kind] cursor = await conn.execute( f""" SELECT DISTINCT m.{map_col} FROM {map_table} m JOIN {grant_table} g ON g.{grant_col} = m.{map_col} JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id WHERE m.host_id = ? AND ugm.user_id = ? AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')) """, (host_id, user_id), ) rows = await cursor.fetchall() ids = [row[0] for row in rows] if not ids: return None if len(ids) > 1: raise AmbiguousCredentialError(host_id=host_id, kind=kind, credential_ids=ids) return ids[0]