191 lines
8.2 KiB
Python
191 lines
8.2 KiB
Python
"""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]
|