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

191 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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]