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:
Dieter Schlüter 2026-06-17 02:14:25 +02:00
commit e0e69fdf15
22 changed files with 625 additions and 57 deletions

View file

@ -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.

3
.gitignore vendored
View file

@ -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]

View file

@ -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 |
|---|---|---|
| `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

View file

@ -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

View file

@ -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 <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
```bash

14
app/api/admin.py Normal file
View 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)

View file

@ -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))

21
app/api/me.py Normal file
View 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}

View file

@ -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}

View file

@ -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))

View file

@ -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))

36
app/auth.py Normal file
View 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")

View file

@ -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"
)

View file

@ -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

View file

@ -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:

View file

@ -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")

View file

@ -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

197
app/store.py Normal file
View 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)

View file

@ -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

View file

@ -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():

91
tests/test_auth.py Normal file
View 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

View file

@ -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():