feat: Cloud-Fundament - Auth, Persistenz und Mandanten-Trennung
- SQLite-Store (app/store.py) hinter Store-Interface: Nutzer + Sessions persistent - Bearer-Token-Auth (app/auth.py); Nutzerverwaltung via Admin-Key (POST /api/admin/users) - GET /api/me, PUT /api/me/prefs (dauerhafte Nutzer-Praeferenzen) - chat/speak/transcribe/sessions auth-geschuetzt; Mandanten-Trennung (fremde Session -> 403) - Route-Aufloesung: Defaults < Profil < ENV < Nutzer-Prefs < Session < Request - SessionManager (in-memory) durch Store ersetzt - AUTH_ENABLED-Schalter (prod an, dev/Tests aus); DB_PATH/ADMIN_API_KEY - Doku aktualisiert (README, BEDIENUNGSANLEITUNG, Architektur, deploy-env); data/ gitignored - Tests: 29 gruen (Auth, Mandanten, Persistenz, Routing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
293ed257db
commit
e0e69fdf15
22 changed files with 625 additions and 57 deletions
|
|
@ -7,6 +7,13 @@ LOG_LEVEL=info
|
||||||
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
|
# Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# --- Authentifizierung -----------------------------------------------------
|
||||||
|
# AUTH_ENABLED=true (Standard) schuetzt chat/speak/transcribe/sessions per Bearer-Token.
|
||||||
|
# Fuer lokale Entwicklung/Tests auf false setzen (dann gilt ein anonymer Nutzer).
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
# Schluessel fuer die Nutzerverwaltung (POST /api/admin/users). Nur ueber die Umgebung.
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|
||||||
# --- Zentrale Konfiguration / Profile -------------------------------------
|
# --- Zentrale Konfiguration / Profile -------------------------------------
|
||||||
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
|
# Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud
|
||||||
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.
|
# (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE.
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@
|
||||||
# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert)
|
# Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert)
|
||||||
config/voice-assistant.toml
|
config/voice-assistant.toml
|
||||||
|
|
||||||
|
# Persistente Daten (SQLite-DB etc.)
|
||||||
|
data/
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
|
||||||
|
|
@ -196,11 +196,66 @@ Start mit klarer Meldung ab.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Fehlerbehebung
|
## 10. Authentifizierung & Mehrbenutzer
|
||||||
|
|
||||||
|
Im Produktivbetrieb ist `AUTH_ENABLED=true` (Standard). Dann brauchen
|
||||||
|
`chat`/`speak`/`transcribe`/`sessions`/`me` ein **Bearer-Token pro Nutzer**.
|
||||||
|
Nutzer und Sessions werden in einer SQLite-Datei gespeichert (`DB_PATH`, Standard
|
||||||
|
`data/voice-assistant.db`).
|
||||||
|
|
||||||
|
**Schritt 1 — Admin-Schlüssel setzen** (nur über die Umgebung):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ADMIN_API_KEY=ein-langes-geheimnis
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 2 — Nutzer anlegen** (Token erscheint **nur einmal**, sicher notieren):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8080/api/admin/users \
|
||||||
|
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"display_name":"Oma Anna"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schritt 3 — mit Token nutzen:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=<das-token-von-oben>
|
||||||
|
curl http://localhost:8080/api/me -H "Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
curl -X POST http://localhost:8080/api/speak \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"text":"Guten Morgen!"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dauerhafte Vorlieben** eines Nutzers (Gerät/Provider/Sprache) setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8080/api/me/prefs \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"language":"de","llm_provider":"openrouter"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Vorlieben gelten automatisch für alle Aufrufe dieses Nutzers (Ebene zwischen
|
||||||
|
Profil und Session). Eine fremde Session zu nutzen, wird mit `403` abgelehnt.
|
||||||
|
|
||||||
|
> **Für lokale Entwicklung** ist in der mitgelieferten `.env` `AUTH_ENABLED=false`
|
||||||
|
> gesetzt — dann ist kein Token nötig (anonymer Nutzer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fehlerbehebung
|
||||||
|
|
||||||
| Symptom | Ursache | Lösung |
|
| Symptom | Ursache | Lösung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OPENROUTER_API_KEY is empty` / 401 | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` |
|
| `OPENROUTER_API_KEY is empty` | Key nicht in der Umgebung | `export OPENROUTER_API_KEY=…`, neues Terminal / `source ~/.bashrc` |
|
||||||
|
| HTTP **401** „Bearer token required/Invalid token" | Auth an, Token fehlt/falsch | gültiges Token im Header `Authorization: Bearer …`, oder `AUTH_ENABLED=false` für dev |
|
||||||
|
| HTTP **401** bei `/api/admin/users` | falscher/fehlender Admin-Key | `X-Admin-Key` mit `ADMIN_API_KEY` abgleichen |
|
||||||
|
| HTTP **403** bei `?session_id=…` | Session gehört anderem Nutzer | eigene `session_id` verwenden |
|
||||||
|
| HTTP **503** bei `/api/admin/users` | `ADMIN_API_KEY` nicht gesetzt | Admin-Key in der Umgebung setzen |
|
||||||
| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen |
|
| HTTP **422** „Unbekannter …-Provider/Endpunkt" | Tippfehler in `*_provider` / `*_endpoint` | gültige Werte via `GET /api/config` prüfen |
|
||||||
| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren |
|
| `VA_PROFILE` wirkt nicht | `DEFAULT_*_PROVIDER` in `.env` überschreibt es | diese Zeilen in `.env` auskommentieren |
|
||||||
| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen |
|
| LLM-Timeout / Connection refused (lokal) | lokaler LLM-Server (Port 11434) läuft nicht | LLM-Server starten oder Profil `cloud` wählen |
|
||||||
|
|
@ -213,7 +268,7 @@ Logs erscheinen im Terminal, in dem `make run` läuft. Für mehr Details
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Tests ausführen
|
## 12. Tests ausführen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test
|
make test
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ einstellbar; höhere Ebene gewinnt:
|
||||||
|
|
||||||
```
|
```
|
||||||
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
|
eingebaute Defaults < config/voice-assistant.toml (inkl. aktivem Profil)
|
||||||
< ENV / .env < Session-Route < Request
|
< ENV / .env < Nutzer-Prefs < Session-Route < Request
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.1 Zentrale Config + Profile
|
### 3.1 Zentrale Config + Profile
|
||||||
|
|
@ -64,9 +64,10 @@ werden aus echter Umgebung **oder** `.env` gelesen.
|
||||||
### 3.2 Einheitliche Route-Auflösung
|
### 3.2 Einheitliche Route-Auflösung
|
||||||
|
|
||||||
`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
|
`app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf
|
||||||
(`resolve_route(session_id, overrides)`): Defaults < Session-Route < Request.
|
(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs <
|
||||||
Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`,
|
Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`,
|
||||||
`llm_provider`, `tts_provider`, `language`.
|
`stt_provider`, `llm_provider`, `tts_provider`, `language`. Gehört eine Session
|
||||||
|
einem anderen Nutzer, wird `SessionOwnershipError` (→ 403) ausgelöst.
|
||||||
|
|
||||||
### 3.3 Registry-Pattern (Provider austauschbar)
|
### 3.3 Registry-Pattern (Provider austauschbar)
|
||||||
|
|
||||||
|
|
@ -162,6 +163,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware).
|
||||||
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
||||||
| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session |
|
| `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session |
|
||||||
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
||||||
|
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
|
||||||
|
| `GET /api/me` · `PUT /api/me/prefs` | aktueller Nutzer + dauerhafte Präferenzen |
|
||||||
|
|
||||||
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
|
Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
|
||||||
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
|
(`?session_id=…`) und **Defaults/Profil** steuerbar. Verwendete Route erscheint als
|
||||||
|
|
@ -172,7 +175,9 @@ Endpunkt-/Provider-Auswahl ist über **Request-Body** (pro Aufruf), **Session**
|
||||||
**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
|
**Umgesetzt:** FastAPI-Gateway, alle o. g. REST-Endpunkte; OpenRouter-Adapter für
|
||||||
STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
|
STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte
|
||||||
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
|
Pipeline; geschichtete Config + Profile; Registry + einheitliche Route-Auflösung;
|
||||||
Device Router (strikt, Singleton); Output-Lifecycle; 22 automatisierte Tests.
|
Device Router (strikt, Singleton); Output-Lifecycle; **Authentifizierung
|
||||||
|
(Bearer-Token) + persistenter SQLite-Store für Nutzer/Sessions + Mandanten-Trennung
|
||||||
|
+ dauerhafte Nutzer-Präferenzen**; automatisierte Tests.
|
||||||
|
|
||||||
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
|
**Platzhalter (Gerüst):** Audio-Endpunkte (`local-default`, `bluetooth`,
|
||||||
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
|
`mobile-ws`, `mobile-webrtc`) liefern leere Chunks — nur Auswahl/Lifecycle sind
|
||||||
|
|
@ -185,8 +190,8 @@ nicht aktiv (lokal/remote trägt vorerst der Provider-Name).
|
||||||
Reihenfolge der Weiterentwicklung:
|
Reihenfolge der Weiterentwicklung:
|
||||||
|
|
||||||
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
|
1. **(erledigt)** Konfig- & Routing-Fundament: Profile, Device Router, Registry, Pro-Request-Override.
|
||||||
2. **Cloud-Fundament:** Authentifizierung + Mehrbenutzer + persistenter Session-/Profil-Store (heute ist `SessionManager` in-memory → nicht skalierend, ohne Auth).
|
2. **(erledigt)** Cloud-Fundament: Bearer-Token-Auth, Mehrbenutzer, persistenter SQLite-Store, Mandanten-Trennung, dauerhafte Nutzer-Präferenzen. Offen: Skalierung auf gemeinsamen Store (Postgres/Redis) für mehrere Instanzen.
|
||||||
3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos).
|
3. **Konversationsgedächtnis:** Verlauf + Langzeit-Erinnerungen (heute ist `llm.complete` zustandslos; Nutzer-Präferenzen sind als Basis vorhanden).
|
||||||
4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager.
|
4. **Echtzeit:** Streaming-STT/TTS, WebSocket/WebRTC-Endpunkte real, Barge-in, Turn-Manager.
|
||||||
5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing.
|
5. **Resilienz:** Fallback-Policy (remote KI fällt aus → lokaler/alternativer Provider), Metriken/Tracing.
|
||||||
6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext).
|
6. **Betrieb:** Kosten-/Quota-Kontrolle pro Nutzer; Notfall-/Eskalationskonzept (Senioren-Kontext).
|
||||||
|
|
@ -203,15 +208,18 @@ voice-assistant-scaffold/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py # FastAPI-App + Router-Registrierung
|
│ ├── main.py # FastAPI-App + Router-Registrierung
|
||||||
│ ├── config.py # Settings, TOML-Profile, Präzedenz
|
│ ├── config.py # Settings, TOML-Profile, Präzedenz
|
||||||
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Singleton-Router
|
│ ├── dependencies.py # Registries, ResolvedRoute, resolve_route, Store-/Router-Singleton
|
||||||
|
│ ├── store.py # Persistenz: Store-Interface + SQLiteStore (Nutzer/Sessions)
|
||||||
|
│ ├── auth.py # Bearer-Token-Auth (require_user) + Admin-Schutz
|
||||||
│ ├── errors.py # RoutingError -> HTTP 422
|
│ ├── errors.py # RoutingError -> HTTP 422
|
||||||
│ ├── schemas.py # Pydantic-Modelle
|
│ ├── schemas.py # Pydantic-Modelle
|
||||||
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config
|
│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me
|
||||||
│ ├── core/ # orchestrator, session_manager
|
│ ├── core/ # orchestrator
|
||||||
│ ├── audio/ # router, transport_router, endpoints/input|output/*
|
│ ├── audio/ # router, transport_router, endpoints/input|output/*
|
||||||
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer
|
│ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer
|
||||||
│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs)
|
│ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs)
|
||||||
├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored)
|
├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored)
|
||||||
|
├── data/ # SQLite-DB (gitignored)
|
||||||
├── deploy/ # systemd unit + env-Beispiel
|
├── deploy/ # systemd unit + env-Beispiel
|
||||||
├── tests/ # config-profile, routing, audio-router, e2e
|
├── tests/ # config-profile, routing, audio-router, e2e
|
||||||
├── Docs/ # dieses Dokument
|
├── Docs/ # dieses Dokument
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -14,7 +14,8 @@ Praktische Bedienung: [`BEDIENUNGSANLEITUNG.md`](BEDIENUNGSANLEITUNG.md).
|
||||||
- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
|
- **Pipeline mit getrennter Semantik/Sprache:** STT → Input-Cleaner → LLM → Spoken-Adapter → TTS-Normalizer → TTS
|
||||||
- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs)
|
- **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs)
|
||||||
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`)
|
- **Geschichtete Konfiguration** mit Profilen (`local-dev` / `hybrid` / `cloud`)
|
||||||
- **Routing auf jeder Ebene:** Default → Profil → ENV → Session → Request
|
- **Routing auf jeder Ebene:** Default → Profil → Nutzer → Session → Request
|
||||||
|
- **Authentifizierung** (Bearer-Token) + persistente Nutzer/Sessions (SQLite)
|
||||||
- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
|
- **REST-API** für Chat, Transkription, Sprachausgabe, Geräte, Sessions, Config
|
||||||
- **Ohne Secrets im Code** — API-Keys nur über die Umgebung
|
- **Ohne Secrets im Code** — API-Keys nur über die Umgebung
|
||||||
|
|
||||||
|
|
@ -80,6 +81,9 @@ Aktive Konfiguration prüfen: `curl http://localhost:8080/api/config`.
|
||||||
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
| `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities |
|
||||||
| `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen |
|
| `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen |
|
||||||
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
| `GET /api/config` | aktives Profil + aufgelöste Route (ohne Secrets) |
|
||||||
|
| `POST /api/admin/users` | Nutzer anlegen (Admin-Key) → Token einmalig |
|
||||||
|
| `GET /api/me` | aktueller Nutzer + Präferenzen |
|
||||||
|
| `PUT /api/me/prefs` | dauerhafte Routing-Präferenzen des Nutzers setzen |
|
||||||
|
|
||||||
Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub):
|
Beispiel (Sprachausgabe an den Test-Loopback, lokaler TTS-Stub):
|
||||||
|
|
||||||
|
|
@ -91,6 +95,31 @@ curl -X POST http://localhost:8080/api/speak \
|
||||||
|
|
||||||
Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis.
|
Unbekannter Endpunkt/Provider → `HTTP 422` mit Klartext-Hinweis.
|
||||||
|
|
||||||
|
## Authentifizierung
|
||||||
|
|
||||||
|
Standardmäßig (`AUTH_ENABLED=true`) sind `chat`/`speak`/`transcribe`/`sessions`/`me`
|
||||||
|
durch ein **Bearer-Token pro Nutzer** geschützt. Nutzer/Sessions werden in SQLite
|
||||||
|
persistiert (`DB_PATH`, Default `data/voice-assistant.db`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) Nutzer anlegen (Admin-Key aus der Umgebung) — Token erscheint EINMALIG
|
||||||
|
export ADMIN_API_KEY=ein-langes-geheimnis
|
||||||
|
curl -X POST http://localhost:8080/api/admin/users \
|
||||||
|
-H "X-Admin-Key: $ADMIN_API_KEY" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"display_name":"Oma Anna"}'
|
||||||
|
# -> {"user_id":"…","display_name":"Oma Anna","token":"…"}
|
||||||
|
|
||||||
|
# 2) Mit dem Token aufrufen
|
||||||
|
curl http://localhost:8080/api/me -H "Authorization: Bearer <TOKEN>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Dauerhafte Präferenzen pro Nutzer (`PUT /api/me/prefs`) fließen in die Route-Auflösung
|
||||||
|
ein (Ebene zwischen Profil und Session). Fremde Sessions → `HTTP 403`.
|
||||||
|
|
||||||
|
> **Lokale Entwicklung:** `AUTH_ENABLED=false` setzen — dann gilt ein anonymer
|
||||||
|
> Standardnutzer und es ist kein Token nötig.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
14
app/api/admin.py
Normal file
14
app/api/admin.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth import require_admin
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.schemas import UserCreate, UserCreated
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
||||||
|
async def create_user(payload: UserCreate):
|
||||||
|
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
||||||
|
user, token = get_store().create_user(payload.display_name)
|
||||||
|
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.errors import RoutingError
|
from app.errors import RoutingError
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.store import User, SessionOwnershipError
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
resolve_route,
|
resolve_route,
|
||||||
build_orchestrator,
|
build_orchestrator,
|
||||||
|
|
@ -36,6 +38,7 @@ async def chat(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional session id to apply a stored route",
|
description="Optional session id to apply a stored route",
|
||||||
),
|
),
|
||||||
|
user: User = Depends(require_user),
|
||||||
):
|
):
|
||||||
overrides = {
|
overrides = {
|
||||||
"input_endpoint": payload.input_endpoint,
|
"input_endpoint": payload.input_endpoint,
|
||||||
|
|
@ -45,12 +48,14 @@ async def chat(
|
||||||
"llm_provider": payload.llm_provider,
|
"llm_provider": payload.llm_provider,
|
||||||
"tts_provider": payload.tts_provider,
|
"tts_provider": payload.tts_provider,
|
||||||
}
|
}
|
||||||
route = resolve_route(session_id, overrides)
|
|
||||||
voice = payload.voice or settings.openrouter_tts_voice
|
voice = payload.voice or settings.openrouter_tts_voice
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
route = resolve_route(user, session_id, overrides)
|
||||||
orchestrator = build_orchestrator(route)
|
orchestrator = build_orchestrator(route)
|
||||||
output = await resolve_output_endpoint(route)
|
output = await resolve_output_endpoint(route)
|
||||||
|
except SessionOwnershipError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
except RoutingError as exc:
|
except RoutingError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
|
||||||
21
app/api/me.py
Normal file
21
app/api/me.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.schemas import UserPrefs
|
||||||
|
from app.store import User
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(user: User = Depends(require_user)):
|
||||||
|
return {"user_id": user.id, "display_name": user.display_name, "prefs": user.prefs}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/prefs")
|
||||||
|
async def set_my_prefs(payload: UserPrefs, user: User = Depends(require_user)):
|
||||||
|
"""Setzt die dauerhaften Routing-Praeferenzen des Nutzers (nur gesetzte Felder)."""
|
||||||
|
prefs = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||||
|
updated = get_store().set_user_prefs(user.id, prefs)
|
||||||
|
return {"user_id": updated.id, "prefs": updated.prefs}
|
||||||
|
|
@ -1,10 +1,21 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.schemas import SessionRouteRequest
|
from app.schemas import SessionRouteRequest
|
||||||
from app.dependencies import session_manager
|
from app.auth import require_user
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.store import User, SessionOwnershipError
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/route")
|
@router.post("/sessions/{session_id}/route")
|
||||||
async def set_session_route(session_id: str, payload: SessionRouteRequest):
|
async def set_session_route(
|
||||||
session = session_manager.update(session_id, payload.model_dump())
|
session_id: str,
|
||||||
return {"session_id": session_id, "route": session}
|
payload: SessionRouteRequest,
|
||||||
|
user: User = Depends(require_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
session = get_store().update_session(session_id, user.id, payload.model_dump())
|
||||||
|
except SessionOwnershipError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
return {"session_id": session_id, "route": session.data}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.errors import RoutingError
|
from app.errors import RoutingError
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.store import User, SessionOwnershipError
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
resolve_route,
|
resolve_route,
|
||||||
build_orchestrator,
|
build_orchestrator,
|
||||||
|
|
@ -22,18 +24,21 @@ async def speak(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional session id to apply a stored route",
|
description="Optional session id to apply a stored route",
|
||||||
),
|
),
|
||||||
|
user: User = Depends(require_user),
|
||||||
):
|
):
|
||||||
overrides = {
|
overrides = {
|
||||||
"output_endpoint": payload.output_endpoint,
|
"output_endpoint": payload.output_endpoint,
|
||||||
"language": payload.language,
|
"language": payload.language,
|
||||||
"tts_provider": payload.tts_provider,
|
"tts_provider": payload.tts_provider,
|
||||||
}
|
}
|
||||||
route = resolve_route(session_id, overrides)
|
|
||||||
voice = payload.voice or settings.openrouter_tts_voice
|
voice = payload.voice or settings.openrouter_tts_voice
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
route = resolve_route(user, session_id, overrides)
|
||||||
orchestrator = build_orchestrator(route)
|
orchestrator = build_orchestrator(route)
|
||||||
output = await resolve_output_endpoint(route)
|
output = await resolve_output_endpoint(route)
|
||||||
|
except SessionOwnershipError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
except RoutingError as exc:
|
except RoutingError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
|
||||||
|
|
||||||
from app.errors import RoutingError
|
from app.errors import RoutingError
|
||||||
|
from app.auth import require_user
|
||||||
|
from app.store import User, SessionOwnershipError
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
resolve_route,
|
resolve_route,
|
||||||
build_orchestrator,
|
build_orchestrator,
|
||||||
|
|
@ -20,17 +22,20 @@ async def transcribe(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional session id to apply a stored route",
|
description="Optional session id to apply a stored route",
|
||||||
),
|
),
|
||||||
|
user: User = Depends(require_user),
|
||||||
):
|
):
|
||||||
overrides = {
|
overrides = {
|
||||||
"input_endpoint": input_endpoint,
|
"input_endpoint": input_endpoint,
|
||||||
"language": language,
|
"language": language,
|
||||||
"stt_provider": stt_provider,
|
"stt_provider": stt_provider,
|
||||||
}
|
}
|
||||||
route = resolve_route(session_id, overrides)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
route = resolve_route(user, session_id, overrides)
|
||||||
orchestrator = build_orchestrator(route)
|
orchestrator = build_orchestrator(route)
|
||||||
source = await resolve_input_endpoint(route)
|
source = await resolve_input_endpoint(route)
|
||||||
|
except SessionOwnershipError as exc:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
except RoutingError as exc:
|
except RoutingError as exc:
|
||||||
raise HTTPException(status_code=422, detail=str(exc))
|
raise HTTPException(status_code=422, detail=str(exc))
|
||||||
|
|
||||||
|
|
|
||||||
36
app/auth.py
Normal file
36
app/auth.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.dependencies import get_store
|
||||||
|
from app.store import User
|
||||||
|
|
||||||
|
|
||||||
|
def require_user(authorization: str | None = Header(default=None)) -> User:
|
||||||
|
"""FastAPI-Dependency: liefert den authentifizierten Nutzer.
|
||||||
|
|
||||||
|
Bei AUTH_ENABLED=false gilt ein anonymer Standardnutzer (dev/Test). Sonst ist
|
||||||
|
ein gueltiges Bearer-Token noetig (sonst 401).
|
||||||
|
"""
|
||||||
|
store = get_store()
|
||||||
|
if not settings.auth_enabled:
|
||||||
|
return store.ensure_anonymous_user()
|
||||||
|
|
||||||
|
if not authorization or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Bearer token required")
|
||||||
|
|
||||||
|
token = authorization.split(" ", 1)[1].strip()
|
||||||
|
user = store.get_user_by_token(token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(x_admin_key: str | None = Header(default=None)) -> None:
|
||||||
|
"""Schuetzt die Nutzerverwaltung ueber ADMIN_API_KEY (aus der Umgebung)."""
|
||||||
|
expected = settings.admin_api_key.strip()
|
||||||
|
if not expected:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Admin API not configured (ADMIN_API_KEY unset)"
|
||||||
|
)
|
||||||
|
if not x_admin_key or x_admin_key.strip() != expected:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid admin key")
|
||||||
|
|
@ -120,6 +120,9 @@ class Settings(BaseSettings):
|
||||||
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
|
local_llm_base_url: str = "http://127.0.0.1:11434/v1"
|
||||||
local_llm_api_key: str = "dummy"
|
local_llm_api_key: str = "dummy"
|
||||||
local_llm_model: str = "llama3.1"
|
local_llm_model: str = "llama3.1"
|
||||||
|
db_path: str = str(BASE_DIR / "data" / "voice-assistant.db")
|
||||||
|
admin_api_key: str = ""
|
||||||
|
auth_enabled: bool = True
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
env_file=ENV_FILE, case_sensitive=False, extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
class SessionManager:
|
|
||||||
def __init__(self):
|
|
||||||
self._sessions = {}
|
|
||||||
|
|
||||||
def get(self, session_id: str) -> dict:
|
|
||||||
return self._sessions.setdefault(session_id, {})
|
|
||||||
|
|
||||||
def update(self, session_id: str, values: dict) -> dict:
|
|
||||||
session = self.get(session_id)
|
|
||||||
session.update({k: v for k, v in values.items() if v is not None})
|
|
||||||
return session
|
|
||||||
|
|
@ -23,9 +23,20 @@ from app.pipeline.input_cleaner import InputCleaner
|
||||||
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
|
from app.pipeline.spoken_response_adapter import SpokenResponseAdapter
|
||||||
from app.pipeline.tts_normalizer import TTSNormalizer
|
from app.pipeline.tts_normalizer import TTSNormalizer
|
||||||
from app.core.orchestrator import Orchestrator
|
from app.core.orchestrator import Orchestrator
|
||||||
from app.core.session_manager import SessionManager
|
from app.store import SQLiteStore, Store, User
|
||||||
|
|
||||||
session_manager = SessionManager()
|
# ---------------------------------------------------------------------------
|
||||||
|
# Persistenz-Store: Modul-Singleton (SQLite). Spaetere Backends implementieren
|
||||||
|
# dasselbe Store-Interface, ohne die App zu aendern.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_store: Store | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_store() -> Store:
|
||||||
|
global _store
|
||||||
|
if _store is None:
|
||||||
|
_store = SQLiteStore(settings.db_path)
|
||||||
|
return _store
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
|
# Provider-Registries: Modul austauschbar via Name, ohne Kern-Code zu aendern.
|
||||||
|
|
@ -136,17 +147,35 @@ class ResolvedRoute:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_session_route(session_id: str | None) -> dict:
|
def get_session_route(session_id: str | None, user: User | None = None) -> dict:
|
||||||
"""Liefert die gespeicherte Route einer Session (leeres dict ohne session_id)."""
|
"""Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst).
|
||||||
return session_manager.get(session_id) if session_id else {}
|
|
||||||
|
Gehoert die Session einem anderen Nutzer, wird SessionOwnershipError ausgeloest.
|
||||||
|
"""
|
||||||
|
if not session_id:
|
||||||
|
return {}
|
||||||
|
session = get_store().get_session(session_id)
|
||||||
|
if session is None:
|
||||||
|
return {}
|
||||||
|
if user is not None and session.user_id != user.id:
|
||||||
|
from app.store import SessionOwnershipError
|
||||||
|
|
||||||
|
raise SessionOwnershipError(
|
||||||
|
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||||
|
)
|
||||||
|
return session.data
|
||||||
|
|
||||||
|
|
||||||
def resolve_route(
|
def resolve_route(
|
||||||
|
user: User | None = None,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
overrides: dict | None = None,
|
overrides: dict | None = None,
|
||||||
cfg: Settings = settings,
|
cfg: Settings = settings,
|
||||||
) -> ResolvedRoute:
|
) -> ResolvedRoute:
|
||||||
"""Loest die effektive Route aus Defaults, Session und Request-Overrides auf."""
|
"""Loest die effektive Route auf.
|
||||||
|
|
||||||
|
Praezedenz (hoeher gewinnt): Defaults < Nutzer-Prefs < Session-Route < Request.
|
||||||
|
"""
|
||||||
resolved = {
|
resolved = {
|
||||||
"input_endpoint": cfg.default_input_endpoint,
|
"input_endpoint": cfg.default_input_endpoint,
|
||||||
"output_endpoint": cfg.default_output_endpoint,
|
"output_endpoint": cfg.default_output_endpoint,
|
||||||
|
|
@ -156,10 +185,11 @@ def resolve_route(
|
||||||
"language": cfg.default_language,
|
"language": cfg.default_language,
|
||||||
}
|
}
|
||||||
|
|
||||||
session_route = get_session_route(session_id)
|
user_prefs = user.prefs if user is not None else {}
|
||||||
|
session_route = get_session_route(session_id, user)
|
||||||
request_overrides = overrides or {}
|
request_overrides = overrides or {}
|
||||||
|
|
||||||
for layer in (session_route, request_overrides):
|
for layer in (user_prefs, session_route, request_overrides):
|
||||||
for key in ROUTE_KEYS:
|
for key in ROUTE_KEYS:
|
||||||
value = layer.get(key)
|
value = layer.get(key)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from app.api.speak import router as speak_router
|
||||||
from app.api.devices import router as devices_router
|
from app.api.devices import router as devices_router
|
||||||
from app.api.sessions import router as sessions_router
|
from app.api.sessions import router as sessions_router
|
||||||
from app.api.config import router as config_router
|
from app.api.config import router as config_router
|
||||||
|
from app.api.admin import router as admin_router
|
||||||
|
from app.api.me import router as me_router
|
||||||
|
|
||||||
app = FastAPI(title="Voice Assistant Gateway")
|
app = FastAPI(title="Voice Assistant Gateway")
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
|
@ -15,3 +17,5 @@ app.include_router(speak_router, prefix="/api")
|
||||||
app.include_router(devices_router, prefix="/api")
|
app.include_router(devices_router, prefix="/api")
|
||||||
app.include_router(sessions_router, prefix="/api")
|
app.include_router(sessions_router, prefix="/api")
|
||||||
app.include_router(config_router, prefix="/api")
|
app.include_router(config_router, prefix="/api")
|
||||||
|
app.include_router(admin_router, prefix="/api")
|
||||||
|
app.include_router(me_router, prefix="/api")
|
||||||
|
|
|
||||||
|
|
@ -69,3 +69,22 @@ class RouteInfo(BaseModel):
|
||||||
tts_provider: str
|
tts_provider: str
|
||||||
language: str
|
language: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
display_name: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreated(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
display_name: str
|
||||||
|
token: str # nur bei Erstellung sichtbar
|
||||||
|
|
||||||
|
|
||||||
|
class UserPrefs(BaseModel):
|
||||||
|
input_endpoint: str | None = None
|
||||||
|
output_endpoint: str | None = None
|
||||||
|
stt_provider: str | None = None
|
||||||
|
llm_provider: str | None = None
|
||||||
|
tts_provider: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
|
||||||
197
app/store.py
Normal file
197
app/store.py
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
"""Persistenzschicht: Nutzer und Sessions.
|
||||||
|
|
||||||
|
Ein abstraktes Store-Interface mit SQLite-Default (stdlib). Spaetere Backends
|
||||||
|
(Postgres/Redis) koennen dasselbe Interface implementieren, ohne die App zu aendern.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ANONYMOUS_USER_ID = "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw_token: str) -> str:
|
||||||
|
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
id: str
|
||||||
|
display_name: str
|
||||||
|
prefs: dict = field(default_factory=dict)
|
||||||
|
created_at: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Session:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
data: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOwnershipError(Exception):
|
||||||
|
"""Eine Session gehoert einem anderen Nutzer (-> HTTP 403)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Store(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||||
|
"""Legt einen Nutzer an und liefert (User, Klartext-Token). Token nur hier sichtbar."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_user_by_token(self, raw_token: str) -> User | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_user(self, user_id: str) -> User | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_user_prefs(self, user_id: str, prefs: dict) -> User: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def ensure_anonymous_user(self) -> User: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_session(self, session_id: str) -> Session | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||||
|
"""Erstellt/aktualisiert eine Session des Nutzers. Fremde Session -> SessionOwnershipError."""
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteStore(Store):
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._init_schema()
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def _init_schema(self) -> None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
prefs_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
data_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----- Nutzer -----------------------------------------------------------
|
||||||
|
def _row_to_user(self, row: sqlite3.Row) -> User:
|
||||||
|
return User(
|
||||||
|
id=row["id"],
|
||||||
|
display_name=row["display_name"],
|
||||||
|
prefs=json.loads(row["prefs_json"] or "{}"),
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_user(self, display_name: str) -> tuple[User, str]:
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
user = User(id=uuid.uuid4().hex, display_name=display_name, prefs={}, created_at=_now())
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user.id, user.display_name, hash_token(raw_token), "{}", user.created_at),
|
||||||
|
)
|
||||||
|
return user, raw_token
|
||||||
|
|
||||||
|
def get_user_by_token(self, raw_token: str) -> User | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM users WHERE token_hash = ?", (hash_token(raw_token),)
|
||||||
|
).fetchone()
|
||||||
|
return self._row_to_user(row) if row else None
|
||||||
|
|
||||||
|
def get_user(self, user_id: str) -> User | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
return self._row_to_user(row) if row else None
|
||||||
|
|
||||||
|
def set_user_prefs(self, user_id: str, prefs: dict) -> User:
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET prefs_json = ? WHERE id = ?",
|
||||||
|
(json.dumps(prefs), user_id),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise KeyError(f"Unbekannter Nutzer: {user_id}")
|
||||||
|
return self._row_to_user(row)
|
||||||
|
|
||||||
|
def ensure_anonymous_user(self) -> User:
|
||||||
|
existing = self.get_user(ANONYMOUS_USER_ID)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
with self._connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (id, display_name, token_hash, prefs_json, created_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(ANONYMOUS_USER_ID, "Anonymous", f"anon-{ANONYMOUS_USER_ID}", "{}", _now()),
|
||||||
|
)
|
||||||
|
return self.get_user(ANONYMOUS_USER_ID)
|
||||||
|
|
||||||
|
# ----- Sessions ---------------------------------------------------------
|
||||||
|
def get_session(self, session_id: str) -> Session | None:
|
||||||
|
with self._connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return Session(id=row["id"], user_id=row["user_id"], data=json.loads(row["data_json"] or "{}"))
|
||||||
|
|
||||||
|
def update_session(self, session_id: str, user_id: str, values: dict) -> Session:
|
||||||
|
existing = self.get_session(session_id)
|
||||||
|
if existing and existing.user_id != user_id:
|
||||||
|
raise SessionOwnershipError(
|
||||||
|
f"Session {session_id!r} gehoert einem anderen Nutzer"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = dict(existing.data) if existing else {}
|
||||||
|
data.update({k: v for k, v in values.items() if v is not None})
|
||||||
|
payload = json.dumps(data)
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
with self._connect() as conn:
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET data_json = ?, updated_at = ? WHERE id = ?",
|
||||||
|
(payload, now, session_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, user_id, data_json, created_at, updated_at)"
|
||||||
|
" VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(session_id, user_id, payload, now, now),
|
||||||
|
)
|
||||||
|
return Session(id=session_id, user_id=user_id, data=data)
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8080
|
PORT=8080
|
||||||
OPENROUTER_API_KEY=
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Profil und Konfiguration
|
||||||
|
VA_PROFILE=cloud
|
||||||
|
|
||||||
|
# Authentifizierung (Produktion: an). ADMIN_API_KEY fuer die Nutzerverwaltung.
|
||||||
|
AUTH_ENABLED=true
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
# Persistente Datenbank (Pfad auf dem Host)
|
||||||
|
DB_PATH=/var/lib/voice-assistant/voice-assistant.db
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,23 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import app.dependencies as deps
|
import app.dependencies as deps
|
||||||
|
from app.config import settings
|
||||||
|
from app.store import SQLiteStore
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_state():
|
def reset_state(tmp_path, monkeypatch):
|
||||||
"""Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test."""
|
"""Pro Test: frische SQLite-DB, frischer Singleton-Audio-Router, Auth aus.
|
||||||
|
|
||||||
|
Auth-Tests schalten `settings.auth_enabled` selbst wieder ein.
|
||||||
|
"""
|
||||||
|
deps._store = SQLiteStore(str(tmp_path / "test.db"))
|
||||||
deps._audio_router = None
|
deps._audio_router = None
|
||||||
deps.session_manager._sessions.clear()
|
monkeypatch.setattr(settings, "auth_enabled", False)
|
||||||
|
monkeypatch.setattr(settings, "admin_api_key", "")
|
||||||
yield
|
yield
|
||||||
|
deps._store = None
|
||||||
deps._audio_router = None
|
deps._audio_router = None
|
||||||
deps.session_manager._sessions.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def loopback_output():
|
def loopback_output():
|
||||||
|
|
|
||||||
91
tests/test_auth.py
Normal file
91
tests/test_auth.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
ADMIN = "admin-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_auth(monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "auth_enabled", True)
|
||||||
|
monkeypatch.setattr(settings, "admin_api_key", ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(name: str) -> str:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/admin/users", headers={"X-Admin-Key": ADMIN}, json={"display_name": name}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return resp.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_endpoint_requires_token(monkeypatch):
|
||||||
|
_enable_auth(monkeypatch)
|
||||||
|
resp = client.post("/api/speak", json={"text": "x", "tts_provider": "piper"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_requires_key(monkeypatch):
|
||||||
|
_enable_auth(monkeypatch)
|
||||||
|
resp = client.post("/api/admin/users", json={"display_name": "Anna"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_and_call_me(monkeypatch):
|
||||||
|
_enable_auth(monkeypatch)
|
||||||
|
token = _create_user("Anna")
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
me = client.get("/api/me", headers=auth)
|
||||||
|
assert me.status_code == 200
|
||||||
|
assert me.json()["display_name"] == "Anna"
|
||||||
|
|
||||||
|
bad = client.get("/api/me", headers={"Authorization": "Bearer nope"})
|
||||||
|
assert bad.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_isolation_returns_403(monkeypatch):
|
||||||
|
_enable_auth(monkeypatch)
|
||||||
|
token_a = _create_user("A")
|
||||||
|
token_b = _create_user("B")
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/sessions/shared/route",
|
||||||
|
headers={"Authorization": f"Bearer {token_a}"},
|
||||||
|
json={"tts_provider": "piper"},
|
||||||
|
)
|
||||||
|
# B versucht die Session von A zu nutzen.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/speak?session_id=shared",
|
||||||
|
headers={"Authorization": f"Bearer {token_b}"},
|
||||||
|
json={"text": "x"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_prefs_applied_to_route(monkeypatch):
|
||||||
|
_enable_auth(monkeypatch)
|
||||||
|
token = _create_user("Pref")
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
client.put(
|
||||||
|
"/api/me/prefs",
|
||||||
|
headers=auth,
|
||||||
|
json={"tts_provider": "piper", "output_endpoint": "loopback"},
|
||||||
|
)
|
||||||
|
resp = client.post("/api/speak", headers=auth, json={"text": "hallo"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.headers["X-TTS-Provider"] == "piper"
|
||||||
|
assert resp.headers["X-Output-Endpoint"] == "loopback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_unconfigured_returns_503(monkeypatch):
|
||||||
|
# Auth an, aber kein Admin-Key gesetzt.
|
||||||
|
monkeypatch.setattr(settings, "auth_enabled", True)
|
||||||
|
monkeypatch.setattr(settings, "admin_api_key", "")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/admin/users", headers={"X-Admin-Key": "irgendwas"}, json={"display_name": "X"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 503
|
||||||
|
|
@ -2,11 +2,12 @@ import pytest
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.errors import UnknownComponentError
|
from app.errors import UnknownComponentError
|
||||||
|
from app.store import SessionOwnershipError
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
resolve_route,
|
resolve_route,
|
||||||
get_llm_provider,
|
get_llm_provider,
|
||||||
get_tts_provider,
|
get_tts_provider,
|
||||||
session_manager,
|
get_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,16 +26,35 @@ def test_request_overrides_win():
|
||||||
assert route.output_endpoint == "loopback"
|
assert route.output_endpoint == "loopback"
|
||||||
|
|
||||||
|
|
||||||
def test_session_then_request_precedence():
|
def test_user_prefs_session_request_precedence():
|
||||||
session_manager.update("s_test", {"tts_provider": "piper", "language": "en"})
|
store = get_store()
|
||||||
route = resolve_route("s_test")
|
user, _ = store.create_user("Tester")
|
||||||
assert route.tts_provider == "piper"
|
store.set_user_prefs(user.id, {"tts_provider": "chatterbox", "language": "en"})
|
||||||
|
user = store.get_user(user.id)
|
||||||
|
|
||||||
|
# Nutzer-Prefs gelten.
|
||||||
|
route = resolve_route(user)
|
||||||
|
assert route.tts_provider == "chatterbox"
|
||||||
assert route.language == "en"
|
assert route.language == "en"
|
||||||
|
|
||||||
# Request schlaegt Session.
|
# Session schlägt Nutzer-Prefs.
|
||||||
route2 = resolve_route("s_test", {"tts_provider": "chatterbox"})
|
store.update_session("s1", user.id, {"tts_provider": "piper"})
|
||||||
assert route2.tts_provider == "chatterbox"
|
route2 = resolve_route(user, "s1")
|
||||||
assert route2.language == "en"
|
assert route2.tts_provider == "piper"
|
||||||
|
assert route2.language == "en" # weiterhin aus den Nutzer-Prefs
|
||||||
|
|
||||||
|
# Request schlägt Session.
|
||||||
|
route3 = resolve_route(user, "s1", {"tts_provider": "openrouter"})
|
||||||
|
assert route3.tts_provider == "openrouter"
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_ownership_enforced_in_route():
|
||||||
|
store = get_store()
|
||||||
|
owner, _ = store.create_user("A")
|
||||||
|
other, _ = store.create_user("B")
|
||||||
|
store.update_session("shared", owner.id, {"tts_provider": "piper"})
|
||||||
|
with pytest.raises(SessionOwnershipError):
|
||||||
|
resolve_route(other, "shared")
|
||||||
|
|
||||||
|
|
||||||
def test_registry_unknown_provider_raises():
|
def test_registry_unknown_provider_raises():
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue