add admin stuff

This commit is contained in:
2026-08-20 10:43:19 +02:00
parent 7108d446d8
commit 91758a2701
19 changed files with 2514 additions and 85 deletions

View File

@ -1,25 +1,44 @@
"""
Administrative CRUD-API: User, Hostgruppen, Hosts, Rollenvergabe, SSH-Keys.
Administrative CRUD-API: User, Benutzergruppen, Hostgruppen, Hosts,
Rollenvergabe (an User UND an Benutzergruppen), SSH-Keys, API-Tokens.
Zustandsaendernde/-lesende Endpunkte sind entweder auf eine eingeloggte
Admin-Session ODER ein API-Token mit passendem Scope beschraenkt
(`require_admin_or_scope`, siehe app/auth/deps.py) und schreiben einen
Audit-Log-Eintrag (Konzept 4.7). Die Token-Verwaltung selbst
(/admin/tokens/*) laeuft bewusst ausschliesslich ueber `require_global_admin`
(reine Session-Aktion, kein Token-Bypass -- Privilege-Escalation-Schutz).
Alle zustandsaendernden Endpunkte sind auf globale Admins beschraenkt
(`require_global_admin`) und schreiben einen Audit-Log-Eintrag (Konzept 4.7).
Eine feingranulare, auf `admin_hostgroup` beschraenkte Admin-Rolle ist im
Datenmodell vorbereitet, wird hier aus Uebersichtlichkeitsgruenden aber nicht
vollstaendig verdrahtet -- siehe TODO-Markierungen fuer den naechsten Ausbauschritt.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.auth.deps import CurrentUser, require_global_admin
from app.auth.deps import CurrentUser, require_admin_or_scope, require_global_admin
from app.db import get_db
from app.models.schemas import (
ApiTokenCreateRequest,
GroupMemberRequest,
GroupRoleGrantRequest,
HostCreateRequest,
HostGroupCreateRequest,
RdpCredentialsRequest,
RoleGrantRequest,
SshKeyCreateRequest,
UserCreateRequest,
UserGroupCreateRequest,
)
from app.security.api_tokens import (
VALID_SCOPES,
generate_token,
hash_token,
token_prefix_for_display,
validate_scopes,
)
from app.security.audit import verify_chain, write_audit_event
from app.security.crypto import encrypt_secret
@ -37,7 +56,8 @@ def _client_ip(request: Request) -> str:
@router.post("/users", status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: UserCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
):
conn = get_db()
cursor = await conn.execute("SELECT 1 FROM users WHERE username = ?", (payload.username,))
@ -60,7 +80,7 @@ async def create_user(
@router.get("/users")
async def list_users(admin: CurrentUser = Depends(require_global_admin)):
async def list_users(admin: CurrentUser = Depends(require_admin_or_scope("users", "read"))):
conn = get_db()
cursor = await conn.execute(
"SELECT id, username, is_admin, is_active, totp_enrolled, created_at FROM users ORDER BY id"
@ -77,7 +97,8 @@ async def list_users(admin: CurrentUser = Depends(require_global_admin)):
@router.post("/users/{user_id}/deactivate")
async def deactivate_user(
user_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
user_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("users", "write")),
):
conn = get_db()
await conn.execute(
@ -92,11 +113,115 @@ async def deactivate_user(
return {"status": "ok"}
# --- Benutzergruppen (Teams) -------------------------------------------------
#
# "Verbindungen mit einer Gruppe teilen" = einer Benutzergruppe ueber
# /admin/group-roles/grant eine Rolle auf einer Hostgruppe geben -- jedes
# aktuelle und zukuenftige Mitglied erbt diese Rolle vollstaendig (siehe
# app/rbac.py: user_has_role() vereinigt direkte und Gruppen-Grants).
@router.post("/user-groups", status_code=status.HTTP_201_CREATED)
async def create_user_group(
payload: UserGroupCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
):
conn = get_db()
cursor = await conn.execute(
"SELECT 1 FROM user_groups WHERE name = ?", (payload.name,)
)
if await cursor.fetchone() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppenname existiert bereits")
cursor = await conn.execute(
"INSERT INTO user_groups (name, description) VALUES (?, ?)",
(payload.name, payload.description),
)
new_id = cursor.lastrowid
await write_audit_event(
conn, event_type="user_group_created", user_id=admin.id, client_ip=_client_ip(request),
details={"id": new_id, "name": payload.name},
)
await conn.commit()
return {"id": new_id, "name": payload.name}
@router.get("/user-groups")
async def list_user_groups(admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))):
conn = get_db()
cursor = await conn.execute(
"SELECT ug.id, ug.name, ug.description, ug.created_at, COUNT(ugm.user_id) "
"FROM user_groups ug LEFT JOIN user_group_members ugm ON ugm.user_group_id = ug.id "
"GROUP BY ug.id ORDER BY ug.name"
)
rows = await cursor.fetchall()
return [
{"id": r[0], "name": r[1], "description": r[2], "created_at": r[3], "member_count": r[4]}
for r in rows
]
@router.get("/user-groups/{group_id}/members")
async def list_group_members(
group_id: int, admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "read"))
):
conn = get_db()
cursor = await conn.execute(
"SELECT u.id, u.username, ugm.added_at FROM user_group_members ugm "
"JOIN users u ON u.id = ugm.user_id WHERE ugm.user_group_id = ? ORDER BY u.username",
(group_id,),
)
rows = await cursor.fetchall()
return [{"user_id": r[0], "username": r[1], "added_at": r[2]} for r in rows]
@router.post("/user-groups/{group_id}/members", status_code=status.HTTP_201_CREATED)
async def add_group_member(
group_id: int, payload: GroupMemberRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
):
conn = get_db()
group_cursor = await conn.execute("SELECT 1 FROM user_groups WHERE id = ?", (group_id,))
if await group_cursor.fetchone() is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,))
if await user_cursor.fetchone() is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
await conn.execute(
"INSERT OR IGNORE INTO user_group_members (user_group_id, user_id, added_by) VALUES (?, ?, ?)",
(group_id, payload.user_id, admin.id),
)
await write_audit_event(
conn, event_type="user_group_member_added", user_id=admin.id, client_ip=_client_ip(request),
details={"user_group_id": group_id, "target_user_id": payload.user_id},
)
await conn.commit()
return {"status": "ok"}
@router.delete("/user-groups/{group_id}/members/{user_id}")
async def remove_group_member(
group_id: int, user_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("user_groups", "write")),
):
conn = get_db()
await conn.execute(
"DELETE FROM user_group_members WHERE user_group_id = ? AND user_id = ?",
(group_id, user_id),
)
await write_audit_event(
conn, event_type="user_group_member_removed", user_id=admin.id, client_ip=_client_ip(request),
details={"user_group_id": group_id, "target_user_id": user_id},
)
await conn.commit()
return {"status": "ok"}
# --- Hostgruppen -------------------------------------------------------------
@router.post("/host-groups", status_code=status.HTTP_201_CREATED)
async def create_host_group(
payload: HostGroupCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: HostGroupCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "write")),
):
conn = get_db()
cursor = await conn.execute(
@ -113,7 +238,7 @@ async def create_host_group(
@router.get("/host-groups")
async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
async def list_host_groups(admin: CurrentUser = Depends(require_admin_or_scope("host_groups", "read"))):
conn = get_db()
cursor = await conn.execute("SELECT id, name, description FROM host_groups ORDER BY id")
rows = await cursor.fetchall()
@ -124,7 +249,8 @@ async def list_host_groups(admin: CurrentUser = Depends(require_global_admin)):
@router.post("/hosts", status_code=status.HTTP_201_CREATED)
async def create_host(
payload: HostCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: HostCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
):
conn = get_db()
cursor = await conn.execute(
@ -153,7 +279,10 @@ async def create_host(
@router.get("/hosts")
async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depends(require_global_admin)):
async def list_hosts(
host_group_id: int | None = None,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read")),
):
conn = get_db()
if host_group_id is not None:
cursor = await conn.execute(
@ -177,7 +306,8 @@ async def list_hosts(host_group_id: int | None = None, admin: CurrentUser = Depe
@router.post("/hosts/{host_id}/discover-host-key")
async def discover_host_key(
host_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
host_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
):
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
@ -196,7 +326,7 @@ async def discover_host_key(
@router.put("/hosts/{host_id}/rdp-credentials")
async def set_rdp_credentials(
host_id: int, payload: RdpCredentialsRequest, request: Request,
admin: CurrentUser = Depends(require_global_admin),
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
):
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
@ -217,11 +347,12 @@ async def set_rdp_credentials(
return {"status": "ok"}
# --- Rollenvergabe -------------------------------------------------------------
# --- Rollenvergabe (an einzelne User) -----------------------------------------
@router.post("/roles/grant")
async def grant_role(
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: RoleGrantRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
):
conn = get_db()
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
@ -247,7 +378,8 @@ async def grant_role(
@router.post("/roles/revoke")
async def revoke_role(
payload: RoleGrantRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: RoleGrantRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
):
conn = get_db()
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
@ -270,11 +402,114 @@ async def revoke_role(
return {"status": "ok"}
@router.get("/roles")
async def list_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
conn = get_db()
cursor = await conn.execute(
"""
SELECT u.id, u.username, hg.id, hg.name, r.name, uhr.granted_by, uhr.granted_at, uhr.expires_at
FROM user_hostgroup_roles uhr
JOIN users u ON u.id = uhr.user_id
JOIN host_groups hg ON hg.id = uhr.host_group_id
JOIN roles r ON r.id = uhr.role_id
ORDER BY u.username, hg.name, r.name
"""
)
rows = await cursor.fetchall()
return [
{
"user_id": r[0], "username": r[1], "host_group_id": r[2], "host_group_name": r[3],
"role_name": r[4], "granted_by": r[5], "granted_at": r[6], "expires_at": r[7],
}
for r in rows
]
# --- Rollenvergabe (an Benutzergruppen) ---------------------------------------
@router.post("/group-roles/grant")
async def grant_group_role(
payload: GroupRoleGrantRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
):
conn = get_db()
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
role_row = await role_cursor.fetchone()
if role_row is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
await conn.execute(
"INSERT OR REPLACE INTO group_hostgroup_roles "
"(user_group_id, host_group_id, role_id, granted_by, expires_at) VALUES (?, ?, ?, ?, ?)",
(payload.user_group_id, payload.host_group_id, role_row[0], admin.id, payload.expires_at),
)
await write_audit_event(
conn, event_type="group_role_granted", user_id=admin.id, client_ip=_client_ip(request),
details={
"user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id,
"role": payload.role_name, "expires_at": payload.expires_at,
},
)
await conn.commit()
return {"status": "ok"}
@router.post("/group-roles/revoke")
async def revoke_group_role(
payload: GroupRoleGrantRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("roles", "write")),
):
conn = get_db()
role_cursor = await conn.execute("SELECT id FROM roles WHERE name = ?", (payload.role_name,))
role_row = await role_cursor.fetchone()
if role_row is None:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unbekannte Rolle")
await conn.execute(
"DELETE FROM group_hostgroup_roles WHERE user_group_id = ? AND host_group_id = ? AND role_id = ?",
(payload.user_group_id, payload.host_group_id, role_row[0]),
)
await write_audit_event(
conn, event_type="group_role_revoked", user_id=admin.id, client_ip=_client_ip(request),
details={
"user_group_id": payload.user_group_id, "host_group_id": payload.host_group_id,
"role": payload.role_name,
},
)
await conn.commit()
return {"status": "ok"}
@router.get("/group-roles")
async def list_group_role_grants(admin: CurrentUser = Depends(require_admin_or_scope("roles", "read"))):
conn = get_db()
cursor = await conn.execute(
"""
SELECT ug.id, ug.name, hg.id, hg.name, r.name, ghr.granted_by, ghr.granted_at, ghr.expires_at
FROM group_hostgroup_roles ghr
JOIN user_groups ug ON ug.id = ghr.user_group_id
JOIN host_groups hg ON hg.id = ghr.host_group_id
JOIN roles r ON r.id = ghr.role_id
ORDER BY ug.name, hg.name, r.name
"""
)
rows = await cursor.fetchall()
return [
{
"user_group_id": r[0], "user_group_name": r[1], "host_group_id": r[2],
"host_group_name": r[3], "role_name": r[4], "granted_by": r[5],
"granted_at": r[6], "expires_at": r[7],
}
for r in rows
]
# --- SSH-Keyverwaltung ---------------------------------------------------------
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
async def create_ssh_key(
payload: SshKeyCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
payload: SshKeyCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
):
"""Nimmt einen privaten Schluessel entgegen, verschluesselt ihn sofort mit
dem KEK (AES-256-GCM) und haelt den Klartext nur fuer die Dauer dieses
@ -295,9 +530,27 @@ async def create_ssh_key(
return {"id": new_id, "label": payload.label}
@router.get("/ssh-keys")
async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))):
conn = get_db()
cursor = await conn.execute(
"SELECT id, label, owner_user_id, key_type, created_at, rotated_at, expires_at "
"FROM ssh_keys ORDER BY id"
)
rows = await cursor.fetchall()
return [
{
"id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3],
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6],
}
for r in rows
]
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
async def map_ssh_key_to_host(
host_id: int, key_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
host_id: int, key_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
):
conn = get_db()
await conn.execute(
@ -312,11 +565,97 @@ async def map_ssh_key_to_host(
return {"status": "ok"}
# --- API-Tokens ------------------------------------------------------------
#
# Bewusst ausschliesslich ueber require_global_admin (Session), NIE ueber
# require_admin_or_scope -- ein geleaktes Token darf sich damit nicht selbst
# weitere/staerkere Tokens ausstellen (Privilege-Escalation-Schutz).
@router.get("/scopes")
async def list_valid_scopes(admin: CurrentUser = Depends(require_global_admin)):
return {"scopes": sorted(VALID_SCOPES)}
@router.post("/tokens", status_code=status.HTTP_201_CREATED)
async def create_api_token(
payload: ApiTokenCreateRequest, request: Request, admin: CurrentUser = Depends(require_global_admin)
):
try:
scopes = validate_scopes(payload.scopes)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
conn = get_db()
user_cursor = await conn.execute("SELECT 1 FROM users WHERE id = ?", (payload.user_id,))
if await user_cursor.fetchone() is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
token = generate_token()
cursor = await conn.execute(
"INSERT INTO api_tokens (user_id, label, token_hash, token_prefix, scopes_json, "
"created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
payload.user_id, payload.label, hash_token(token), token_prefix_for_display(token),
json.dumps(scopes), admin.id, payload.expires_at,
),
)
new_id = cursor.lastrowid
await write_audit_event(
conn, event_type="api_token_created", user_id=admin.id, client_ip=_client_ip(request),
details={
"token_id": new_id, "target_user_id": payload.user_id, "label": payload.label,
"scopes": scopes, "expires_at": payload.expires_at,
},
)
await conn.commit()
# Klartext-Token wird NUR in dieser einen Response zurueckgegeben -- danach
# ist nur noch der Hash in der DB, das Token ist nicht mehr rekonstruierbar.
return {"id": new_id, "token": token, "prefix": token_prefix_for_display(token), "scopes": scopes}
@router.get("/tokens")
async def list_api_tokens(admin: CurrentUser = Depends(require_global_admin)):
conn = get_db()
cursor = await conn.execute(
"SELECT t.id, t.user_id, u.username, t.label, t.token_prefix, t.scopes_json, "
"t.created_at, t.expires_at, t.last_used_at, t.revoked_at "
"FROM api_tokens t JOIN users u ON u.id = t.user_id ORDER BY t.id DESC"
)
rows = await cursor.fetchall()
return [
{
"id": r[0], "user_id": r[1], "username": r[2], "label": r[3], "prefix": r[4],
"scopes": json.loads(r[5]), "created_at": r[6], "expires_at": r[7],
"last_used_at": r[8], "revoked_at": r[9],
}
for r in rows
]
@router.post("/tokens/{token_id}/revoke")
async def revoke_api_token(
token_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
):
conn = get_db()
await conn.execute(
"UPDATE api_tokens SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') "
"WHERE id = ? AND revoked_at IS NULL",
(token_id,),
)
await write_audit_event(
conn, event_type="api_token_revoked", user_id=admin.id, client_ip=_client_ip(request),
details={"token_id": token_id},
)
await conn.commit()
return {"status": "ok"}
# --- Audit-Log -----------------------------------------------------------------
@router.get("/audit-log")
async def get_audit_log(
limit: int = 100, offset: int = 0, admin: CurrentUser = Depends(require_global_admin)
limit: int = 100, offset: int = 0,
admin: CurrentUser = Depends(require_admin_or_scope("audit", "read")),
):
limit = max(1, min(limit, 1000))
conn = get_db()
@ -333,7 +672,7 @@ async def get_audit_log(
@router.get("/audit-log/verify")
async def verify_audit_log(admin: CurrentUser = Depends(require_global_admin)):
async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("audit", "read"))):
"""Prueft die Hash-Chain auf Manipulationsfreiheit (Konzept 6.1/4.7)."""
conn = get_db()
intact, broken_at = await verify_chain(conn)

View File

@ -1,12 +1,15 @@
"""FastAPI-Dependencies fuer Authentifizierung und Autorisierung."""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Cookie, Depends, HTTPException, Request, Response, WebSocket, status
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
from app.db import get_db
from app.rbac import user_has_role, user_has_role_for_host
from app.security.api_tokens import TOKEN_PREFIX, hash_token, token_has_scope
from app.security.sessions import (
SESSION_COOKIE_NAME,
decode_session_token,
@ -120,3 +123,77 @@ def require_host_role(role_name: str):
return user
return _dep
# --- API-Tokens (siehe app/security/api_tokens.py, Migration 0005) -------------
#
# Tokens sind bewusst NUR fuer die Admin-/Verwaltungs-API gedacht (Hosts,
# 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.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
if not token.startswith(TOKEN_PREFIX):
return None
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, "
"u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id WHERE at.token_hash = ?",
(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
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")
if expires_at is not None and expires_at <= now_str:
return None
scopes = json.loads(scopes_json)
if not token_has_scope(scopes, resource, action):
return None
await conn.execute(
"UPDATE api_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
(token_id,),
)
await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False)
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)."""
async def _dep(
response: Response,
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
authorization: str | None = Header(default=None),
) -> CurrentUser:
if authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
principal = await _validate_api_token(token, resource=resource, action=action)
if principal is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
)
return principal
user = await get_current_user(response, jh_session)
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
return _dep

View File

@ -1,5 +1,7 @@
"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine
Rolle in der jeweiligen Hostgruppe vergeben hat (Konzept 4.6)."""
Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept
4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle
Rollen-Vererbung, siehe app/rbac.py)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
@ -32,18 +34,31 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
cursor = await conn.execute(
"""
SELECT DISTINCT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id,
SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id,
g.name, h.clipboard_enabled, h.file_transfer_enabled
FROM hosts h
JOIN host_groups g ON g.id = h.host_group_id
JOIN user_hostgroup_roles uhr ON uhr.host_group_id = h.host_group_id
JOIN roles r ON r.id = uhr.role_id
WHERE h.is_active = 1 AND uhr.user_id = ?
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
WHERE h.is_active = 1
AND (
EXISTS (
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 = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
OR EXISTS (
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 = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
)
ORDER BY g.name, h.hostname
""",
(user.id,),
(user.id, user.id),
)
rows = await cursor.fetchall()
hosts = [dict(zip(
@ -53,12 +68,18 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
ft_cursor = await conn.execute(
"""
SELECT DISTINCT uhr.host_group_id FROM user_hostgroup_roles uhr
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
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 = 'file_transfer'
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id,),
(user.id, user.id),
)
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
for h in hosts:

View File

@ -0,0 +1,35 @@
-- Benutzergruppen (Teams): Rollen koennen einer ganzen Gruppe auf einer
-- Hostgruppe gewaehrt werden ("Verbindungen mit einer Gruppe teilen"),
-- statt jedes Mitglied einzeln ueber user_hostgroup_roles pflegen zu
-- muessen. Volle Rollen-Vererbung: jedes aktuelle und zukuenftige
-- Mitglied der Gruppe erhaelt automatisch alle der Gruppe gewaehrten
-- Rollen (Durchsetzung in app/rbac.py: user_has_role() vereinigt direkte
-- User-Grants mit Grants ueber Gruppenmitgliedschaft).
CREATE TABLE IF NOT EXISTS user_groups (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_by INTEGER REFERENCES users(id),
added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (user_group_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ugm_user ON user_group_members(user_id);
CREATE TABLE IF NOT EXISTS group_hostgroup_roles (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
host_group_id INTEGER NOT NULL REFERENCES host_groups(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, host_group_id, role_id)
);
CREATE INDEX IF NOT EXISTS idx_ghr_group ON group_hostgroup_roles(user_group_id);
CREATE INDEX IF NOT EXISTS idx_ghr_hostgroup ON group_hostgroup_roles(host_group_id);

View File

@ -0,0 +1,26 @@
-- API-Tokens mit granularem Scope (ro/rw je Ressourcengruppe) fuer
-- programmatischen Zugriff auf die Admin-/Verwaltungs-API, unabhaengig von
-- der Browser-Session. Bewusst NICHT nutzbar fuer SSH/RDP/SFTP-Sitzungen
-- selbst (Terminal-/RDP-WebSocket und Dateitransfer bleiben ausschliesslich
-- an eine eingeloggte, TOTP-verifizierte Session gebunden -- siehe
-- app/auth/deps.py) und NICHT nutzbar, um weitere Tokens zu verwalten
-- (Privilege-Escalation-Schutz: Token-Verwaltung selbst bleibt reine
-- Session-Admin-Aktion).
--
-- Nur der Hash des Tokens wird gespeichert (siehe app/security/api_tokens.py,
-- analog zu Recovery-Codes); das Klartext-Token wird nur einmalig bei der
-- Erzeugung angezeigt und ist danach nicht mehr rekonstruierbar.
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);

View File

@ -7,13 +7,14 @@ from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.admin.routes import router as admin_router
from app.auth.deps import get_current_user
from app.auth.deps import CurrentUser, require_global_admin
from app.auth.routes import router as auth_router
from app.catalog.routes import router as catalog_router
from app.db import close_db, init_db
@ -84,6 +85,15 @@ async def dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard.html", {})
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request):
# Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die
# Admin-Pruefung (is_admin) erfolgt clientseitig ueber GET /auth/me
# (Redirect zu /dashboard falls kein Admin) UND serverseitig hart auf
# jedem einzelnen /admin/*-API-Aufruf (require_admin_or_scope).
return templates.TemplateResponse(request, "admin.html", {})
@app.get("/terminal/{host_id}", response_class=HTMLResponse)
async def terminal_page(request: Request, host_id: int):
return templates.TemplateResponse(request, "terminal.html", {"host_id": host_id})
@ -97,3 +107,24 @@ async def rdp_page(request: Request, host_id: int):
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
# --- API-Dokumentation --------------------------------------------------------
#
# docs_url/redoc_url/openapi_url sind am FastAPI()-Konstruktor bewusst
# deaktiviert (siehe oben) -- eine oeffentlich erreichbare API-Uebersicht
# waere auf einem oeffentlich exponierten Jumphost unnoetige Informations-
# preisgabe (Konzept 6.6). Stattdessen: eigene, auf eingeloggte Admins
# beschraenkte Routen. Bewusst KEIN vendored/CDN-bezogenes Swagger-UI-Bundle
# (Konzept 4.1/6.6: kein Laufzeit-CDN-Bezug im Browser) -- stattdessen eine
# schlanke selbstgebaute Ansicht (templates/api_docs.html +
# static/js/api-docs.js), die /openapi.json clientseitig ausliest.
@app.get("/openapi.json", include_in_schema=False)
async def protected_openapi_schema(admin: CurrentUser = Depends(require_global_admin)):
return get_openapi(title=app.title, version=app.version, routes=app.routes)
@app.get("/docs", response_class=HTMLResponse, include_in_schema=False)
async def protected_api_docs(request: Request, admin: CurrentUser = Depends(require_global_admin)):
return templates.TemplateResponse(request, "api_docs.html", {})

View File

@ -93,6 +93,32 @@ class RoleGrantRequest(BaseModel):
expires_at: str | None = None
class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class GroupMemberRequest(BaseModel):
user_id: int
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",
]
expires_at: str | None = None
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
class SshKeyCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128)
owner_user_id: int | None = None

View File

@ -1,4 +1,10 @@
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6)."""
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).
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."""
from __future__ import annotations
import aiosqlite
@ -15,9 +21,17 @@ async def user_has_role(
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
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),
(user_id, host_group_id, role_name, user_id, host_group_id, role_name),
)
row = await cursor.fetchone()
return row is not None

View File

@ -0,0 +1,65 @@
"""
API-Tokens mit granularem Scope (siehe app/db/migrations/0005_api_tokens.sql).
Format: "jht_<43 zeichen urlsicherer Zufalls-String>" (secrets.token_urlsafe(32)).
Wie Recovery-Codes (app/security/totp.py) wird nur ein gepfefferter SHA-256-Hash
gespeichert -- das Klartext-Token existiert nur einmalig im Response-Body der
Erzeugung und ist danach serverseitig nicht mehr rekonstruierbar.
Scope-Modell: "<ressource>:<aktion>" mit aktion in {read, write}. "write"
impliziert automatisch "read" auf derselben Ressource (siehe token_has_scope).
"""
from __future__ import annotations
import hashlib
import secrets
TOKEN_PREFIX = "jht_"
_TOKEN_PEPPER = b"api_token_pepper"
SCOPE_RESOURCES = (
"users",
"host_groups",
"hosts",
"ssh_keys",
"roles",
"user_groups",
"audit",
)
SCOPE_ACTIONS = ("read", "write")
# Alle gueltigen "<ressource>:<aktion>"-Strings, z.B. "hosts:read".
VALID_SCOPES = frozenset(
f"{resource}:{action}" for resource in SCOPE_RESOURCES for action in SCOPE_ACTIONS
)
def generate_token() -> str:
return TOKEN_PREFIX + secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode() + _TOKEN_PEPPER).hexdigest()
def token_prefix_for_display(token: str) -> str:
"""Kurzer, nicht-geheimer Praefix zur Wiedererkennung in der UI (kein
Rueckschluss auf das volle Token moeglich, siehe Recovery-Code-Analogie)."""
return token[: len(TOKEN_PREFIX) + 8]
def validate_scopes(scopes: list[str]) -> list[str]:
"""Wirft ValueError bei unbekannten Scopes, sonst normalisierte, deduplizierte Liste."""
unknown = sorted(set(scopes) - VALID_SCOPES)
if unknown:
raise ValueError(f"Unbekannte Scope(s): {', '.join(unknown)}")
return sorted(set(scopes))
def token_has_scope(granted_scopes: list[str], resource: str, action: str) -> bool:
"""'write' schliesst 'read' auf derselben Ressource automatisch mit ein."""
if f"{resource}:{action}" in granted_scopes:
return True
if action == "read" and f"{resource}:write" in granted_scopes:
return True
return False