more admin stuff

This commit is contained in:
2026-08-20 14:49:19 +02:00
parent 91758a2701
commit 0e67092ba0
14 changed files with 3030 additions and 247 deletions

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,7 +22,25 @@ from app.security.sessions import (
class CurrentUser:
id: int
username: str
is_admin: bool
is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
token_tenant_id: int | None = None
@property
def is_any_admin(self) -> bool:
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
return self.is_admin or bool(self.tenant_admin_ids)
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
rows = await cursor.fetchall()
return frozenset(r[0] for r in rows)
async def get_current_user(
@ -39,7 +57,8 @@ async def get_current_user(
conn = get_db()
cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
"SELECT id, username, is_admin, is_active, session_version FROM users "
"WHERE id = ? AND deleted_at IS NULL",
(payload.user_id,),
)
row = await cursor.fetchone()
@ -59,7 +78,8 @@ async def get_current_user(
path="/",
)
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
@ -75,7 +95,8 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
conn = get_db()
cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, session_version FROM users WHERE id = ?",
"SELECT id, username, is_admin, is_active, session_version FROM users "
"WHERE id = ? AND deleted_at IS NULL",
(payload.user_id,),
)
row = await cursor.fetchone()
@ -85,7 +106,18 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
return user
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
if not user.is_any_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
@ -131,10 +163,12 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin).
# Die Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# require_global_admin (Session), niemals ueber ein Token -- ein geleaktes
# Token darf sich damit nicht selbst weitere/staerkere Tokens ausstellen.
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
# ausschliesslich ueber require_admin_session (Session, Super- ODER
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich
# damit nicht selbst weitere/staerkere Tokens ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
@ -143,15 +177,16 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db()
token_hash = hash_token(token)
cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, "
"u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?",
"FROM api_tokens at JOIN users u ON u.id = at.user_id "
"WHERE at.token_hash = ? AND u.deleted_at IS NULL",
(token_hash,),
)
row = await cursor.fetchone()
if row is None:
return None
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row
if revoked_at is not None or not is_active:
return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@ -166,15 +201,17 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
(token_id,),
)
await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False)
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Cookie, wie bisher) ODER ueber ein
API-Token mit passendem Scope (Header 'Authorization: Bearer <token>').
'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope)."""
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
async def _dep(
response: Response,
@ -192,8 +229,25 @@ def require_admin_or_scope(resource: str, action: str):
return principal
user = await get_current_user(response, jh_session)
if not user.is_admin:
if not user.is_any_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
return _dep
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
Mandanten-IDs, auf die der Principal beschraenkt ist:
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
require_admin_session ohnehin nie passieren -- kommt hier praktisch
nicht an."""
if admin.token_tenant_id is not None:
return {admin.token_tenant_id}
if admin.is_admin:
return None
return set(admin.tenant_admin_ids)

View File

@ -327,4 +327,19 @@ async def change_password(
@router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)):
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
tenant_admin_of = []
if user.tenant_admin_ids:
conn = get_db()
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
cursor = await conn.execute(
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
tuple(user.tenant_admin_ids),
)
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"is_any_admin": user.is_any_admin,
"tenant_admin_of": tenant_admin_of,
}

View File

@ -0,0 +1,65 @@
-- Mandantenfaehigkeit (volle Isolation, siehe Konzept-Erweiterung dieser
-- Session): jede Hostgruppe, Benutzergruppe, jeder SSH-Key und jedes
-- API-Token gehoert genau einem Mandanten. Ein Benutzer kann Mandanten-Admin
-- fuer einen oder mehrere Mandanten sein (tenant_admins) -- er sieht/
-- verwaltet dann AUSSCHLIESSLICH Ressourcen seines/seiner Mandanten
-- (Durchsetzung zentral in app/tenancy.py + app/auth/deps.py, angewendet in
-- jedem Endpunkt in app/admin/routes.py). Das bestehende users.is_admin
-- bleibt unveraendert bestehen und wird zum "Super-Admin": sieht/verwaltet
-- weiterhin ALLE Mandanten uebergreifend (volle Abwaertskompatibilitaet zu
-- allen bisherigen Admin-Workflows dieser App).
--
-- Rueckwirkende Kompatibilitaet: ein Standard-Mandant (id=1) wird automatisch
-- angelegt und alle VOR dieser Migration bestehenden Hostgruppen/
-- Benutzergruppen/SSH-Keys/API-Tokens werden ihm zugeordnet -- fuer
-- Super-Admins aendert sich dadurch am sichtbaren Verhalten nichts.
CREATE TABLE IF NOT EXISTS tenants (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT OR IGNORE INTO tenants (id, name, description) VALUES
(1, 'Standard', 'Automatisch angelegter Standard-Mandant (Daten von vor der Einfuehrung der Mandantenfaehigkeit).');
-- Mandanten-Admin-Zuweisung ist bewusst eine eigene Tabelle (statt eines
-- einzelnen users.tenant_id) -- ein User kann Mandanten-Admin fuer MEHRERE
-- Mandanten sein (z.B. externer Dienstleister fuer mehrere Kunden), ohne
-- gleich globaler Super-Admin zu sein. Nur ein Super-Admin darf diese
-- Zuweisung vornehmen (require_global_admin, siehe admin/routes.py).
CREATE TABLE IF NOT EXISTS tenant_admins (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_id, tenant_id)
);
CREATE INDEX IF NOT EXISTS idx_tenant_admins_user ON tenant_admins(user_id);
ALTER TABLE host_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
ALTER TABLE user_groups ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
ALTER TABLE ssh_keys ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
-- api_tokens: bewusst NICHT nullable -- jedes Token (auch von einem
-- Super-Admin erzeugte) ist genau einem Mandanten zugeordnet. Ein Token ist
-- (wie schon vor dieser Migration) nie is_admin=True; ohne verpflichtenden
-- Mandantenbezug waere unklar, welche Mandanten-Ressourcen es sehen darf --
-- lieber explizit pro Mandant ein Token ausstellen als eine mehrdeutige
-- "gilt ueberall"-Sonderregel einzufuehren (Prinzip: fail closed).
ALTER TABLE api_tokens ADD COLUMN tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id);
-- users.home_tenant_id: rein informativ/UX -- der Mandant, "fuer" den ein
-- Benutzer angelegt wurde (automatisch gesetzt, wenn ein Mandanten-Admin den
-- User anlegt), damit ein frisch angelegter User sofort in der Mandanten-
-- Admin-Sicht auftaucht, auch bevor er einer Gruppe zugeordnet oder ihm eine
-- Rolle gewaehrt wurde. Erzwingt KEINE Zugriffsbeschraenkung selbst -- die
-- eigentliche Sichtbarkeit/Isolation ergibt sich weiterhin aus Rollen-
-- Vergabe auf Hostgruppen eines Mandanten (siehe app/tenancy.py).
ALTER TABLE users ADD COLUMN home_tenant_id INTEGER REFERENCES tenants(id);
CREATE INDEX IF NOT EXISTS idx_host_groups_tenant ON host_groups(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_groups_tenant ON user_groups(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ssh_keys_tenant ON ssh_keys(tenant_id);
CREATE INDEX IF NOT EXISTS idx_api_tokens_tenant ON api_tokens(tenant_id);
CREATE INDEX IF NOT EXISTS idx_users_home_tenant ON users(home_tenant_id);

View File

@ -0,0 +1,14 @@
-- Ergaenzung fuer vollstaendige Loesch-/Editierbarkeit (siehe app/admin/routes.py):
--
-- users.deleted_at: ein Benutzerkonto kann nicht immer per hartem SQL-DELETE
-- entfernt werden, weil audit_log.user_id (bewusst, siehe 0001_initial.sql)
-- OHNE ON DELETE CASCADE auf users(id) verweist -- das manipulationssichere
-- Audit-Log darf durch das Loeschen eines Kontos nicht nachtraeglich
-- veraendert oder seiner Zuordnung beraubt werden (jede Aenderung an
-- audit_log-Zeilen wuerde die Hash-Chain brechen bzw. ist durch DB-Trigger
-- ohnehin hart verboten). Ein Konto mit vorhandener Audit-Historie (praktisch
-- jedes jemals eingeloggte Konto) wird deshalb beim "Loeschen" stattdessen
-- deaktiviert, anonymisiert (Benutzername/Passwort/TOTP-Secret geloescht)
-- und hier markiert; nur ein Konto OHNE jede Audit-Historie wird tatsaechlich
-- per SQL-DELETE entfernt. Siehe delete_user() in app/admin/routes.py.
ALTER TABLE users ADD COLUMN deleted_at TEXT;

View File

@ -15,6 +15,11 @@ from pydantic import BaseModel, Field, field_validator
USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
ROLE_NAME = Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
class LoginRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
@ -46,6 +51,11 @@ class UserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
initial_password: str = Field(min_length=12, max_length=256)
is_admin: bool = False
# Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird
# serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe
# create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber
# KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional.
home_tenant_id: int | None = None
@field_validator("username")
@classmethod
@ -55,9 +65,38 @@ class UserCreateRequest(BaseModel):
return v
class UserUpdateRequest(BaseModel):
is_admin: bool | None = None
is_active: bool | None = None
new_password: str | None = Field(default=None, min_length=12, max_length=256)
class TenantCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
is_active: bool | None = None
class TenantAdminAssignRequest(BaseModel):
user_id: int
class HostGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
# Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig
# ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py).
tenant_id: int | None = None
class HostGroupUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class HostCreateRequest(BaseModel):
@ -83,19 +122,49 @@ class HostCreateRequest(BaseModel):
return v
class HostUpdateRequest(BaseModel):
host_group_id: int | None = None
hostname: str | None = Field(default=None, min_length=1, max_length=128)
address: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, gt=0, le=65535)
ssh_username: str | None = None
rdp_username: str | None = None
rdp_domain: str | None = None
rdp_require_nla: bool | None = None
clipboard_enabled: bool | None = None
file_transfer_enabled: bool | None = None
is_active: bool | None = None
@field_validator("hostname")
@classmethod
def check_hostname(cls, v: str | None) -> str | None:
if v is not None and not HOSTNAME_LABEL_RE.match(v):
raise ValueError("Ungueltiger Hostname")
return v
class RoleGrantRequest(BaseModel):
user_id: int
host_group_id: int
role_name: Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
expires_at: str | None = None
class RoleRevokeRequest(BaseModel):
user_id: int
host_group_id: int
role_name: ROLE_NAME
class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
tenant_id: int | None = None
class UserGroupUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class GroupMemberRequest(BaseModel):
@ -105,18 +174,24 @@ class GroupMemberRequest(BaseModel):
class GroupRoleGrantRequest(BaseModel):
user_group_id: int
host_group_id: int
role_name: Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
]
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
expires_at: str | None = None
class GroupRoleRevokeRequest(BaseModel):
user_group_id: int
host_group_id: int
role_name: ROLE_NAME
class ApiTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128)
user_id: int
scopes: list[str] = Field(min_length=1, max_length=32)
expires_at: str | None = None
# Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig
# ohnehin auf seinen eigenen Mandanten festgelegt.
tenant_id: int | None = None
class SshKeyCreateRequest(BaseModel):
@ -125,6 +200,18 @@ class SshKeyCreateRequest(BaseModel):
private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
class SshKeyUpdateRequest(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=128)
owner_user_id: int | None = None
# Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial,
# rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen
# angegeben werden (siehe rotate_ssh_key() in admin/routes.py).
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
class ConnectRequest(BaseModel):

97
app/tenancy.py Normal file
View File

@ -0,0 +1,97 @@
"""
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}