feat(auth): FOUND-016 API-Token für Entwicklungs-/Tooling-Zugriff
CI / backend-tests (push) Successful in 3m57s
CI / frontend-build (push) Successful in 24s

Rollenbasiertes, langlebiges Token neben dem normalen JWT-Login-Flow -
für automatisierte Zugriffe/Skripte. Admin wählt bei Erzeugung eine oder
mehrere Rollen, Klartext wird einmalig gezeigt (wie ein Passwort), Token
ist jederzeit deaktivierbar/reaktivierbar.

Architektur: jedes Token bekommt einen Begleit-"Dienstkonto"-Benutzer
(Benutzer.ist_dienstkonto=True, kein echter Login), der die Rollen über
die bestehende benutzer_rolle-Tabelle trägt - dadurch funktionieren
require_roles()/require_permission() und alle Audit-FKs unverändert,
keine bestehende Endpunkt-Dependency musste angepasst werden.
get_current_user() erkennt ein Token am Präfix mabea_pat_ und löst es
über den Hash (SHA-256, kein bcrypt - Token ist bereits hochentropisch)
auf den Begleit-Benutzer auf. Dienstkonten sind aus GET /benutzer
ausgeblendet, damit sie keine Auswahl-Dropdowns verunreinigen.

Neuer Admin-Tab "API-Tokens" (Gruppe System, nur Administration).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-17 01:23:21 +02:00
1 parent dabe0dbe0c
commit a4d4119d55
17 files changed
+811 -3

No files matched your search

+75
View File
@@ -16263,3 +16263,78 @@ Keine Commits in dieser Session.
- backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++
---
## 2026-09-15 00:29 – 00:33 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 260e0f6 fix(zustaendigkeit): ZUST-004 Runde 7 - Lagerort-Lücken aus Runde 5 geschlossen
### Geänderte Dateien
- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++
- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++----
- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-15 00:34 – 00:34 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++
- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++----
- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-15 00:37 – 00:38 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++
- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++----
- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-15 00:39 – 00:40 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- dabe0db fix(lager): fehlenden Import standort_fuer_lagerort ergänzt
### Geänderte Dateien
- backend/app/api/v1/endpoints/lager.py | 1 +
---
## 2026-09-15 00:45 – 00:45 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/api/v1/endpoints/lager.py | 1 +
---
## 2026-09-15 01:06 – 01:06 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/api/v1/endpoints/lager.py | 1 +
---
+71
View File
@@ -303,6 +303,77 @@ detaillierten) siehe `00_index.md`.
Fallback bestehen, DB-Wert hat Vorrang. `send_default_pii=False` fest
verdrahtet (DSGVO). Tests: `test_found013_systemeinstellung.py`.
## FOUND-016 — API-Token für Entwicklungs-/Tooling-Zugriff (P2, neu, Nutzer-Anforderung 2026-09-17)
- **Ziel:** Langlebiger, rollenbasierter API-Zugriff ohne den normalen
Login-Flow (Passwort+JWT) - für Entwicklungs-/Tooling-Zwecke (z.B.
automatisierte Zugriffe/Skripte gegen die laufende API).
- **Bedarf: bestätigt (2026-09-17)** — Nutzer-Anforderung, Klärung per
Rückfrage: Zweck=Tooling-Zugriff, Rechte=frei wählbar bei Erzeugung,
Verwaltung=Admin-UI.
- **Beschreibung:** Admin legt ein Token mit Name + einer oder mehreren der
vier festen Rollen an, bekommt den Klartext EINMALIG angezeigt (wie ein
Passwort - danach nur noch Hash gespeichert), kann es jederzeit
deaktivieren/reaktivieren.
- **Benutzerwert:** Kein Nutzer-Login-Passwort für automatisierte Zugriffe
zweckentfremden/teilen, granular je Zweck deaktivierbar ohne einen
echten Benutzer-Account anzufassen.
- **Abhängigkeiten:** FOUND-003 (Authentication), FOUND-004 (Authorization)
- **Datenmodell:** `api_token` (id, name, token_hash, benutzer_id,
aktiv, erstellt_am, erstellt_von_benutzer_id, letzte_verwendung_am) +
`Benutzer.ist_dienstkonto` (bool). Bewusste Entscheidung: KEIN eigenes
Rollen-/Rechte-Konzept für Tokens - jedes Token bekommt einen
Begleit-„Dienstkonto"-Benutzer (`ist_dienstkonto=True`, kein echter
Login, Zufalls-Passwort-Hash), der die Rollen über die bestehende
`benutzer_rolle`-Tabelle trägt. Dadurch funktionieren
`require_roles()`/`require_permission()` und alle Audit-FKs
(`historie.benutzer_id`, `dokument.hochgeladen_von`, ...) unverändert -
keine einzige bestehende Endpunkt-Dependency musste angepasst werden.
- **Backend:** `GET/POST /api-tokens`, `PATCH /api-tokens/{id}` (aktiv
setzen). `app/api/deps.py:get_current_user()` erkennt ein API-Token am
festen Präfix `mabea_pat_` und löst es über
`app/services/api_token.py:hole_benutzer_fuer_token()` auf den
Begleit-Benutzer auf, statt den JWT-Pfad zu versuchen - kein teurer
"erst JWT probieren, dann Fallback"-Umweg. Hashing: SHA-256 (nicht
bcrypt) - das Token ist bereits hochentropisch (256 Bit Zufall über
`secrets.token_urlsafe`), kein Passwort mit Wörterbuch-/Brute-Force-
Risiko, ein schneller deterministischer Hash erlaubt den Lookup per
UNIQUE-Index statt bcrypt.verify() gegen jede Zeile einzeln.
`GET /benutzer` blendet Dienstkonten aus (`ist_dienstkonto=False`-Filter),
damit sie keine Kontrollverantwortung-/Zuständigkeit-Auswahl-Dropdowns im
Frontend verunreinigen.
- **Frontend:** neuer Admin-Tab „API-Tokens" (`ApiTokenSection.tsx`,
Gruppe „System", nur Administration) - Anlegen-Formular mit
Rollen-Checkboxen, zeigt den Klartext nach Erzeugung einmalig in einem
auffällig markierten Kasten mit Kopier-Button und Warnhinweis, Liste
bestehender Tokens mit Rollen/Erstellt-am/Letzte-Verwendung/Status-Badge
+ Aktivieren/Deaktivieren-Button.
- **Mobile:** keins (reiner Admin-/Tooling-Vorgang).
- **QR-Code/Seriennummer/Inventarnummer:** nein.
- **Akte:** nein.
- **Rechte:** Verwaltung (Anlegen/Deaktivieren/Liste) ausschließlich
Administration.
- **Audit:** `erstellt_von_benutzer_id`/`erstellt_am`/`letzte_verwendung_am`
je Token; kein separates Historie-Log-Event (Token-Erzeugung selbst ist
bereits über diese Felder nachvollziehbar) - P3-Detail, kein
Akzeptanzkriterium dieser Kachel.
- **Akzeptanzkriterien:** Token funktioniert als Bearer-Token mit exakt den
zugewiesenen Rollen; Deaktivieren sperrt den Zugriff sofort (401),
Reaktivieren stellt ihn wieder her; Dienstkonten erscheinen nicht in
`GET /benutzer`; ein frei erfundenes Token wird abgelehnt (401).
- **Tests:** `backend/tests/test_api_token.py` - Anlegen+Verwenden,
Ausblendung aus Benutzer-Liste, Deaktivieren sperrt Zugriff,
Reaktivieren stellt wieder her, ungültiges Token abgelehnt, Rollen
werden respektiert (403 bei fehlender Rolle), Verwaltung nur Admin,
Liste zeigt Rollen/Status korrekt.
- **DoD:** ✅ umgesetzt (2026-09-17). Migration `0061_api_token.py`.
`app/models/api_token.py`, `app/services/api_token.py`,
`app/schemas/api_token.py`, `app/api/v1/endpoints/api_token.py`,
`app/core/security.py` (`erzeuge_api_token()`/`hashe_api_token()`),
`app/api/deps.py` (Token-Erkennung per Präfix). Frontend:
`ApiTokenSection.tsx`, `AdminPage.tsx`-Tab, `api/types.ts`
(`ApiToken`/`ApiTokenCreateResponse`).
---
**MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-10):** FOUND-001…006 sind in
@@ -0,0 +1,40 @@
"""FOUND-016: API-Token für Entwicklungs-/Tooling-Zugriff, mit Deaktivierung.
Begleit-Benutzer (ist_dienstkonto=True) trägt die Rollen des Tokens über die
bestehende benutzer_rolle-Tabelle - kein eigenes Rollen-Konzept nötig.
Revision ID: 0061_api_token
Revises: 0060_lagerort_standort_id
Create Date: 2026-09-17
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0061_api_token"
down_revision: Union[str, None] = "0060_lagerort_standort_id"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE benutzer ADD COLUMN ist_dienstkonto BOOLEAN NOT NULL DEFAULT false")
op.execute(
"""
CREATE TABLE api_token (
id UUID PRIMARY KEY,
name VARCHAR NOT NULL,
token_hash VARCHAR NOT NULL UNIQUE,
benutzer_id INTEGER NOT NULL UNIQUE REFERENCES benutzer(id),
aktiv BOOLEAN NOT NULL DEFAULT true,
erstellt_am TIMESTAMPTZ NOT NULL,
erstellt_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id),
letzte_verwendung_am TIMESTAMPTZ
)
"""
)
def downgrade() -> None:
op.execute("DROP TABLE api_token")
op.execute("ALTER TABLE benutzer DROP COLUMN ist_dienstkonto")
+10 -1
View File
@@ -5,10 +5,11 @@ from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import InvalidTokenError, decode_access_token
from app.core.security import API_TOKEN_PRAEFIX, InvalidTokenError, decode_access_token
from app.db.session import get_db
from app.models.auth import Benutzer, RolleTyp
from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung
from app.services.api_token import hole_benutzer_fuer_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -22,6 +23,14 @@ async def get_current_user(
detail="Ungültiger oder abgelaufener Token",
headers={"WWW-Authenticate": "Bearer"},
)
# FOUND-016: API-Token statt JWT - eindeutig am Präfix erkennbar, kein
# teurer/fehlerträchtiger "erst JWT-Decode versuchen"-Umweg nötig.
if token.startswith(API_TOKEN_PRAEFIX):
benutzer = await hole_benutzer_fuer_token(db, token)
if benutzer is None:
raise credentials_error
return benutzer
try:
payload = decode_access_token(token)
except InvalidTokenError as exc:
+2
View File
@@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.api.v1.endpoints import (
akte,
api_token,
app_download,
ausgabe,
auth,
@@ -47,6 +48,7 @@ api_router.include_router(app_download.router, tags=["app_download"])
api_router.include_router(auth.router, tags=["auth"])
api_router.include_router(stammdaten.router, tags=["stammdaten"])
api_router.include_router(benutzer.router, tags=["benutzer"])
api_router.include_router(api_token.router, tags=["api_token"])
api_router.include_router(zustaendigkeit.router, tags=["zustaendigkeit"])
api_router.include_router(vorlagen.router, tags=["vorlagen"])
# import_export VOR objekte/geraet_instanz registriert: /objekte/export und
+76
View File
@@ -0,0 +1,76 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_roles
from app.db.session import get_db
from app.models.auth import Benutzer, RolleTyp
from app.schemas.api_token import ApiTokenAktivUpdate, ApiTokenCreate, ApiTokenCreateResponse, ApiTokenRead
from app.services.api_token import (
ApiTokenNichtGefundenError,
erstelle_token,
liste_tokens,
rollen_fuer_token,
setze_aktiv,
)
router = APIRouter()
_admin_only = require_roles(RolleTyp.administration)
@router.get("/api-tokens", response_model=list[ApiTokenRead])
async def hole_api_tokens(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[ApiTokenRead]:
ergebnisse = []
for token in await liste_tokens(db):
ergebnisse.append(
ApiTokenRead(
id=token.id,
name=token.name,
aktiv=token.aktiv,
erstellt_am=token.erstellt_am,
letzte_verwendung_am=token.letzte_verwendung_am,
rollen=await rollen_fuer_token(db, token),
)
)
return ergebnisse
@router.post("/api-tokens", response_model=ApiTokenCreateResponse, status_code=status.HTTP_201_CREATED)
async def erstelle_api_token(
payload: ApiTokenCreate,
db: AsyncSession = Depends(get_db),
current_user: Benutzer = Depends(_admin_only),
) -> ApiTokenCreateResponse:
"""FOUND-016: Klartext-Token wird NUR in dieser Antwort zurückgegeben -
danach ist nur noch der Hash gespeichert, kein Wiederherstellen möglich
(wie ein Passwort). Admin-UI muss den Wert sofort anzeigen/kopieren
lassen und darauf hinweisen, dass er nicht wieder abrufbar ist."""
token, klartext = await erstelle_token(
db, name=payload.name, rollen=payload.rollen, erstellt_von_benutzer_id=current_user.id
)
return ApiTokenCreateResponse(id=token.id, name=token.name, token=klartext, rollen=payload.rollen)
@router.patch("/api-tokens/{token_id}", response_model=ApiTokenRead)
async def aendere_api_token_status(
token_id: uuid.UUID,
payload: ApiTokenAktivUpdate,
db: AsyncSession = Depends(get_db),
_=Depends(_admin_only),
) -> ApiTokenRead:
"""Deaktivieren/Reaktivieren - kein Löschen, damit Audit-Historie
(erstellt_von, letzte_verwendung_am) nachvollziehbar bleibt."""
try:
token = await setze_aktiv(db, token_id, payload.aktiv)
except ApiTokenNichtGefundenError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API-Token nicht gefunden") from exc
return ApiTokenRead(
id=token.id,
name=token.name,
aktiv=token.aktiv,
erstellt_am=token.erstellt_am,
letzte_verwendung_am=token.letzte_verwendung_am,
rollen=await rollen_fuer_token(db, token),
)
+5 -1
View File
@@ -23,7 +23,11 @@ _lesend = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlich
@router.get("/benutzer", response_model=list[BenutzerRead])
async def liste_benutzer(db: AsyncSession = Depends(get_db), _=Depends(_lesend)) -> list[BenutzerRead]:
result = await db.execute(select(Benutzer))
"""FOUND-016: Dienstkonten (Begleit-Benutzer eines API-Tokens) tauchen
hier bewusst nicht auf - kein echter Login, würde nur Kontrollverant-
wortung-/Zuständigkeit-Auswahl-Dropdowns im Frontend verunreinigen.
Verwaltet werden sie ausschließlich über /api-tokens."""
result = await db.execute(select(Benutzer).where(Benutzer.ist_dienstkonto.is_(False)))
return [BenutzerRead.from_orm_benutzer(b) for b in result.scalars().all()]
+24
View File
@@ -1,3 +1,5 @@
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
import jwt
@@ -5,6 +7,11 @@ from passlib.context import CryptContext
from app.core.app_settings import settings
# FOUND-016: Präfix macht ein API-Token in Logs/Diffs sofort als solches
# erkennbar (analog GitHub Personal Access Tokens), erleichtert versehentlich
# committete Tokens per Secret-Scanner zu finden.
API_TOKEN_PRAEFIX = "mabea_pat_"
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Konstanter Dummy-Hash für Timing-Angriff-Schutz (jwt-expert-Review-Fund):
@@ -39,6 +46,23 @@ class InvalidTokenError(Exception):
pass
def erzeuge_api_token() -> tuple[str, str]:
"""FOUND-016: gibt (Klartext, Hash) zurück - Klartext wird NUR beim
Erzeugen einmal zurückgegeben, danach ist nur noch der Hash gespeichert
und der Klartext nirgends mehr rekonstruierbar (wie ein Passwort)."""
klartext = f"{API_TOKEN_PRAEFIX}{secrets.token_urlsafe(32)}"
return klartext, hashe_api_token(klartext)
def hashe_api_token(klartext: str) -> str:
# SHA-256 statt bcrypt: das Token selbst ist bereits hochentropisch
# (256 Bit Zufall), kein Passwort mit Wörterbuch-/Brute-Force-Risiko -
# ein schneller, deterministischer Hash erlaubt den Lookup per
# UNIQUE-Index (`SELECT ... WHERE token_hash = ?`) statt bcrypt.verify()
# gegen jede gespeicherte Zeile einzeln durchzuprobieren.
return hashlib.sha256(klartext.encode()).hexdigest()
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ApiToken(Base):
"""FOUND-016: langlebiges Token für Entwicklungs-/Tooling-Zugriff auf die
API (kein Nutzer-Login-Flow) - `benutzer_id` zeigt auf einen begleitenden
Dienstkonto-Benutzer (`Benutzer.ist_dienstkonto=True`), der die dem Token
zugewiesenen Rollen trägt. Nur `token_hash` wird gespeichert (SHA-256,
kein bcrypt nötig - hochentropisches Zufallstoken statt Passwort mit
Wörterbuch-/Brute-Force-Risiko, siehe app/core/security.py), der
Klartext wird ausschließlich einmalig bei der Erzeugung zurückgegeben."""
__tablename__ = "api_token"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String, nullable=False)
token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False)
benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False, unique=True)
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
erstellt_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
letzte_verwendung_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
+7
View File
@@ -52,6 +52,13 @@ class Benutzer(Base):
# aber nullable - ein Login ohne fachliche Person(Übergang) wäre sonst nicht
# anlegbar, wenn die Migration künftig übersprungen wird.
person_id: Mapped[int | None] = mapped_column(ForeignKey("person.id"))
# FOUND-016: Begleit-Benutzer für ein API-Token, kein echter Login (kein
# Mensch tippt hier ein Passwort ein) - trägt nur die dem Token
# zugewiesenen Rollen, damit require_roles()/require_permission()
# unverändert funktionieren und Audit-FKs (historie.benutzer_id etc.)
# auf eine echte Zeile zeigen. Aus normalen Benutzer-Listen/Pickern
# ausgeblendet (siehe liste_benutzer()).
ist_dienstkonto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin")
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.auth import RolleTyp
class ApiTokenCreate(BaseModel):
name: str
rollen: list[RolleTyp]
class ApiTokenCreateResponse(BaseModel):
id: uuid.UUID
name: str
token: str # Klartext - wird NUR in dieser Antwort einmalig übertragen
rollen: list[RolleTyp]
class ApiTokenRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
aktiv: bool
erstellt_am: datetime
letzte_verwendung_am: datetime | None
rollen: list[RolleTyp]
class ApiTokenAktivUpdate(BaseModel):
aktiv: bool
+95
View File
@@ -0,0 +1,95 @@
import secrets
import uuid
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import erzeuge_api_token, hash_password, hashe_api_token
from app.models.api_token import ApiToken
from app.models.auth import Benutzer, BenutzerRolle, RolleTyp
class ApiTokenNichtGefundenError(Exception):
pass
async def erstelle_token(
db: AsyncSession, *, name: str, rollen: list[RolleTyp], erstellt_von_benutzer_id: int
) -> tuple[ApiToken, str]:
"""FOUND-016: legt einen Begleit-Benutzer an (kein echter Login - Passwort
ist ein unverwendbarer Zufallswert, siehe Docstring ApiToken), der die
übergebenen Rollen über die bestehende benutzer_rolle-Tabelle trägt.
Gibt das Klartext-Token zusätzlich zurück - der Aufrufer (Endpoint) MUSS
es dem Admin einmalig zeigen, es ist danach nirgends mehr abrufbar."""
dienstkonto = Benutzer(
name=f"API-Token: {name}",
login=f"api-token:{uuid.uuid4()}",
passwort_hash=hash_password(secrets_urlsafe_unbenutzbar()),
aktiv=True,
ist_dienstkonto=True,
)
db.add(dienstkonto)
await db.flush()
for rolle in rollen:
db.add(BenutzerRolle(benutzer_id=dienstkonto.id, rolle=rolle))
klartext, token_hash = erzeuge_api_token()
token = ApiToken(
name=name,
token_hash=token_hash,
benutzer_id=dienstkonto.id,
aktiv=True,
erstellt_am=datetime.now(timezone.utc),
erstellt_von_benutzer_id=erstellt_von_benutzer_id,
)
db.add(token)
await db.flush()
return token, klartext
def secrets_urlsafe_unbenutzbar() -> str:
"""Eigene, benannte Hilfsfunktion statt `secrets.token_urlsafe()` inline -
macht an der Aufrufstelle klar, dass dieses "Passwort" bewusst nie
irgendwo eingegeben werden soll (Dienstkonto authentifiziert sich nur
über das API-Token, nie über Login/Passwort)."""
return secrets.token_urlsafe(32)
async def hole_benutzer_fuer_token(db: AsyncSession, klartext_token: str) -> Benutzer | None:
"""Zentrale Auth-Fallback-Funktion (app/api/deps.py): löst ein
API-Token-Klartext auf den zugehörigen Dienstkonto-Benutzer auf, sofern
Token UND Dienstkonto aktiv sind. Aktualisiert `letzte_verwendung_am`
bei jeder erfolgreichen Verwendung (Audit/Aufräum-Hinweis, welche Tokens
noch genutzt werden)."""
token_hash = hashe_api_token(klartext_token)
result = await db.execute(select(ApiToken).where(ApiToken.token_hash == token_hash))
token = result.scalar_one_or_none()
if token is None or not token.aktiv:
return None
benutzer = await db.get(Benutzer, token.benutzer_id)
if benutzer is None or not benutzer.aktiv:
return None
token.letzte_verwendung_am = datetime.now(timezone.utc)
await db.flush()
return benutzer
async def liste_tokens(db: AsyncSession) -> list[ApiToken]:
result = await db.execute(select(ApiToken).order_by(ApiToken.erstellt_am.desc()))
return list(result.scalars().all())
async def rollen_fuer_token(db: AsyncSession, token: ApiToken) -> list[RolleTyp]:
result = await db.execute(select(BenutzerRolle.rolle).where(BenutzerRolle.benutzer_id == token.benutzer_id))
return [row[0] for row in result.all()]
async def setze_aktiv(db: AsyncSession, token_id: uuid.UUID, aktiv: bool) -> ApiToken:
token = await db.get(ApiToken, token_id)
if token is None:
raise ApiTokenNichtGefundenError
token.aktiv = aktiv
await db.flush()
return token
+134
View File
@@ -0,0 +1,134 @@
import pytest
from tests.conftest import auth_header, login
@pytest.mark.asyncio
async def test_api_token_anlegen_und_verwenden(client, admin_user):
"""FOUND-016: Token wird einmalig im Klartext zurückgegeben und
funktioniert danach als Bearer-Token mit den zugewiesenen Rollen."""
admin_token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/api-tokens",
json={"name": "CI-Zugriff", "rollen": ["mitarbeiter"]},
headers=auth_header(admin_token),
)
assert erstellt.status_code == 201
body = erstellt.json()
assert body["token"].startswith("mabea_pat_")
assert body["rollen"] == ["mitarbeiter"]
klartext = body["token"]
# Funktioniert wie ein normaler Bearer-Token gegen einen mitarbeiter+-Endpunkt.
me = await client.get("/api/v1/objekte", headers=auth_header(klartext))
assert me.status_code == 200
@pytest.mark.asyncio
async def test_api_token_erscheint_nicht_in_benutzer_liste(client, admin_user):
admin_token = await login(client, "admin1")
await client.post(
"/api/v1/api-tokens", json={"name": "Verstecktes Token", "rollen": []}, headers=auth_header(admin_token)
)
liste = await client.get("/api/v1/benutzer", headers=auth_header(admin_token))
namen = {b["name"] for b in liste.json()}
assert not any("Verstecktes Token" in n for n in namen)
@pytest.mark.asyncio
async def test_api_token_deaktivieren_sperrt_zugriff(client, admin_user):
admin_token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/api-tokens",
json={"name": "Wird deaktiviert", "rollen": ["administration"]},
headers=auth_header(admin_token),
)
token_id = erstellt.json()["id"]
klartext = erstellt.json()["token"]
vorher = await client.get("/api/v1/objekte", headers=auth_header(klartext))
assert vorher.status_code == 200
deaktiviert = await client.patch(
f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token)
)
assert deaktiviert.status_code == 200
assert deaktiviert.json()["aktiv"] is False
nachher = await client.get("/api/v1/objekte", headers=auth_header(klartext))
assert nachher.status_code == 401
@pytest.mark.asyncio
async def test_api_token_reaktivieren_erlaubt_zugriff_wieder(client, admin_user):
admin_token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/api-tokens",
json={"name": "Reaktivierbar", "rollen": ["mitarbeiter"]},
headers=auth_header(admin_token),
)
token_id = erstellt.json()["id"]
klartext = erstellt.json()["token"]
await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token))
gesperrt = await client.get("/api/v1/objekte", headers=auth_header(klartext))
assert gesperrt.status_code == 401
await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": True}, headers=auth_header(admin_token))
wieder_erlaubt = await client.get("/api/v1/objekte", headers=auth_header(klartext))
assert wieder_erlaubt.status_code == 200
@pytest.mark.asyncio
async def test_ungueltiges_api_token_wird_abgelehnt(client):
response = await client.get("/api/v1/objekte", headers=auth_header("mabea_pat_frei-erfunden"))
assert response.status_code == 401
@pytest.mark.asyncio
async def test_api_token_rollen_werden_respektiert(client, admin_user):
"""Token mit mitarbeiter-Rolle darf keine admin-only-Aktion (Objekttyp
anlegen) - dieselbe require_roles()-Prüfung wie bei echten Nutzern."""
admin_token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/api-tokens",
json={"name": "Nur Mitarbeiter", "rollen": ["mitarbeiter"]},
headers=auth_header(admin_token),
)
klartext = erstellt.json()["token"]
verboten = await client.post(
"/api/v1/objekttypen",
json={"bereich_id": 1, "name": "Test", "typ": "lager"},
headers=auth_header(klartext),
)
assert verboten.status_code == 403
@pytest.mark.asyncio
async def test_api_token_liste_und_verwaltung_nur_admin(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
liste = await client.get("/api/v1/api-tokens", headers=auth_header(token))
assert liste.status_code == 403
erstellen = await client.post(
"/api/v1/api-tokens", json={"name": "x", "rollen": []}, headers=auth_header(token)
)
assert erstellen.status_code == 403
@pytest.mark.asyncio
async def test_api_token_liste_zeigt_rollen_und_status(client, admin_user):
admin_token = await login(client, "admin1")
await client.post(
"/api/v1/api-tokens",
json={"name": "Sichtbar", "rollen": ["materialverantwortlicher"]},
headers=auth_header(admin_token),
)
liste = await client.get("/api/v1/api-tokens", headers=auth_header(admin_token))
assert liste.status_code == 200
eintrag = next(t for t in liste.json() if t["name"] == "Sichtbar")
assert eintrag["rollen"] == ["materialverantwortlicher"]
assert eintrag["aktiv"] is True
assert eintrag["letzte_verwendung_am"] is None
+11
View File
@@ -1348,3 +1348,14 @@ Keine Commits in dieser Session.
- frontend/src/pages/admin/ObjekttypSection.tsx | 19 ++++++++++++++++++
---
## 2026-09-15 23:03 – 23:04 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- backend/app/api/v1/endpoints/lager.py | 1 +
---
+17
View File
@@ -519,6 +519,23 @@ export interface Me {
export type Rolle = "mitarbeiter" | "materialverantwortlicher" | "leitungsverantwortlicher" | "administration";
// FOUND-016
export interface ApiToken {
id: string;
name: string;
aktiv: boolean;
erstellt_am: string;
letzte_verwendung_am: string | null;
rollen: Rolle[];
}
export interface ApiTokenCreateResponse {
id: string;
name: string;
token: string;
rollen: Rolle[];
}
export interface Benutzer {
id: number;
name: string;
+5 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiRequest } from "../api/client";
import { ApiTokenSection } from "./admin/ApiTokenSection";
import { AusgabeSection } from "./admin/AusgabeSection";
import { useAuth } from "../auth/AuthContext";
import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Nummernschema, Objekt, Objekttyp, Person, Qualifikationstyp, Standort } from "../api/types";
@@ -59,7 +60,8 @@ type Tab =
| "sollmengenGruppen"
| "inventur"
| "fehlerTracking"
| "pruefarten";
| "pruefarten"
| "apiTokens";
type Gruppe = "stammdaten" | "personalUndRechte" | "betrieb" | "wartungUndPruefung" | "einsaetzeUndMeldungen" | "system";
@@ -158,6 +160,7 @@ const TABS: {
rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"],
},
{ key: "fehlerTracking", label: "Fehler-Tracking", gruppe: "system", rollen: ["administration"] },
{ key: "apiTokens", label: "API-Tokens", gruppe: "system", rollen: ["administration"] },
];
/**
@@ -367,6 +370,7 @@ export function AdminPage() {
)}
{aktiverTab === "inventur" && <InventurSection materialien={materialien} onFehler={setFehler} />}
{aktiverTab === "fehlerTracking" && <FehlerTrackingSection onFehler={setFehler} />}
{aktiverTab === "apiTokens" && <ApiTokenSection onFehler={setFehler} />}
{aktiverTab === "pruefarten" && <PruefartenSection objekttypen={objekttypen} onFehler={setFehler} />}
{aktiverTab === "verleih" && <VerleihSection objekte={objekte} onFehler={setFehler} />}
{aktiverTab === "eskalation" && <EskalationSection onFehler={setFehler} />}
@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { ApiToken, ApiTokenCreateResponse, Rolle } from "../../api/types";
interface Props {
onFehler: (text: string) => void;
}
const ALLE_ROLLEN: Rolle[] = [
"mitarbeiter",
"materialverantwortlicher",
"leitungsverantwortlicher",
"administration",
];
const ROLLE_LABEL: Record<Rolle, string> = {
mitarbeiter: "Mitarbeiter",
materialverantwortlicher: "Materialverantwortlicher",
leitungsverantwortlicher: "Leitungsverantwortlicher",
administration: "Administration",
};
// FOUND-016: API-Token für Entwicklungs-/Tooling-Zugriff, kein Nutzer-Login.
// Klartext wird nur einmalig bei der Erzeugung zurückgegeben (wie ein
// Passwort) - danach ist er nirgends mehr abrufbar, nur noch der Hash liegt
// in der DB.
export function ApiTokenSection({ onFehler }: Props) {
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [laedt, setLaedt] = useState(true);
const [name, setName] = useState("");
const [rollen, setRollen] = useState<Rolle[]>(["mitarbeiter"]);
const [wirdAngelegt, setWirdAngelegt] = useState(false);
const [neuesToken, setNeuesToken] = useState<ApiTokenCreateResponse | null>(null);
const [kopiertHinweis, setKopiertHinweis] = useState(false);
async function laden() {
setLaedt(true);
try {
setTokens(await apiRequest<ApiToken[]>("/api-tokens"));
} catch {
onFehler("API-Tokens konnten nicht geladen werden.");
} finally {
setLaedt(false);
}
}
useEffect(() => {
laden();
}, []);
function rolleUmschalten(rolle: Rolle) {
setRollen((bisherig) => (bisherig.includes(rolle) ? bisherig.filter((r) => r !== rolle) : [...bisherig, rolle]));
}
async function anlegen() {
if (!name.trim()) {
onFehler("Name eingeben.");
return;
}
setWirdAngelegt(true);
try {
const antwort = await apiRequest<ApiTokenCreateResponse>("/api-tokens", {
method: "POST",
body: { name, rollen },
});
setNeuesToken(antwort);
setKopiertHinweis(false);
setName("");
setRollen(["mitarbeiter"]);
await laden();
} catch {
onFehler("API-Token konnte nicht angelegt werden.");
} finally {
setWirdAngelegt(false);
}
}
async function statusAendern(token: ApiToken, aktiv: boolean) {
try {
await apiRequest(`/api-tokens/${token.id}`, { method: "PATCH", body: { aktiv } });
await laden();
} catch {
onFehler("Status konnte nicht geändert werden.");
}
}
async function kopieren(text: string) {
try {
await navigator.clipboard.writeText(text);
setKopiertHinweis(true);
} catch {
// Zwischenablage evtl. nicht verfügbar (z.B. kein HTTPS-Kontext) -
// Klartext bleibt trotzdem im Textfeld markierbar, kein Blocker.
}
}
return (
<section className="section">
<h2>API-Tokens</h2>
<p className="text-muted" style={{ marginTop: 0 }}>
Für Entwicklungs-/Tooling-Zugriff auf die API ohne Nutzer-Login. Ein Token erbt die hier
ausgewählten Rollen, genau wie ein Benutzer.
</p>
{neuesToken && (
<div className="card" style={{ borderColor: "var(--color-warning)", marginBottom: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Token „{neuesToken.name}" erstellt</h3>
<p style={{ color: "var(--color-danger)", fontWeight: "bold" }}>
Dieser Wert wird nur jetzt angezeigt und ist danach nicht mehr abrufbar. Jetzt kopieren!
</p>
<div className="row" style={{ gap: "0.5rem" }}>
<input className="input" readOnly value={neuesToken.token} style={{ flex: 1, fontFamily: "monospace" }} />
<button className="btn btn-primary" onClick={() => kopieren(neuesToken.token)}>
{kopiertHinweis ? "Kopiert ✓" : "Kopieren"}
</button>
</div>
<button className="btn btn-secondary" style={{ marginTop: "0.5rem" }} onClick={() => setNeuesToken(null)}>
Schließen
</button>
</div>
)}
<div className="card">
<h3 style={{ marginTop: 0 }}>Token anlegen</h3>
<div className="row" style={{ flexWrap: "wrap", alignItems: "center", gap: "0.75rem" }}>
<input
className="input"
placeholder="Name (z.B. Claude Code Dev-Zugriff)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{ALLE_ROLLEN.map((rolle) => (
<label key={rolle} className="row" style={{ alignItems: "center", gap: "0.3rem" }}>
<input type="checkbox" checked={rollen.includes(rolle)} onChange={() => rolleUmschalten(rolle)} />
{ROLLE_LABEL[rolle]}
</label>
))}
<button className="btn btn-primary" onClick={anlegen} disabled={!name.trim() || wirdAngelegt}>
Anlegen
</button>
</div>
</div>
<div className="card" style={{ marginTop: "1rem" }}>
<h3 style={{ marginTop: 0 }}>Bestehende Tokens</h3>
{laedt && <p className="text-muted">Lade…</p>}
{!laedt && tokens.length === 0 && <p className="text-muted">Keine API-Tokens angelegt.</p>}
{!laedt && tokens.length > 0 && (
<ul className="card-list">
{tokens.map((token) => (
<li key={token.id} className="row-between" style={{ padding: "0.4rem 0" }}>
<span>
<strong>{token.name}</strong> — {token.rollen.map((r) => ROLLE_LABEL[r]).join(", ") || "keine Rollen"}
<br />
<span className="text-muted" style={{ fontSize: "0.85rem" }}>
Erstellt: {new Date(token.erstellt_am).toLocaleString("de-DE")}
{token.letzte_verwendung_am
? ` · Zuletzt verwendet: ${new Date(token.letzte_verwendung_am).toLocaleString("de-DE")}`
: " · Noch nicht verwendet"}
</span>
</span>
<div className="row" style={{ alignItems: "center", gap: "0.5rem" }}>
<span className={`badge ${token.aktiv ? "badge-success" : "badge-neutral"}`}>
{token.aktiv ? "Aktiv" : "Deaktiviert"}
</span>
<button className="btn btn-secondary" onClick={() => statusAendern(token, !token.aktiv)}>
{token.aktiv ? "Deaktivieren" : "Aktivieren"}
</button>
</div>
</li>
))}
</ul>
)}
</div>
</section>
);
}