diff --git a/maskinrommet/src/ai_admin.rs b/maskinrommet/src/ai_admin.rs index 2be1e3e..9ee4a50 100644 --- a/maskinrommet/src/ai_admin.rs +++ b/maskinrommet/src/ai_admin.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use crate::auth::AuthUser; +use crate::auth::AdminUser; use crate::AppState; // ============================================================================= @@ -92,7 +92,7 @@ fn internal_error(msg: &str) -> (StatusCode, Json) { pub async fn ai_overview( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { let aliases = sqlx::query_as::<_, AiModelAlias>( "SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias" @@ -178,7 +178,7 @@ pub struct UpdateAliasRequest { pub async fn update_alias( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let rows = sqlx::query( @@ -195,7 +195,7 @@ pub async fn update_alias( return Err(bad_request("Alias ikke funnet")); } - tracing::info!(alias_id = %req.id, user = %_user.node_id, "Admin: alias oppdatert"); + tracing::info!(alias_id = %req.id, user = %_admin.node_id, "Admin: alias oppdatert"); Ok(Json(serde_json::json!({ "success": true }))) } @@ -211,7 +211,7 @@ pub struct CreateAliasRequest { pub async fn create_alias( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if req.alias.trim().is_empty() { @@ -233,7 +233,7 @@ pub async fn create_alias( } })?; - tracing::info!(alias = %req.alias, user = %_user.node_id, "Admin: alias opprettet"); + tracing::info!(alias = %req.alias, user = %_admin.node_id, "Admin: alias opprettet"); Ok(Json(serde_json::json!({ "id": id, "success": true }))) } @@ -250,7 +250,7 @@ pub struct UpdateProviderRequest { pub async fn update_provider( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if let Some(priority) = req.priority { @@ -270,7 +270,7 @@ pub async fn update_provider( .map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?; } - tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider oppdatert"); + tracing::info!(provider_id = %req.id, user = %_admin.node_id, "Admin: provider oppdatert"); Ok(Json(serde_json::json!({ "success": true }))) } @@ -289,7 +289,7 @@ pub struct CreateProviderRequest { pub async fn create_provider( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if req.model.trim().is_empty() { @@ -317,7 +317,7 @@ pub async fn create_provider( } })?; - tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_user.node_id, "Admin: provider opprettet"); + tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_admin.node_id, "Admin: provider opprettet"); Ok(Json(serde_json::json!({ "id": id, "success": true }))) } @@ -332,7 +332,7 @@ pub struct DeleteProviderRequest { pub async fn delete_provider( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1") @@ -345,7 +345,7 @@ pub async fn delete_provider( return Err(bad_request("Provider ikke funnet")); } - tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider slettet"); + tracing::info!(provider_id = %req.id, user = %_admin.node_id, "Admin: provider slettet"); Ok(Json(serde_json::json!({ "success": true }))) } @@ -362,7 +362,7 @@ pub struct UpdateRoutingRequest { pub async fn update_routing( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { if req.job_type.trim().is_empty() || req.alias.trim().is_empty() { @@ -381,7 +381,7 @@ pub async fn update_routing( .await .map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?; - tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_user.node_id, "Admin: ruting oppdatert"); + tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_admin.node_id, "Admin: ruting oppdatert"); Ok(Json(serde_json::json!({ "success": true }))) } @@ -396,7 +396,7 @@ pub struct DeleteRoutingRequest { pub async fn delete_routing( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1") @@ -409,7 +409,7 @@ pub async fn delete_routing( return Err(bad_request("Rutingregel ikke funnet")); } - tracing::info!(job_type = %req.job_type, user = %_user.node_id, "Admin: ruting slettet"); + tracing::info!(job_type = %req.job_type, user = %_admin.node_id, "Admin: ruting slettet"); Ok(Json(serde_json::json!({ "success": true }))) } @@ -425,7 +425,7 @@ pub struct UsageQueryParams { pub async fn ai_usage( State(state): State, - _user: AuthUser, + _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result>, (StatusCode, Json)> { let days = params.days.unwrap_or(30).min(365); diff --git a/maskinrommet/src/auth.rs b/maskinrommet/src/auth.rs index ffc9053..7d6d784 100644 --- a/maskinrommet/src/auth.rs +++ b/maskinrommet/src/auth.rs @@ -115,6 +115,9 @@ impl IntoResponse for AuthErrorKind { AuthErrorKind::UnknownIdentity => { (StatusCode::UNAUTHORIZED, "Ukjent brukeridentitet") } + AuthErrorKind::Forbidden => { + (StatusCode::FORBIDDEN, "Krever admin-tilgang") + } AuthErrorKind::Internal => { (StatusCode::INTERNAL_SERVER_ERROR, "Intern feil ved autentisering") } @@ -133,6 +136,7 @@ pub enum AuthErrorKind { MissingToken, InvalidToken(String), UnknownIdentity, + Forbidden, Internal, } @@ -210,3 +214,62 @@ where // We need FromRef to extract AppState from the state use axum::extract::FromRef; + +// --------------------------------------------------------------------------- +// AdminUser extractor — krever owner/admin-edge til en samling +// --------------------------------------------------------------------------- + +/// Admin-bruker: autentisert bruker som har `owner`- eller `admin`-edge til +/// minst én samling. Brukes som extractor på /admin/*-endepunkter. +#[derive(Debug, Clone)] +pub struct AdminUser { + pub node_id: Uuid, + pub authentik_sub: String, +} + +impl FromRequestParts for AdminUser +where + S: Send + Sync, + AppState: FromRef, +{ + type Rejection = AuthErrorKind; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // Autentiser først + let user = AuthUser::from_request_parts(parts, state).await?; + + let app_state = AppState::from_ref(state); + + // Sjekk at brukeren er admin (owner/admin-edge til en samling) + let is_admin = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS( + SELECT 1 FROM edges + WHERE source_id = $1 + AND edge_type IN ('owner', 'admin') + AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection') + ) + "#, + ) + .bind(user.node_id) + .fetch_one(&app_state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved admin-sjekk"); + AuthErrorKind::Internal + })?; + + if !is_admin { + tracing::warn!( + user_id = %user.node_id, + "Ikke-admin forsøkte å bruke admin-endepunkt" + ); + return Err(AuthErrorKind::Forbidden); + } + + Ok(AdminUser { + node_id: user.node_id, + authentik_sub: user.authentik_sub, + }) + } +} diff --git a/maskinrommet/src/health.rs b/maskinrommet/src/health.rs index d0a3d78..068b03f 100644 --- a/maskinrommet/src/health.rs +++ b/maskinrommet/src/health.rs @@ -9,7 +9,7 @@ use axum::{extract::{Query, State}, http::StatusCode, Json}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use crate::auth::AuthUser; +use crate::auth::AdminUser; use crate::AppState; // ============================================================================= @@ -436,7 +436,7 @@ fn read_service_logs(service: &str, max_lines: usize) -> Vec { /// GET /admin/health — komplett serverhelse-dashboard. pub async fn health_dashboard( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { // Kjør alle tjeneste-sjekker parallelt let (pg, caddy, authentik, litellm, whisper, livekit) = tokio::join!( @@ -463,7 +463,7 @@ pub async fn health_dashboard( /// GET /admin/health/logs?service=maskinrommet&lines=50 pub async fn health_logs( - _user: AuthUser, + _admin: AdminUser, Query(params): Query, ) -> Json { let max_lines = params.lines.unwrap_or(50).min(200); diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 3ccc40b..9a49571 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use crate::auth::AuthUser; +use crate::auth::{AdminUser, AuthUser}; use crate::livekit; use crate::AppState; @@ -3927,7 +3927,7 @@ pub struct InitiateMaintenanceResponse { /// Kall GET /admin/maintenance_status først for å se aktive sesjoner. pub async fn initiate_maintenance( State(state): State, - _user: AuthUser, + admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at) @@ -3938,7 +3938,7 @@ pub async fn initiate_maintenance( return Err(bad_request("scheduled_at kan ikke være i fortiden")); } - let user_id = _user.node_id; + let user_id = admin.node_id; let announcement_id = state .maintenance @@ -3963,7 +3963,7 @@ pub struct CancelMaintenanceResponse { /// nedtellingstasken. pub async fn cancel_maintenance( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { state .maintenance @@ -3980,7 +3980,7 @@ pub async fn cancel_maintenance( /// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse. pub async fn maintenance_status( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { let status = state .maintenance @@ -4001,7 +4001,7 @@ pub async fn maintenance_status( /// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0 pub async fn list_jobs( State(state): State, - _user: AuthUser, + _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let limit = params.limit.unwrap_or(50).min(200); @@ -4050,7 +4050,7 @@ pub struct ListJobsResponse { /// Sett en feilet jobb tilbake til 'pending' for nytt forsøk. pub async fn retry_job( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let retried = crate::jobs::retry_job(&state.db, req.job_id) @@ -4061,7 +4061,7 @@ pub async fn retry_job( return Err(bad_request("Jobben finnes ikke eller har feil status for retry")); } - tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb restartet"); + tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb restartet"); Ok(Json(JobActionResponse { success: true })) } @@ -4070,7 +4070,7 @@ pub async fn retry_job( /// Avbryt en ventende jobb. pub async fn cancel_job( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let cancelled = crate::jobs::cancel_job(&state.db, req.job_id) @@ -4081,7 +4081,7 @@ pub async fn cancel_job( return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt")); } - tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb avbrutt"); + tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb avbrutt"); Ok(Json(JobActionResponse { success: true })) } @@ -4102,7 +4102,7 @@ pub struct JobActionResponse { /// GET /admin/resources — samlet ressursstatus. pub async fn resource_status( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { let cas_root = std::env::var("CAS_ROOT") .unwrap_or_else(|_| "/srv/synops/media/cas".to_string()); @@ -4139,7 +4139,7 @@ pub async fn resource_status( /// GET /admin/resources/disk — disk-status med historikk. pub async fn resource_disk( State(state): State, - _user: AuthUser, + _admin: AdminUser, ) -> Result, (StatusCode, Json)> { let current = crate::resources::latest_disk_status(&state.db) .await @@ -4160,7 +4160,7 @@ pub struct DiskOverview { /// POST /admin/resources/update_rule — oppdater en prioritetsregel. pub async fn update_priority_rule( State(state): State, - _user: AuthUser, + _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { sqlx::query( @@ -4192,7 +4192,7 @@ pub async fn update_priority_rule( tracing::info!( job_type = %req.job_type, - user = %_user.node_id, + user = %_admin.node_id, "Admin: prioritetsregel oppdatert" ); diff --git a/maskinrommet/src/mixer.rs b/maskinrommet/src/mixer.rs index 8a3a527..3c28e10 100644 --- a/maskinrommet/src/mixer.rs +++ b/maskinrommet/src/mixer.rs @@ -79,15 +79,16 @@ pub struct SetGainRequest { pub async fn set_gain( State(state): State, - _user: AuthUser, + user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { sqlx::query( - "UPDATE mixer_channels SET gain = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" + "UPDATE mixer_channels SET gain = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" ) .bind(req.gain.clamp(0.0, 1.5)) .bind(&req.room_id) .bind(&req.target_user_id) + .bind(user.node_id.to_string()) .execute(&state.db) .await .map_err(|e| { @@ -111,15 +112,16 @@ pub struct SetMuteRequest { pub async fn set_mute( State(state): State, - _user: AuthUser, + user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { sqlx::query( - "UPDATE mixer_channels SET is_muted = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" + "UPDATE mixer_channels SET is_muted = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" ) .bind(req.is_muted) .bind(&req.room_id) .bind(&req.target_user_id) + .bind(user.node_id.to_string()) .execute(&state.db) .await .map_err(|e| { @@ -143,42 +145,32 @@ pub struct ToggleEffectRequest { pub async fn toggle_effect( State(state): State, - _user: AuthUser, + user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { - // Hent nåværende active_effects JSON - let current: Option = sqlx::query_scalar( - "SELECT active_effects FROM mixer_channels WHERE room_id = $1 AND target_user_id = $2" - ) - .bind(&req.room_id) - .bind(&req.target_user_id) - .fetch_optional(&state.db) - .await - .map_err(|e| { - tracing::error!("Feil ved toggle_effect (les): {e}"); - internal_error("Databasefeil") - })?; - - let mut effects: serde_json::Value = current - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or(serde_json::json!({})); - - // Toggle effekten - let current_val = effects.get(&req.effect_name).and_then(|v| v.as_bool()).unwrap_or(false); - effects[&req.effect_name] = serde_json::Value::Bool(!current_val); - - let new_effects = serde_json::to_string(&effects).unwrap_or_else(|_| "{}".to_string()); - + // Atomisk toggle via PG JSON-operasjoner — unngår race condition ved + // samtidige oppdateringer fra flere klienter. sqlx::query( - "UPDATE mixer_channels SET active_effects = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" + r#"UPDATE mixer_channels + SET active_effects = ( + CASE + WHEN (active_effects::jsonb->>$3)::boolean IS NOT DISTINCT FROM true + THEN (active_effects::jsonb || jsonb_build_object($3, false))::text + ELSE (active_effects::jsonb || jsonb_build_object($3, true))::text + END + ), + updated_by = $4, + updated_at = now() + WHERE room_id = $1 AND target_user_id = $2"#, ) - .bind(&new_effects) .bind(&req.room_id) .bind(&req.target_user_id) + .bind(&req.effect_name) + .bind(user.node_id.to_string()) .execute(&state.db) .await .map_err(|e| { - tracing::error!("Feil ved toggle_effect (skriv): {e}"); + tracing::error!("Feil ved toggle_effect: {e}"); internal_error("Databasefeil") })?; @@ -198,7 +190,7 @@ pub struct SetMixerRoleRequest { pub async fn set_mixer_role( State(state): State, - _user: AuthUser, + user: AuthUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let valid_roles = ["editor", "viewer"]; @@ -207,11 +199,12 @@ pub async fn set_mixer_role( } sqlx::query( - "UPDATE mixer_channels SET role = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" + "UPDATE mixer_channels SET role = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" ) .bind(&req.role) .bind(&req.room_id) .bind(&req.target_user_id) + .bind(user.node_id.to_string()) .execute(&state.db) .await .map_err(|e| { diff --git a/maskinrommet/src/usage_overview.rs b/maskinrommet/src/usage_overview.rs index dc48d34..483b789 100644 --- a/maskinrommet/src/usage_overview.rs +++ b/maskinrommet/src/usage_overview.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; -use crate::auth::AuthUser; +use crate::auth::AdminUser; use crate::AppState; // ============================================================================= @@ -90,7 +90,7 @@ fn internal_error(msg: &str) -> (StatusCode, Json) { pub async fn usage_overview( State(state): State, - _user: AuthUser, + _admin: AdminUser, axum::extract::Query(params): axum::extract::Query, ) -> Result, (StatusCode, Json)> { let days = params.days.unwrap_or(30).clamp(1, 365);