add admin stuff
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
35
app/db/migrations/0004_user_groups.sql
Normal file
35
app/db/migrations/0004_user_groups.sql
Normal 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);
|
||||
26
app/db/migrations/0005_api_tokens.sql
Normal file
26
app/db/migrations/0005_api_tokens.sql
Normal 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);
|
||||
35
app/main.py
35
app/main.py
@ -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", {})
|
||||
|
||||
@ -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
|
||||
|
||||
18
app/rbac.py
18
app/rbac.py
@ -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
|
||||
|
||||
65
app/security/api_tokens.py
Normal file
65
app/security/api_tokens.py
Normal 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
|
||||
Reference in New Issue
Block a user