feat(admin): DELETE /api/admin/users/{id} — Nutzer und alle Daten löschen

- Store.delete_user() löscht User + Sessions + Nachrichten + Erinnerungen + Nutzung
  (atomic, anonymer Nutzer geschützt)
- DELETE /api/admin/users/{user_id} (Admin-Key erforderlich)
  → 200 {"deleted":"..."} | 404 | 400 (anonymous)
- BEDIENUNGSANLEITUNG: $URL-Erklärung am Anfang, § 7.2 vollständig mit
  Anlegen/Anzeigen/Löschen-Beispielen inkl. realer Beispiel-Antworten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-18 17:23:25 +02:00
commit 4970bdc85a
3 changed files with 127 additions and 11 deletions

View file

@ -6,12 +6,26 @@
> Remote-Deployment: [deploy/README.md](deploy/README.md) · > Remote-Deployment: [deploy/README.md](deploy/README.md) ·
> Kurzübersicht: [README.md](README.md) > Kurzübersicht: [README.md](README.md)
In den Shell-Beispielen steht die Gateway-Adresse als Variable — einmal setzen, ### Lesehilfe: `$URL` und `| jq`
dann überall einsetzbar (Port aus deiner `.env`, hier `8003`):
In allen Shell-Beispielen dieses Handbuchs steht `$URL` als Platzhalter für die
Gateway-Adresse. Einmal setzen, dann überall einsetzbar:
```bash ```bash
export URL=http://localhost:8003 export URL=http://localhost:8003
``` ```
Befehle mit JSON-Ausgabe enden auf `| jq` (schöne Formatierung). Installieren: `sudo apt install jq`.
*(Port aus deiner `.env` — Standard ist `8080`, in dieser Installation `8003`.)*
Danach kann man z. B. schreiben:
```bash
curl -s $URL/health
# entspricht: curl -s http://localhost:8003/health
```
Befehle, die JSON zurückgeben, enden auf `| jq` — das formatiert die Ausgabe lesbar.
Installieren: `sudo apt install jq`. Ohne `jq` einfach weglassen; der Befehl
funktioniert trotzdem, die Ausgabe ist dann unformatiert.
--- ---
@ -916,27 +930,92 @@ AUTH_ENABLED=false # Lokal/Entwicklung: anonymer Standardnutzer, kein Token n
Geschützte Endpunkte: `chat`, `speak`, `transcribe`, `sessions`, `me`. Geschützte Endpunkte: `chat`, `speak`, `transcribe`, `sessions`, `me`.
### 7.2 Nutzer anlegen und Tokens ### 7.2 Nutzer anlegen, anzeigen und löschen
**Voraussetzung:** `ADMIN_API_KEY` muss beim Gateway-Start als Umgebungsvariable gesetzt sein.
Einmal setzen (gilt für alle folgenden Befehle im Terminal):
```bash ```bash
export ADMIN_API_KEY=ein-langes-geheimnis # muss beim Gateway-Start gesetzt sein export ADMIN_API_KEY=mein-langes-geheimnis
```
# Nutzer anlegen (Token erscheint NUR einmal — sicher aufbewahren!): #### Nutzer anlegen
```bash
curl -s -X POST $URL/api/admin/users \ curl -s -X POST $URL/api/admin/users \
-H "X-Admin-Key: $ADMIN_API_KEY" \ -H "X-Admin-Key: $ADMIN_API_KEY" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"display_name":"Oma Anna"}' | jq -d '{"display_name":"Oma Anna"}' | jq
# → {"user_id":"…","display_name":"Oma Anna","token":"…"} ```
# Mit Token aufrufen: Beispiel-Antwort:
TOKEN=<token-von-oben> ```json
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"token": "va-tok-AbCdEfGh12345..."
}
```
> ⚠️ Das Token erscheint **nur einmal** — sofort sicher aufbewahren (z. B. in einem
> Passwort-Manager). Es kann danach nicht mehr abgerufen werden. Bei Verlust muss
> der Nutzer gelöscht und neu angelegt werden.
Das Token dem Nutzer mitteilen. Er gibt es bei jedem Aufruf im `Authorization`-Header an:
```bash
TOKEN=va-tok-AbCdEfGh12345... # einmal setzen
curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq curl -s $URL/api/me -H "Authorization: Bearer $TOKEN" | jq
# → {"user_id":"a3f8c1d2e4b7…","display_name":"Oma Anna","prefs":{}}
```
# Alle Nutzer anzeigen (Admin): #### Alle Nutzer anzeigen
```bash
curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" | jq curl -s $URL/api/admin/users -H "X-Admin-Key: $ADMIN_API_KEY" | jq
``` ```
**Dauerhafte Präferenzen** pro Nutzer (Ebene zwischen Profil und Session): Beispiel-Antwort:
```json
[
{
"user_id": "a3f8c1d2e4b7...",
"display_name": "Oma Anna",
"external_id": null,
"created_at": "2026-06-18T10:00:00+00:00"
},
{
"user_id": "b9e2f5a1c6d3...",
"display_name": "Herr Müller",
"external_id": null,
"created_at": "2026-06-18T11:30:00+00:00"
}
]
```
#### Nutzer löschen
Löscht den Nutzer **und alle seine Daten** (Sessions, Gesprächsverlauf, Erinnerungen,
Nutzungsstatistik) unwiderruflich.
```bash
USER_ID=a3f8c1d2e4b7... # user_id aus der Liste oben
curl -s -X DELETE $URL/api/admin/users/$USER_ID \
-H "X-Admin-Key: $ADMIN_API_KEY" | jq
```
Beispiel-Antwort bei Erfolg:
```json
{ "deleted": "a3f8c1d2e4b7..." }
```
Nutzer nicht gefunden → HTTP 404:
```json
{ "detail": "Nutzer 'xyz' nicht gefunden." }
```
#### Dauerhafte Nutzerpräferenzen setzen
```bash ```bash
curl -s -X PUT $URL/api/me/prefs \ curl -s -X PUT $URL/api/me/prefs \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
@ -1436,6 +1515,7 @@ Body-Felder: `input_endpoint`, `output_endpoint`, `stt_provider`, `llm_provider`
|---------|------|------|--------------| |---------|------|------|--------------|
| `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig | | `POST` | `/api/admin/users` | `X-Admin-Key` | Nutzer anlegen → Token einmalig |
| `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten | | `GET` | `/api/admin/users` | `X-Admin-Key` | Alle Nutzer auflisten |
| `DELETE` | `/api/admin/users/{user_id}` | `X-Admin-Key` | Nutzer + alle Daten löschen |
## B.6 WebSocket ## B.6 WebSocket

View file

@ -46,6 +46,19 @@ async def create_user(payload: UserCreate):
return UserCreated(user_id=user.id, display_name=user.display_name, token=token) return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
@router.delete("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
async def delete_user(user_id: str):
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
Nutzungsdaten). Der anonyme Nutzer kann nicht geloescht werden."""
try:
deleted = get_store().delete_user(user_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not deleted:
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
return {"deleted": user_id}
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)]) @router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
async def list_users(): async def list_users():
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY.""" """Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""

View file

@ -118,6 +118,12 @@ class Store(ABC):
def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int: def add_usage(self, user_id: str, units: int = 0, day: str | None = None) -> int:
"""Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl.""" """Zaehlt eine Anfrage (+units) und liefert die neue Tages-Anfragezahl."""
@abstractmethod
def delete_user(self, user_id: str) -> bool:
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
Nutzungsdaten). Anonymer Nutzer kann nicht geloescht werden.
Liefert True, wenn der Nutzer existierte und geloescht wurde."""
@abstractmethod @abstractmethod
def log_emergency(self, user_id: str, category: str, snippet: str) -> None: def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
"""Protokolliert ein erkanntes Notfall-Signal (sensibel!).""" """Protokolliert ein erkanntes Notfall-Signal (sensibel!)."""
@ -422,6 +428,23 @@ class SQLiteStore(Store):
).fetchone() ).fetchone()
return int(row["requests"]) return int(row["requests"])
def delete_user(self, user_id: str) -> bool:
if user_id == ANONYMOUS_USER_ID:
raise ValueError("Der anonyme Nutzer kann nicht geloescht werden.")
with self._connect() as conn:
if not conn.execute("SELECT 1 FROM users WHERE id = ?", (user_id,)).fetchone():
return False
conn.execute(
"DELETE FROM messages WHERE session_id IN"
" (SELECT id FROM sessions WHERE user_id = ?)",
(user_id,),
)
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM memories WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM usage WHERE user_id = ?", (user_id,))
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
return True
# ----- Notfall-Protokoll ------------------------------------------------ # ----- Notfall-Protokoll ------------------------------------------------
def log_emergency(self, user_id: str, category: str, snippet: str) -> None: def log_emergency(self, user_id: str, category: str, snippet: str) -> None:
with self._connect() as conn: with self._connect() as conn: