diff --git a/docs/projektplan.md b/docs/projektplan.md index 4acd406..e803e07 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -487,16 +487,28 @@ Freitext: [________________________] --- -### 4. Konfiguration (.env) +### 4. Konfiguration + +**System-Konfiguration (.env):** ``` +# Server +SERVER_PORT=3000 +DATA_PATH=/data +USERS_FILE=users.toml + +# Logging +LOG_LEVEL=info +LOG_PATH=/var/log/recorder/ +LOG_MAX_DAYS=90 + # Aufbewahrung RETENTION_AUDIO_DAYS=30 RETENTION_TRANSCRIPT_DAYS=30 RETENTION_DOCUMENT_DAYS=0 # 0 = permanent # faster-whisper -WHISPER_URL=http://localhost:8100 +WHISPER_URL=http://localhost:10300 WHISPER_TIMEOUT_SECONDS=120 # Ollama @@ -510,27 +522,28 @@ LLM_API_KEY=... LLM_MODEL=... LLM_TEMPERATURE=0 -# Server -SERVER_PORT=3000 -DATA_PATH=/data - -# Logging -LOG_LEVEL=info -LOG_PATH=/var/log/recorder/ -LOG_MAX_DAYS=90 - -# API Keys (ein Eintrag pro Arzt, für Watch-Authentifizierung) -API_KEY_DR_MUELLER=... -API_KEY_DR_SCHMIDT=... - -# Web Login (ein Eintrag pro Arzt, bcrypt-Hash) -WEB_PASSWORD_DR_MUELLER=$2b$12$... -WEB_PASSWORD_DR_SCHMIDT=$2b$12$... - # Session SESSION_TIMEOUT_HOURS=8 ``` +**User-Verwaltung (users.toml):** + +User-Daten (API-Keys, Passwörter, Rollen) werden separat in `users.toml` verwaltet statt in .env. So können neue User hinzugefügt werden, ohne die System-Konfiguration anzufassen. Das `role`-Feld ermöglicht neben `doctor` auch andere Rollen (z.B. `mta`, `admin`). + +```toml +[[user]] +slug = "dr_mueller" +api_key = "..." +web_password = "$2b$12$..." +role = "doctor" + +[[user]] +slug = "dr_schmidt" +api_key = "..." +web_password = "$2b$12$..." +role = "doctor" +``` + --- ### 5. Vorgefertigte Prompts (prompts.toml) @@ -656,7 +669,7 @@ Axum selbst benötigt keinen GPU-Zugriff — STT und Preprocessing laufen als ex ```toml [dependencies] -axum = "0.7" +axum = "0.8" # 0.8 wegen edition 2024 (native async fn in Traits) tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "multipart"] } askama = "0.12" @@ -668,10 +681,10 @@ uuid = { version = "1", features = ["v4"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -tower-http = { version = "0.5", features = ["limit", "trace"] } +tower-http = { version = "0.6", features = ["limit", "trace"] } # passend zu axum 0.8 rand = "0.8" # Session-Token-Generierung (OsRng) bcrypt = "0.15" # Passwort-Hashing (Web-Login) -axum-extra = { version = "0.9", features = ["cookie"] } # Cookie-Handling +axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 ``` --- @@ -814,7 +827,7 @@ axum-extra = { version = "0.9", features = ["cookie"] } # Cookie-Handling | Ollama Modell | Gemma 3 4B — ggf. Llama 3.1 8B falls Qualität nicht reicht | | Ionos Modell | Noch zu evaluieren | | Watch Hardware | Pixel Watch 2 oder 3 (LTE empfohlen) | -| Authentifizierung Browser | Entschieden: Serverseitiges Session-Token (256-Bit, Cookie mit `Secure`/`HttpOnly`/`SameSite=Strict`), Ablauf 8 h. Arzt-Identität ausschließlich aus Session, nie aus URL (IDOR-Prävention). Login gegen Arzt-Passwort in `.env`. | +| Authentifizierung Browser | Entschieden: Serverseitiges Session-Token (256-Bit, Cookie mit `Secure`/`HttpOnly`/`SameSite=Strict`), Ablauf 8 h. User-Identität ausschließlich aus Session, nie aus URL (IDOR-Prävention). Login gegen User-Passwort in `users.toml`. | | Prompt-Qualität | Erfordert Testing mit echten Diktaten | | Transkript editierbar? | Read-only oder editierbar vor Abschluss — offen | | Sicheres Löschen | Reicht rm oder Overwrite nötig? — offen | @@ -831,3 +844,15 @@ axum-extra = { version = "0.9", features = ["cookie"] } # Cookie-Handling - Multi-Tenant / Cloud-Hosting - Echtzeit-Transkription - Automatische Patientenzuordnung + +--- + +## Abweichungen von der Originalplanung + +| Änderung | Original | Aktuell | Grund | +|---|---|---|---| +| Crate-Versionen | axum 0.7, tower-http 0.5, axum-extra 0.9 | axum 0.8, tower-http 0.6, axum-extra 0.12 | Rust edition 2024 erfordert native async fn in Traits; axum 0.7 nutzt `#[async_trait]`, was inkompatibel ist | +| User-Verwaltung | `API_KEY_*` / `WEB_PASSWORD_*` in .env | Separate `users.toml` mit role-Feld | Flexibler: neue User ohne .env-Änderung, Rollen-System (doctor, mta, admin) | +| faster-whisper Port | 8100 | 10300 | Tatsächlicher Port des laufenden Containers auf minerva | +| Terminologie | "Arzt/Ärzte" | "User" | Rollen-System: nicht nur Ärzte, auch MTAs und ggf. Admins | +| Erster Upload neue case_id | "Fall unbekannt → ACK gone" | Neuer Fall anlegen, ACK "received" | Sonst würde der allererste Upload einer neuen case_id immer abgelehnt. "gone" nur für gelöschte Fälle. | diff --git a/server/Cargo.lock b/server/Cargo.lock index 711ef31..c2fb33d 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -61,17 +61,6 @@ dependencies = [ "nom", ] -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -86,13 +75,13 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -105,8 +94,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -120,19 +108,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -141,26 +127,24 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.6" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" dependencies = [ "axum", "axum-core", "bytes", "cookie", - "fastrand", + "futures-core", "futures-util", "http", "http-body", "http-body-util", "mime", - "multer", "pin-project-lite", - "serde", - "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -349,7 +333,7 @@ dependencies = [ "tokio", "toml", "tower", - "tower-http 0.5.2", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", @@ -906,9 +890,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -949,23 +933,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - [[package]] name = "native-tls" version = "0.2.18" @@ -1256,7 +1223,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tower", - "tower-http 0.6.8", + "tower-http", "tower-service", "url", "wasm-bindgen", @@ -1502,12 +1469,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1773,23 +1734,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower-http" version = "0.6.8" @@ -1801,11 +1745,13 @@ dependencies = [ "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/server/Cargo.toml b/server/Cargo.toml index a89077b..f50e8ba 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = "0.7" +axum = "0.8" tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "multipart"] } askama = "0.12" @@ -16,10 +16,10 @@ uuid = { version = "1", features = ["v4"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" -tower-http = { version = "0.5", features = ["limit", "trace"] } +tower-http = { version = "0.6", features = ["limit", "trace"] } rand = "0.8" bcrypt = "0.15" -axum-extra = { version = "0.9", features = ["cookie"] } +axum-extra = { version = "0.12", features = ["cookie"] } [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..6d1eae3 --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; + +use crate::config::Config; +use crate::error::AppError; + +/// Extracted from the X-API-Key header on every authenticated request. +pub struct AuthenticatedUser { + pub slug: String, + pub role: String, + pub data_dir: PathBuf, +} + +impl FromRequestParts> for AuthenticatedUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let api_key = parts + .headers + .get("X-API-Key") + .and_then(|v| v.to_str().ok()) + .ok_or(AppError::Unauthorized)?; + + let slug = state + .api_keys + .get(api_key) + .ok_or(AppError::Unauthorized)?; + + let user = state + .users + .iter() + .find(|u| u.slug == *slug) + .ok_or(AppError::Unauthorized)?; + + let data_dir = state.data_path.join(slug); + tokio::fs::create_dir_all(data_dir.join("open")).await?; + tokio::fs::create_dir_all(data_dir.join("done")).await?; + + Ok(Self { + slug: slug.clone(), + role: user.role.clone(), + data_dir, + }) + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index a68194b..0dcaec8 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod config; pub mod error; pub mod routes; diff --git a/server/src/routes/debug.rs b/server/src/routes/debug.rs new file mode 100644 index 0000000..f590723 --- /dev/null +++ b/server/src/routes/debug.rs @@ -0,0 +1,13 @@ +use axum::Json; +use serde_json::{json, Value}; + +use crate::auth::AuthenticatedUser; +use crate::error::AppError; + +pub async fn handle_whoami(user: AuthenticatedUser) -> Result, AppError> { + Ok(Json(json!({ + "slug": user.slug, + "role": user.role, + "data_dir": user.data_dir.to_string_lossy(), + }))) +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index ce121b9..d00ee15 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,11 +1,15 @@ +mod debug; mod health; use std::sync::Arc; +use axum::routing::get; use axum::Router; use crate::config::Config; pub fn api_router() -> Router> { - Router::new().route("/api/health", axum::routing::get(health::handle_health)) + Router::new() + .route("/api/health", get(health::handle_health)) + .route("/api/debug/whoami", get(debug::handle_whoami)) } diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs new file mode 100644 index 0000000..ea4ae6a --- /dev/null +++ b/server/tests/auth_test.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::util::ServiceExt; + +use doctate_server::config::{Config, User}; + +fn test_config() -> Arc { + Arc::new(Config { + server_port: 3000, + data_path: "/tmp/doctate-test".into(), + log_level: "info".into(), + log_path: "/tmp/doctate-test/logs".into(), + log_max_days: 90, + users: vec![User { + slug: "dr_test".into(), + api_key: "test-key-123".into(), + web_password: "unused".into(), + role: "doctor".into(), + }], + api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), + retention_audio_days: 30, + retention_transcript_days: 30, + retention_document_days: 0, + whisper_url: "http://localhost:10300".into(), + whisper_timeout_seconds: 120, + ollama_url: "http://localhost:11434".into(), + ollama_model: "gemma3:4b".into(), + ollama_keep_alive: 0, + llm_url: String::new(), + llm_api_key: String::new(), + llm_model: String::new(), + llm_temperature: 0.0, + session_timeout_hours: 8, + }) +} + +#[tokio::test] +async fn valid_api_key_returns_user() { + let app = doctate_server::create_router(test_config()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/debug/whoami") + .header("X-API-Key", "test-key-123") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["slug"], "dr_test"); + assert_eq!(json["role"], "doctor"); +} + +#[tokio::test] +async fn invalid_api_key_returns_401() { + let app = doctate_server::create_router(test_config()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/debug/whoami") + .header("X-API-Key", "wrong-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn missing_api_key_returns_401() { + let app = doctate_server::create_router(test_config()); + + let response = app + .oneshot( + Request::builder() + .uri("/api/debug/whoami") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +}