umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -1,27 +1,87 @@
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6) sowie, seit
Teil D Schritt 4, Achse B (Benutzergruppe × Zugangsdatensatz).
Eine Rolle gilt fuer einen User entweder, wenn sie ihm DIREKT vergeben wurde
(user_hostgroup_roles), ODER wenn sie einer Benutzergruppe (user_groups)
vergeben wurde, in der der User Mitglied ist (group_hostgroup_roles) --
volle Rollen-Vererbung: jedes Gruppenmitglied erhaelt automatisch alle der
Gruppe gewaehrten Rollen, ohne individuellen Eintrag."""
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 user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ?
AND uhr.host_group_id = ?
AND r.name = ?
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
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
@ -31,7 +91,7 @@ async def user_has_role(
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, user_id, host_group_id, role_name),
(user_id, host_group_id, role_name),
)
row = await cursor.fetchone()
return row is not None
@ -45,3 +105,86 @@ async def user_has_role_for_host(
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]