diff --git a/.env.example b/.env.example index 5f554e4..491b84a 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ LOG_LEVEL=info # Secret nur ueber die Umgebung setzen (nicht hier eintragen), z. B. export in ~/.bashrc 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 ------------------------------------- # Aktives Profil aus config/voice-assistant.toml waehlen: local-dev | hybrid | cloud # (leer lassen = nur Defaults/ENV). Eigener Pfad via VA_CONFIG_FILE. diff --git a/.gitignore b/.gitignore index a8cfe85..5d1581d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Lokale/instanzspezifische Konfiguration (nur die *.example.toml wird versioniert) config/voice-assistant.toml +# Persistente Daten (SQLite-DB etc.) +data/ + # Python __pycache__/ *.py[cod] diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 6f5d1ee..20082c6 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -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= +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 | |---|---|---| -| `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 | | `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 | @@ -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 make test diff --git a/Docs/voice-assistant-architecture.md b/Docs/voice-assistant-architecture.md index 97c2c09..f4eef5b 100644 --- a/Docs/voice-assistant-architecture.md +++ b/Docs/voice-assistant-architecture.md @@ -38,7 +38,7 @@ einstellbar; höhere Ebene gewinnt: ``` 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 @@ -64,9 +64,10 @@ werden aus echter Umgebung **oder** `.env` gelesen. ### 3.2 Einheitliche Route-Auflösung `app/dependencies.py` löst pro Aufruf eine `ResolvedRoute` auf -(`resolve_route(session_id, overrides)`): Defaults < Session-Route < Request. -Die Route umfasst `input_endpoint`, `output_endpoint`, `stt_provider`, -`llm_provider`, `tts_provider`, `language`. +(`resolve_route(user, session_id, overrides)`): Defaults < Nutzer-Prefs < +Session-Route < Request. Die Route umfasst `input_endpoint`, `output_endpoint`, +`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) @@ -162,6 +163,8 @@ ein No-op; `LoopbackOutput` sammelt die Chunks (testbar ohne Hardware). | `GET /api/devices` | verfügbare Audio-Endpunkte + Capabilities | | `POST /api/sessions/{id}/route` | bevorzugte Geräte/Provider/Sprache je Session | | `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** (`?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 STT (multipart), LLM und TTS; lokaler OpenAI-kompatibler LLM-Adapter; regelbasierte 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`, `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: 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). -3. **Konversationsgedächtnis:** Verlauf + Langzeit-Präferenzen (heute ist `llm.complete` zustandslos). +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-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. 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). @@ -203,15 +208,18 @@ voice-assistant-scaffold/ ├── app/ │ ├── main.py # FastAPI-App + Router-Registrierung │ ├── 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 │ ├── schemas.py # Pydantic-Modelle -│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config -│ ├── core/ # orchestrator, session_manager +│ ├── api/ # health, chat, speak, transcribe, devices, sessions, config, admin, me +│ ├── core/ # orchestrator │ ├── audio/ # router, transport_router, endpoints/input|output/* │ ├── pipeline/ # input_cleaner, spoken_response_adapter, tts_normalizer │ └── providers/ # stt/ llm/ tts/ (openrouter + lokale Stubs) ├── config/ # voice-assistant.example.toml (+ lokale .toml, gitignored) +├── data/ # SQLite-DB (gitignored) ├── deploy/ # systemd unit + env-Beispiel ├── tests/ # config-profile, routing, audio-router, e2e ├── Docs/ # dieses Dokument diff --git a/README.md b/README.md index 73ed35d..a7e9471 100644 --- a/README.md +++ b/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 - **Provider austauschbar** über Registry (OpenRouter remote; faster-whisper/piper/chatterbox als lokale Stubs) - **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 - **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 | | `POST /api/sessions/{id}/route` | Geräte/Provider/Sprache je Session setzen | | `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): @@ -91,6 +95,31 @@ curl -X POST http://localhost:8080/api/speak \ 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 " +``` + +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 ```bash diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..c69ee72 --- /dev/null +++ b/app/api/admin.py @@ -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) diff --git a/app/api/chat.py b/app/api/chat.py index 300a500..be40b2a 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -1,10 +1,12 @@ from io import BytesIO -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse, StreamingResponse from app.config import settings from app.errors import RoutingError +from app.auth import require_user +from app.store import User, SessionOwnershipError from app.dependencies import ( resolve_route, build_orchestrator, @@ -36,6 +38,7 @@ async def chat( default=None, description="Optional session id to apply a stored route", ), + user: User = Depends(require_user), ): overrides = { "input_endpoint": payload.input_endpoint, @@ -45,12 +48,14 @@ async def chat( "llm_provider": payload.llm_provider, "tts_provider": payload.tts_provider, } - route = resolve_route(session_id, overrides) voice = payload.voice or settings.openrouter_tts_voice try: + route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) except RoutingError as exc: raise HTTPException(status_code=422, detail=str(exc)) diff --git a/app/api/me.py b/app/api/me.py new file mode 100644 index 0000000..816a606 --- /dev/null +++ b/app/api/me.py @@ -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} diff --git a/app/api/sessions.py b/app/api/sessions.py index 181ff9d..c5ac2e1 100644 --- a/app/api/sessions.py +++ b/app/api/sessions.py @@ -1,10 +1,21 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException + 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.post("/sessions/{session_id}/route") -async def set_session_route(session_id: str, payload: SessionRouteRequest): - session = session_manager.update(session_id, payload.model_dump()) - return {"session_id": session_id, "route": session} +async def set_session_route( + session_id: str, + 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} diff --git a/app/api/speak.py b/app/api/speak.py index 4573de9..7cdff77 100644 --- a/app/api/speak.py +++ b/app/api/speak.py @@ -1,10 +1,12 @@ from io import BytesIO -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from app.config import settings from app.errors import RoutingError +from app.auth import require_user +from app.store import User, SessionOwnershipError from app.dependencies import ( resolve_route, build_orchestrator, @@ -22,18 +24,21 @@ async def speak( default=None, description="Optional session id to apply a stored route", ), + user: User = Depends(require_user), ): overrides = { "output_endpoint": payload.output_endpoint, "language": payload.language, "tts_provider": payload.tts_provider, } - route = resolve_route(session_id, overrides) voice = payload.voice or settings.openrouter_tts_voice try: + route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) output = await resolve_output_endpoint(route) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) except RoutingError as exc: raise HTTPException(status_code=422, detail=str(exc)) diff --git a/app/api/transcribe.py b/app/api/transcribe.py index b727849..24ad4ae 100644 --- a/app/api/transcribe.py +++ b/app/api/transcribe.py @@ -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.auth import require_user +from app.store import User, SessionOwnershipError from app.dependencies import ( resolve_route, build_orchestrator, @@ -20,17 +22,20 @@ async def transcribe( default=None, description="Optional session id to apply a stored route", ), + user: User = Depends(require_user), ): overrides = { "input_endpoint": input_endpoint, "language": language, "stt_provider": stt_provider, } - route = resolve_route(session_id, overrides) try: + route = resolve_route(user, session_id, overrides) orchestrator = build_orchestrator(route) source = await resolve_input_endpoint(route) + except SessionOwnershipError as exc: + raise HTTPException(status_code=403, detail=str(exc)) except RoutingError as exc: raise HTTPException(status_code=422, detail=str(exc)) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..93407d3 --- /dev/null +++ b/app/auth.py @@ -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") diff --git a/app/config.py b/app/config.py index 6d40398..64dbcbc 100644 --- a/app/config.py +++ b/app/config.py @@ -120,6 +120,9 @@ class Settings(BaseSettings): local_llm_base_url: str = "http://127.0.0.1:11434/v1" local_llm_api_key: str = "dummy" 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( env_file=ENV_FILE, case_sensitive=False, extra="ignore" ) diff --git a/app/core/session_manager.py b/app/core/session_manager.py deleted file mode 100644 index 821c707..0000000 --- a/app/core/session_manager.py +++ /dev/null @@ -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 diff --git a/app/dependencies.py b/app/dependencies.py index 1c05482..51c68c6 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -23,9 +23,20 @@ from app.pipeline.input_cleaner import InputCleaner from app.pipeline.spoken_response_adapter import SpokenResponseAdapter from app.pipeline.tts_normalizer import TTSNormalizer 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. @@ -136,17 +147,35 @@ class ResolvedRoute: } -def get_session_route(session_id: str | None) -> dict: - """Liefert die gespeicherte Route einer Session (leeres dict ohne session_id).""" - return session_manager.get(session_id) if session_id else {} +def get_session_route(session_id: str | None, user: User | None = None) -> dict: + """Liefert die gespeicherte Route einer Session des Nutzers (leeres dict sonst). + + 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( + user: User | None = None, session_id: str | None = None, overrides: dict | None = None, cfg: Settings = settings, ) -> 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 = { "input_endpoint": cfg.default_input_endpoint, "output_endpoint": cfg.default_output_endpoint, @@ -156,10 +185,11 @@ def resolve_route( "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 {} - for layer in (session_route, request_overrides): + for layer in (user_prefs, session_route, request_overrides): for key in ROUTE_KEYS: value = layer.get(key) if value is not None: diff --git a/app/main.py b/app/main.py index eadbef0..adcc928 100644 --- a/app/main.py +++ b/app/main.py @@ -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.sessions import router as sessions_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.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(sessions_router, prefix="/api") app.include_router(config_router, prefix="/api") +app.include_router(admin_router, prefix="/api") +app.include_router(me_router, prefix="/api") diff --git a/app/schemas.py b/app/schemas.py index 173957b..c8df4ad 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -69,3 +69,22 @@ class RouteInfo(BaseModel): tts_provider: 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 + diff --git a/app/store.py b/app/store.py new file mode 100644 index 0000000..98d61fb --- /dev/null +++ b/app/store.py @@ -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) diff --git a/deploy/voice-assistant.env.example b/deploy/voice-assistant.env.example index ce273b5..578603b 100644 --- a/deploy/voice-assistant.env.example +++ b/deploy/voice-assistant.env.example @@ -1,3 +1,12 @@ HOST=0.0.0.0 PORT=8080 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 diff --git a/tests/conftest.py b/tests/conftest.py index 1879910..e1bfbef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,23 @@ import pytest import app.dependencies as deps +from app.config import settings +from app.store import SQLiteStore @pytest.fixture(autouse=True) -def reset_state(): - """Isoliert den Singleton-Audio-Router (Loopback-Buffer) und Sessions je Test.""" +def reset_state(tmp_path, monkeypatch): + """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.session_manager._sessions.clear() + monkeypatch.setattr(settings, "auth_enabled", False) + monkeypatch.setattr(settings, "admin_api_key", "") yield + deps._store = None deps._audio_router = None - deps.session_manager._sessions.clear() def loopback_output(): diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4442b96 --- /dev/null +++ b/tests/test_auth.py @@ -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 diff --git a/tests/test_routing.py b/tests/test_routing.py index 89f3c67..4eeab76 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -2,11 +2,12 @@ import pytest from app.config import Settings from app.errors import UnknownComponentError +from app.store import SessionOwnershipError from app.dependencies import ( resolve_route, get_llm_provider, get_tts_provider, - session_manager, + get_store, ) @@ -25,16 +26,35 @@ def test_request_overrides_win(): assert route.output_endpoint == "loopback" -def test_session_then_request_precedence(): - session_manager.update("s_test", {"tts_provider": "piper", "language": "en"}) - route = resolve_route("s_test") - assert route.tts_provider == "piper" +def test_user_prefs_session_request_precedence(): + store = get_store() + user, _ = store.create_user("Tester") + 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" - # Request schlaegt Session. - route2 = resolve_route("s_test", {"tts_provider": "chatterbox"}) - assert route2.tts_provider == "chatterbox" - assert route2.language == "en" + # Session schlägt Nutzer-Prefs. + store.update_session("s1", user.id, {"tts_provider": "piper"}) + route2 = resolve_route(user, "s1") + 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():