more admin stuff
This commit is contained in:
1012
app/admin/routes.py
1012
app/admin/routes.py
File diff suppressed because it is too large
Load Diff
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
65
app/db/migrations/0006_tenants.sql
Normal file
65
app/db/migrations/0006_tenants.sql
Normal 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);
|
||||
14
app/db/migrations/0007_crud_extras.sql
Normal file
14
app/db/migrations/0007_crud_extras.sql
Normal 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;
|
||||
@ -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
97
app/tenancy.py
Normal 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}
|
||||
Reference in New Issue
Block a user