From 66915a306dd348802101009dad402e557da855e5 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:31:19 +0100 Subject: [PATCH] Feature/rbac (#656) * RBAC implementation * Schedules config edit mode --- CHANGELOG.md | 24 + README.md | 8 + backend/src/api/endpoints/library_api.rs | 37 +- backend/src/api/endpoints/mod.rs | 1 + backend/src/api/endpoints/rbac_api.rs | 788 ++++++++++++++++++ backend/src/api/endpoints/user_api.rs | 6 +- backend/src/api/endpoints/v1_api.rs | 63 +- backend/src/api/endpoints/v1_api_config.rs | 292 ++++++- backend/src/api/endpoints/v1_api_playlist.rs | 63 +- backend/src/api/endpoints/v1_api_user.rs | 33 +- backend/src/api/endpoints/web_index.rs | 102 ++- backend/src/api/endpoints/websocket_api.rs | 99 ++- backend/src/api/main_api.rs | 2 +- .../src/api/model/metadata_update_manager.rs | 76 +- backend/src/auth/auth_bearer.rs | 10 +- backend/src/auth/authenticator.rs | 70 +- backend/src/auth/mod.rs | 15 +- backend/src/model/config/input.rs | 32 +- backend/src/model/config/web_auth.rs | 685 ++++++++++++++- backend/src/processing/processor/playlist.rs | 6 +- .../processing/processor/resolve_options.rs | 2 +- backend/src/utils/file/file_utils.rs | 10 +- config/README.md | 16 +- docs/src/configuration/main-config.md | 52 +- docs/src/configuration/overview.md | 2 + docs/src/features.md | 11 + docs/src/getting-started.md | 5 +- frontend/public/assets/i18n/en.json | 38 +- frontend/public/assets/icons.json | 10 + frontend/scss/_default.scss | 43 +- frontend/scss/_theme.scss | 10 +- frontend/scss/app/components/_form.scss | 4 + frontend/scss/app/components/_input.scss | 1 + .../components/_particle_flow_background.scss | 2 +- frontend/scss/app/components/_table.scss | 3 + .../app/components/config/_config_view.scss | 25 +- frontend/src/app/components/authentication.rs | 24 +- .../app/components/config/config_update.rs | 2 + .../src/app/components/config/config_view.rs | 12 +- .../config/hdhomerun_config_view.rs | 4 +- .../config/schedules_config_view.rs | 210 ++++- .../components/config/webui_config_view.rs | 4 + frontend/src/app/components/home.rs | 72 +- frontend/src/app/components/mod.rs | 10 +- frontend/src/app/components/no_access.rs | 13 + .../playlist/playlist_update_view.rs | 22 +- .../app/components/rbac/group_management.rs | 511 ++++++++++++ frontend/src/app/components/rbac/mod.rs | 5 + frontend/src/app/components/rbac/rbac_view.rs | 86 ++ .../app/components/rbac/user_management.rs | 494 +++++++++++ .../src/app/components/role_based_content.rs | 53 +- .../src/app/components/setup/setup_helpers.rs | 2 + frontend/src/app/components/sidebar.rs | 109 ++- .../components/source_editor/editor_view.rs | 29 +- .../app/components/source_editor/sidebar.rs | 10 +- frontend/src/app/components/userlist/list.rs | 14 +- .../userlist/proxy_user_credentials_form.rs | 16 +- .../src/app/components/userlist/user_table.rs | 28 +- frontend/src/hooks/use_service_context.rs | 10 +- frontend/src/model/view_type.rs | 4 + frontend/src/services/auth_service.rs | 39 +- frontend/src/services/config_service.rs | 65 +- frontend/src/services/mod.rs | 5 +- frontend/src/services/rbac_service.rs | 113 +++ shared/src/model/auth/mod.rs | 3 +- shared/src/model/auth/permission.rs | 153 ++++ shared/src/model/auth/user.rs | 78 +- shared/src/model/config/web_auth.rs | 11 +- shared/src/model/web_socket.rs | 5 +- shared/src/utils/bitset.rs | 46 +- shared/src/utils/constants.rs | 1 + shared/src/utils/default_utils.rs | 9 +- 72 files changed, 4471 insertions(+), 447 deletions(-) create mode 100644 backend/src/api/endpoints/rbac_api.rs create mode 100644 frontend/src/app/components/no_access.rs create mode 100644 frontend/src/app/components/rbac/group_management.rs create mode 100644 frontend/src/app/components/rbac/mod.rs create mode 100644 frontend/src/app/components/rbac/rbac_view.rs create mode 100644 frontend/src/app/components/rbac/user_management.rs create mode 100644 frontend/src/services/rbac_service.rs create mode 100644 shared/src/model/auth/permission.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a649caa1..c66c2a7e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,22 @@ ## 🌟 New Features +- **Role-Based Access Control (RBAC)**: Replaced the binary admin/non-admin model with fine-grained, group-based permissions. + - **14 permissions** across 7 domains (`config`, `source`, `user`, `playlist`, `library`, `system`, `epg`), + each with independent `.read` and `.write` grants. + - **Group management** via `groups.txt` — define custom roles (e.g., `viewer`, `source_manager`) + with specific permission sets. + - **Extended `user.txt` format** — users can now be assigned to one or more groups + (`username:hash:group1,group2`). Missing group field defaults to `admin` for backward compatibility. + - **Compact JWT encoding** — permissions are resolved at login and stored as a `u16` bitmask in JWT claims. + Backend middleware checks permissions via single-instruction bitwise tests. + - **Password-version tracking** — `pwd_version` in JWT enables automatic token invalidation when a user's password changes. + - **Backend permission middleware** — per-route `require_permission()` guards replace the old blanket admin check. The backend is the security boundary. + - **RBAC management API** — CRUD endpoints for web UI users and groups (`/api/v1/rbac/users`, `/api/v1/rbac/groups`, `/api/v1/rbac/permissions`). + - **Frontend permission gating** — UI elements (buttons, menu items, views) are cosmetically hidden based on the user's resolved permissions. + - **RBAC admin panel** — new Web UI page with tabbed user/group management, permission checkbox grid, and write-without-read warnings. + - **No-access page** — users with zero permissions see a friendly "no access" screen instead of an empty dashboard. + - **Built-in `admin` group** — reserved, always grants all permissions (`*`), cannot be deleted or modified. - **User Connection Priority**: API users now carry a `priority` field (type `i8`, nice-style: lower value = higher priority, default `0`, probe `127`). When all provider slots are occupied and a higher-priority user connects, the lowest-priority active connection on that provider is evicted (oldest first when tied). Only connections with exactly one active listener are eligible for eviction; shared connections @@ -188,6 +204,14 @@ active URL of the specified provider. ## ⚙️ New Settings +- **config.yml (`web_ui.auth`)**: + - Added `groupfile` (optional, default: `groups.txt` in same directory as `userfile`): path to the RBAC group definitions file. +- **`user.txt`** (extended format, backward compatible): + - Format is now `username:argon2_hash[:group1,group2,...]`. The optional third field assigns group memberships. + Missing third field defaults to the `admin` group for full backward compatibility. +- **`groups.txt`** (new file): + - Defines permission groups in `group_name:permission1,permission2,...` format. + The `admin` group is built-in and cannot be defined here. See the configuration docs for the full permission list. - **api-proxy.yml / Web UI (user)**: - Added `priority` (`i8`, default `0`) to user credentials. Lower value = higher priority (nice-style). Configurable via Web UI user editor. Negative values are valid and represent higher-than-default priority. diff --git a/README.md b/README.md index f50ffe3e0..06de0f52f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ Define complex filters using expressive logic, e.g.: - Send notifications via **Telegram**, **Pushover**, or custom **REST** endpoints when problems occur. - Track group changes and get real-time alerts. +## 🔒 Role-Based Access Control + +- Fine-grained permissions across 7 domains (config, source, user, playlist, library, system, epg). +- Manage users and groups via flat files (`user.txt`, `groups.txt`) or the Web UI. +- Permissions encoded as compact bitmask in JWT — zero file I/O on every request. +- Built-in `admin` group with full access; custom groups with any permission combination. +- Backward compatible — existing `user.txt` files work without changes. + ## 📺 Stream Management - Share live TV connections. diff --git a/backend/src/api/endpoints/library_api.rs b/backend/src/api/endpoints/library_api.rs index 3dd84a934..e56d59ce0 100644 --- a/backend/src/api/endpoints/library_api.rs +++ b/backend/src/api/endpoints/library_api.rs @@ -1,14 +1,11 @@ -use crate::{ - api::{ - library_scan::{spawn_library_scan, LibraryScanTaskOptions}, - model::{AppState, EventMessage}, - }, - library::{resolve_metadata_storage_path, LibraryProcessor}, -}; +use crate::{api::{ + library_scan::{spawn_library_scan, LibraryScanTaskOptions}, + model::{AppState, EventMessage}, +}, auth::permission_layer, library::{resolve_metadata_storage_path, LibraryProcessor}}; use axum::response::IntoResponse; use log::{debug, warn}; use serde_json::json; -use shared::model::{LibraryScanRequest, LibraryScanSummary, LibraryScanSummaryStatus, LibraryStatus}; +use shared::model::{permission::Permission, LibraryScanRequest, LibraryScanSummary, LibraryScanSummaryStatus, LibraryStatus}; use std::sync::Arc; // Triggers a library scan @@ -104,9 +101,23 @@ async fn get_library_status( axum::Json(response).into_response() } -/// Registers Library API routes -pub fn library_api_register(router: axum::Router>) -> axum::Router> { - router - .route("/library/scan", axum::routing::post(scan_library)) - .route("/library/status", axum::routing::get(get_library_status)) +/// Registers Library API routes. +pub fn library_api_register( + router: axum::Router>, + app_state: Option<&Arc>, +) -> axum::Router> { + match app_state { + Some(app_state) => router + .route( + "/library/status", + axum::routing::get(get_library_status).layer(permission_layer!(app_state, Permission::LibraryRead)), + ) + .route( + "/library/scan", + axum::routing::post(scan_library).layer(permission_layer!(app_state, Permission::LibraryWrite)), + ), + None => router + .route("/library/scan", axum::routing::post(scan_library)) + .route("/library/status", axum::routing::get(get_library_status)), + } } diff --git a/backend/src/api/endpoints/mod.rs b/backend/src/api/endpoints/mod.rs index da7091bf9..2e742dca7 100644 --- a/backend/src/api/endpoints/mod.rs +++ b/backend/src/api/endpoints/mod.rs @@ -6,6 +6,7 @@ pub(in crate::api) mod hdhomerun_api; pub(in crate::api) mod hls_api; mod library_api; pub(in crate::api) mod m3u_api; +mod rbac_api; mod user_api; pub(in crate::api) mod v1_api; mod v1_api_config; diff --git a/backend/src/api/endpoints/rbac_api.rs b/backend/src/api/endpoints/rbac_api.rs new file mode 100644 index 000000000..9f2eaa093 --- /dev/null +++ b/backend/src/api/endpoints/rbac_api.rs @@ -0,0 +1,788 @@ +use crate::{ + api::model::AppState, + auth::{generate_password_from_input, verify_token, AuthBearer}, + model::{RbacGroup, WebAuthConfig, WebUiUser}, + utils, +}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, Router, +}; +use log::{info, warn}; +use rand::Rng; +use serde::Deserialize; +use serde_json::json; +use shared::model::{ + permission::{permission_from_name, Permission, PermissionSet, PERMISSION_NAMES}, + RbacGroupDto, WebUiUserDto, +}; +use std::{ + collections::HashSet, + path::{Path as FsPath, PathBuf}, + sync::Arc, +}; + +const RBAC_MUTATION_LOCK: &str = "rbac:mutation"; + +#[derive(Debug, Deserialize)] +struct CreateUserRequest { + username: String, + password: String, + #[serde(default)] + groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct UpdateUserRequest { + password: Option, + #[serde(default)] + groups: Vec, +} + +#[derive(Debug, Deserialize)] +struct CreateGroupRequest { + name: String, + #[serde(default)] + permissions: Vec, +} + +#[derive(Debug, serde::Serialize)] +struct PermissionInfo { + name: &'static str, + reserved: bool, +} + +fn create_temp_path(file_path: &FsPath) -> PathBuf { + let file_name = + file_path.file_name().and_then(|value| value.to_str()).filter(|value| !value.is_empty()).unwrap_or("rbac"); + let temp_name = format!(".{file_name}.tmp-{}-{}", std::process::id(), rand::rng().random::()); + match file_path.parent() { + Some(parent) => parent.join(temp_name), + None => PathBuf::from(temp_name), + } +} + +async fn replace_file_atomic(source: &FsPath, target: &FsPath) -> std::io::Result<()> { + match tokio::fs::rename(source, target).await { + Ok(()) => Ok(()), + Err(err) => { + #[cfg(windows)] + { + if target.exists() { + tokio::fs::remove_file(target).await?; + return tokio::fs::rename(source, target).await; + } + } + Err(err) + } + } +} + +async fn write_text_file_atomic(path: &FsPath, content: &str) -> Result<(), std::io::Error> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let temp_path = create_temp_path(path); + tokio::fs::write(&temp_path, content).await?; + if let Err(err) = replace_file_atomic(&temp_path, path).await { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(err); + } + Ok(()) +} + +fn serialize_users_file(users: &[WebUiUser]) -> String { + users.iter() + .map(|user| { + if user.groups.is_empty() || (user.groups.len() == 1 && user.groups[0].eq_ignore_ascii_case("admin")) { + format!("{}:{}", user.username, user.password_hash) + } else { + format!("{}:{}:{}", user.username, user.password_hash, user.groups.join(",")) + } + }) + .collect::>() + .join("\n") +} + +fn serialize_groups_file(groups: &[RbacGroup]) -> String { + groups.iter() + .map(|group| { + let permissions = PERMISSION_NAMES + .iter() + .filter_map(|(name, permission)| group.permissions.contains(*permission).then_some(*name)) + .collect::>() + .join(","); + format!("{}:{permissions}", group.name) + }) + .collect::>() + .join("\n") +} + +fn normalize_name(value: &str) -> String { value.trim().to_string() } + +fn normalize_groups(groups: &[String]) -> Vec { + let mut normalized = Vec::with_capacity(groups.len()); + let mut seen = HashSet::with_capacity(groups.len()); + for group in groups { + let group = group.trim(); + if group.is_empty() { + continue; + } + let key = group.to_ascii_lowercase(); + if seen.insert(key) { + normalized.push(group.to_string()); + } + } + if normalized.iter().any(|group| group.eq_ignore_ascii_case("admin")) { + return vec![String::from("admin")]; + } + normalized +} + +fn validate_group_names(groups: &[String], available_groups: &[RbacGroup]) -> Result<(), String> { + for group in groups { + if group.eq_ignore_ascii_case("admin") { + continue; + } + if !available_groups.iter().any(|candidate| candidate.name.eq_ignore_ascii_case(group)) { + return Err(format!("Unknown group '{group}'")); + } + } + Ok(()) +} + +fn reject_empty_groups(groups: &[String]) -> Result<(), (StatusCode, &'static str)> { + if groups.is_empty() { + Err((StatusCode::BAD_REQUEST, "At least one group must be assigned")) + } else { + Ok(()) + } +} + +fn normalize_permissions(permission_names: &[String]) -> PermissionSet { + let mut permissions = PermissionSet::new(); + for permission_name in permission_names { + let trimmed = permission_name.trim(); + if trimmed.is_empty() { + continue; + } + match permission_from_name(trimmed) { + Some(permission) => permissions.set(permission), + None => warn!("RBAC API: unknown permission '{trimmed}' ignored"), + } + } + permissions +} + +fn validate_permission_dependencies(permission_names: &[String]) -> Result<(), Vec> { + let normalized = normalize_permissions(permission_names); + let invalid_domains = PERMISSION_NAMES + .iter() + .filter_map(|(name, permission)| { + name.strip_suffix(".write").and_then(|domain| { + normalized.contains(*permission).then(|| { + let read_name = format!("{domain}.read"); + permission_from_name(&read_name) + .filter(|read_permission| !normalized.contains(*read_permission)) + .map(|_| domain.to_string()) + })? + }) + }) + .collect::>(); + + if invalid_domains.is_empty() { + Ok(()) + } else { + Err(invalid_domains) + } +} + +fn user_has_admin_group(user: &WebUiUser) -> bool { user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin")) } + +fn count_admin_users(users: &[WebUiUser]) -> usize { users.iter().filter(|user| user_has_admin_group(user)).count() } + +fn resolve_auth_paths(web_auth: &WebAuthConfig, config_path: &str) -> (PathBuf, PathBuf) { + let userfile_name = if utils::is_blank_or_default_user_file_path(&web_auth.userfile) { + utils::get_default_user_file_path(config_path) + } else { + web_auth.userfile.as_ref().map_or_else(String::new, std::borrow::ToOwned::to_owned) + }; + let groupfile_name = if utils::is_blank_or_default_user_group_file_path(&web_auth.groupfile) { + utils::get_default_user_group_file_path(config_path) + } else { + web_auth.groupfile.as_ref().map_or_else(String::new, std::borrow::ToOwned::to_owned) + }; + + let resolve_path = |file_name: &str| { + let path = PathBuf::from(file_name); + if path.is_absolute() || utils::path_exists(&path) { + path + } else { + PathBuf::from(config_path).join(file_name) + } + }; + + let userfile_path = if utils::is_blank_or_default_user_file_path(&web_auth.userfile) { + PathBuf::from(&userfile_name) + } else { + resolve_path(&userfile_name) + }; + let groupfile_path = if utils::is_blank_or_default_user_group_file_path(&web_auth.groupfile) { + PathBuf::from(&groupfile_name) + } else { + resolve_path(&groupfile_name) + }; + + (userfile_path, groupfile_path) +} + +fn current_web_auth_snapshot(app_state: &AppState) -> Result<(WebAuthConfig, String), StatusCode> { + let paths = app_state.app_config.paths.load(); + let config = app_state.app_config.config.load(); + let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { + return Err(StatusCode::UNAUTHORIZED); + }; + Ok((web_auth.clone(), paths.config_path.clone())) +} + +fn current_username(app_state: &AppState, token: &str) -> Option { + let config = app_state.app_config.config.load(); + let web_auth = config.web_ui.as_ref()?.auth.as_ref()?; + verify_token(token, web_auth.secret.as_bytes()).map(|token_data| token_data.claims.username) +} + +fn store_reprepared_web_auth(app_state: &AppState) -> Result<(), String> { + let paths = app_state.app_config.paths.load(); + let mut config = (*app_state.app_config.config.load_full()).clone(); + let Some(web_ui) = config.web_ui.as_mut() else { + return Err("Web UI auth is not configured".to_string()); + }; + let Some(auth) = web_ui.auth.as_mut() else { + return Err("Web UI auth is not configured".to_string()); + }; + auth.prepare(paths.config_path.as_str()).map_err(|err| err.to_string())?; + app_state.app_config.config.store(Arc::new(config)); + Ok(()) +} + +fn token_from_extensions_or_headers(request: &mut axum::extract::Request) -> Result { + if let Some(token) = request.extensions().get::().cloned() { + return Ok(token); + } + + let token = AuthBearer::from_headers(request.headers()).map_err(|(status, _)| status)?; + request.extensions_mut().insert(token.clone()); + Ok(token) +} + +async fn check_permission( + permission: Permission, + State(app_state): State>, + mut request: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + let token = token_from_extensions_or_headers(&mut request)?; + let config = app_state.app_config.config.load(); + let Some(web_auth_config) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { + return Err(StatusCode::UNAUTHORIZED); + }; + + let Some(token_data) = verify_token(&token.0, web_auth_config.secret.as_bytes()) else { + return Err(StatusCode::UNAUTHORIZED); + }; + + if token_data.claims.permissions.contains(permission) { + return Ok(next.run(request).await); + } + + Err(StatusCode::FORBIDDEN) +} + +async fn validator_user_read( + state: State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + check_permission(Permission::UserRead, state, request, next).await +} + +async fn validator_user_write( + state: State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + check_permission(Permission::UserWrite, state, request, next).await +} + +async fn list_users(State(app_state): State>) -> impl IntoResponse { + let config = app_state.app_config.config.load(); + let users: Vec = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.auth.as_ref()) + .and_then(|auth| auth.t_users.as_ref()) + .map(|users| { + users.iter() + .map(|user| WebUiUserDto { + username: user.username.clone(), + groups: user.groups.clone(), + }) + .collect() + }) + .unwrap_or_default(); + Json(users) +} + +async fn list_groups(State(app_state): State>) -> impl IntoResponse { + let config = app_state.app_config.config.load(); + let mut groups = vec![RbacGroupDto { + name: "admin".to_string(), + permissions: vec!["*".to_string()], + builtin: true, + }]; + + if let Some(parsed_groups) = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.auth.as_ref()) + .and_then(|auth| auth.t_groups.as_ref()) + { + for group in parsed_groups { + let permissions = PERMISSION_NAMES + .iter() + .filter_map(|(name, permission)| group.permissions.contains(*permission).then_some((*name).to_string())) + .collect(); + groups.push(RbacGroupDto { + name: group.name.clone(), + permissions, + builtin: false, + }); + } + } + + Json(groups) +} + +async fn list_permissions() -> impl IntoResponse { + let permissions = PERMISSION_NAMES + .iter() + .map(|(name, permission)| PermissionInfo { + name, + reserved: matches!(permission, Permission::EpgWrite), + }) + .collect::>(); + Json(permissions) +} + +async fn create_user( + State(app_state): State>, + Json(request): Json, +) -> impl IntoResponse { + let username = normalize_name(&request.username); + if username.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Username cannot be empty"}))).into_response(); + } + if request.password.len() < 8 { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Password must be at least 8 characters"}))).into_response(); + } + let groups = normalize_groups(&request.groups); + if let Err((status, error)) = reject_empty_groups(&groups) { + return (status, Json(json!({"error": error}))).into_response(); + } + + let password = request.password; + let hash = match tokio::task::spawn_blocking(move || generate_password_from_input(&password)).await { + Ok(Ok(hash)) => hash, + Ok(Err(err)) => { + return (StatusCode::BAD_REQUEST, Json(json!({"error": err.to_string()}))).into_response(); + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Password hashing task failed: {err}")})), + ) + .into_response(); + } + }; + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let mut users = web_auth.t_users.clone().unwrap_or_default(); + let available_groups = web_auth.t_groups.clone().unwrap_or_default(); + + if users.iter().any(|user| user.username.eq_ignore_ascii_case(&username)) { + return (StatusCode::CONFLICT, Json(json!({"error": format!("User '{username}' already exists")}))).into_response(); + } + if let Err(err) = validate_group_names(&groups, &available_groups) { + return (StatusCode::BAD_REQUEST, Json(json!({"error": err}))).into_response(); + } + + users.push(WebUiUser { + username: username.clone(), + password_hash: hash, + groups, + }); + + let (userfile_path, _) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&userfile_path).await; + if let Err(err) = write_text_file_atomic(&userfile_path, &serialize_users_file(&users)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: created web UI user '{username}'"); + StatusCode::CREATED.into_response() +} + +async fn update_user( + State(app_state): State>, + Path(username): Path, + Json(request): Json, +) -> impl IntoResponse { + let username = normalize_name(&username); + if username.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Username cannot be empty"}))).into_response(); + } + if request.password.as_ref().is_some_and(|password| password.len() < 8) { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Password must be at least 8 characters"}))).into_response(); + } + + let groups = normalize_groups(&request.groups); + if let Err((status, error)) = reject_empty_groups(&groups) { + return (status, Json(json!({"error": error}))).into_response(); + } + + let new_hash = if let Some(password) = request.password { + match tokio::task::spawn_blocking(move || generate_password_from_input(&password)).await { + Ok(Ok(hash)) => Some(hash), + Ok(Err(err)) => { + return (StatusCode::BAD_REQUEST, Json(json!({"error": err.to_string()}))).into_response(); + } + Err(err) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": format!("Password hashing task failed: {err}")})), + ) + .into_response(); + } + } + } else { + None + }; + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let mut users = web_auth.t_users.clone().unwrap_or_default(); + let available_groups = web_auth.t_groups.clone().unwrap_or_default(); + if let Err(err) = validate_group_names(&groups, &available_groups) { + return (StatusCode::BAD_REQUEST, Json(json!({"error": err}))).into_response(); + } + + let Some(user_index) = users.iter().position(|user| user.username.eq_ignore_ascii_case(&username)) else { + return (StatusCode::NOT_FOUND, Json(json!({"error": format!("User '{username}' not found")}))).into_response(); + }; + + let removing_admin = user_has_admin_group(&users[user_index]) && !groups.iter().any(|group| group.eq_ignore_ascii_case("admin")); + if removing_admin && count_admin_users(&users) == 1 { + return ( + StatusCode::CONFLICT, + Json(json!({"error": "Cannot remove admin group from the last admin user"})), + ) + .into_response(); + } + + users[user_index].groups = groups; + if let Some(hash) = new_hash { + users[user_index].password_hash = hash; + } + + let (userfile_path, _) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&userfile_path).await; + if let Err(err) = write_text_file_atomic(&userfile_path, &serialize_users_file(&users)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: updated web UI user '{username}'"); + StatusCode::OK.into_response() +} + +async fn delete_user( + State(app_state): State>, + AuthBearer(token): AuthBearer, + Path(username): Path, +) -> impl IntoResponse { + let username = normalize_name(&username); + if username.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Username cannot be empty"}))).into_response(); + } + + if current_username(&app_state, &token).is_some_and(|current_username| current_username.eq_ignore_ascii_case(&username)) { + return (StatusCode::FORBIDDEN, Json(json!({"error": "Admin cannot delete themselves"}))).into_response(); + } + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let mut users = web_auth.t_users.clone().unwrap_or_default(); + let Some(user_index) = users.iter().position(|user| user.username.eq_ignore_ascii_case(&username)) else { + return (StatusCode::NOT_FOUND, Json(json!({"error": format!("User '{username}' not found")}))).into_response(); + }; + + if user_has_admin_group(&users[user_index]) && count_admin_users(&users) == 1 { + return (StatusCode::CONFLICT, Json(json!({"error": "Cannot delete the last admin user"}))).into_response(); + } + + users.remove(user_index); + + let (userfile_path, _) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&userfile_path).await; + if let Err(err) = write_text_file_atomic(&userfile_path, &serialize_users_file(&users)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: deleted web UI user '{username}'"); + StatusCode::OK.into_response() +} + +async fn create_group( + State(app_state): State>, + Json(request): Json, +) -> impl IntoResponse { + let name = normalize_name(&request.name); + if name.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group name cannot be empty"}))).into_response(); + } + if name.eq_ignore_ascii_case("admin") { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group 'admin' is reserved"}))).into_response(); + } + if let Err(domains) = validate_permission_dependencies(&request.permissions) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("Write permissions require matching read permissions for: {}", domains.join(", "))})), + ) + .into_response(); + } + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let mut groups = web_auth.t_groups.clone().unwrap_or_default(); + if groups.iter().any(|group| group.name.eq_ignore_ascii_case(&name)) { + return (StatusCode::CONFLICT, Json(json!({"error": format!("Group '{name}' already exists")}))).into_response(); + } + + groups.push(RbacGroup { + name: name.clone(), + permissions: normalize_permissions(&request.permissions), + }); + + let (_, groupfile_path) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&groupfile_path).await; + if let Err(err) = write_text_file_atomic(&groupfile_path, &serialize_groups_file(&groups)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: created group '{name}'"); + StatusCode::CREATED.into_response() +} + +async fn update_group( + State(app_state): State>, + Path(name): Path, + Json(request): Json, +) -> impl IntoResponse { + let name = normalize_name(&name); + if name.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group name cannot be empty"}))).into_response(); + } + if name.eq_ignore_ascii_case("admin") { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group 'admin' cannot be modified"}))).into_response(); + } + if let Err(domains) = validate_permission_dependencies(&request.permissions) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("Write permissions require matching read permissions for: {}", domains.join(", "))})), + ) + .into_response(); + } + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let mut groups = web_auth.t_groups.clone().unwrap_or_default(); + let Some(group_index) = groups.iter().position(|group| group.name.eq_ignore_ascii_case(&name)) else { + return (StatusCode::NOT_FOUND, Json(json!({"error": format!("Group '{name}' not found")}))).into_response(); + }; + + groups[group_index].permissions = normalize_permissions(&request.permissions); + + let (_, groupfile_path) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&groupfile_path).await; + if let Err(err) = write_text_file_atomic(&groupfile_path, &serialize_groups_file(&groups)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: updated group '{name}'"); + StatusCode::OK.into_response() +} + +async fn delete_group( + State(app_state): State>, + Path(name): Path, +) -> impl IntoResponse { + let name = normalize_name(&name); + if name.is_empty() { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group name cannot be empty"}))).into_response(); + } + if name.eq_ignore_ascii_case("admin") { + return (StatusCode::BAD_REQUEST, Json(json!({"error": "Group 'admin' cannot be deleted"}))).into_response(); + } + + let _rbac_lock = app_state.app_config.file_locks.write_lock_str(RBAC_MUTATION_LOCK).await; + let (web_auth, config_path) = match current_web_auth_snapshot(&app_state) { + Ok(value) => value, + Err(status) => return status.into_response(), + }; + let users = web_auth.t_users.clone().unwrap_or_default(); + let mut groups = web_auth.t_groups.clone().unwrap_or_default(); + let Some(group_index) = groups.iter().position(|group| group.name.eq_ignore_ascii_case(&name)) else { + return (StatusCode::NOT_FOUND, Json(json!({"error": format!("Group '{name}' not found")}))).into_response(); + }; + + let assigned_users = users + .iter() + .filter(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case(&name))) + .map(|user| user.username.clone()) + .collect::>(); + if !assigned_users.is_empty() { + return ( + StatusCode::CONFLICT, + Json(json!({ + "error": format!("Group '{name}' is still assigned to users"), + "users": assigned_users, + })), + ) + .into_response(); + } + + groups.remove(group_index); + + let (_, groupfile_path) = resolve_auth_paths(&web_auth, &config_path); + let _lock = app_state.app_config.file_locks.write_lock(&groupfile_path).await; + if let Err(err) = write_text_file_atomic(&groupfile_path, &serialize_groups_file(&groups)).await { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err.to_string()}))).into_response(); + } + if let Err(err) = store_reprepared_web_auth(&app_state) { + return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": err}))).into_response(); + } + + info!("RBAC API: deleted group '{name}'"); + StatusCode::OK.into_response() +} + +pub fn rbac_api_register(app_state: Arc) -> Router> { + Router::new() + .route( + "/rbac/users", + axum::routing::get(list_users) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_read)) + .post(create_user) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)), + ) + .route( + "/rbac/users/{username}", + axum::routing::put(update_user) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)) + .delete(delete_user) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)), + ) + .route( + "/rbac/groups", + axum::routing::get(list_groups) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_read)) + .post(create_group) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)), + ) + .route( + "/rbac/groups/{name}", + axum::routing::put(update_group) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)) + .delete(delete_group) + .layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_user_write)), + ) + .route( + "/rbac/permissions", + axum::routing::get(list_permissions) + .layer(axum::middleware::from_fn_with_state(app_state, validator_user_read)), + ) +} + +#[cfg(test)] +mod tests { + use super::{reject_empty_groups, resolve_auth_paths, validate_permission_dependencies}; + use axum::http::StatusCode; + use crate::model::WebAuthConfig; + use crate::utils; + + #[test] + fn rejects_write_permission_without_matching_read_permission() { + let result = validate_permission_dependencies(&["config.write".to_string()]); + + assert_eq!(result, Err(vec!["config".to_string()])); + } + + #[test] + fn rejects_empty_group_assignments() { + assert_eq!( + reject_empty_groups(&[]), + Err((StatusCode::BAD_REQUEST, "At least one group must be assigned")), + ); + } + + #[test] + fn resolve_auth_paths_uses_default_group_file_path_for_groups() { + let web_auth = WebAuthConfig { + enabled: true, + issuer: "test".to_string(), + secret: "secret".to_string(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: None, + t_groups: None, + }; + + let config_path = "config"; + let (_, groupfile_path) = resolve_auth_paths(&web_auth, config_path); + + assert_eq!(groupfile_path, std::path::PathBuf::from(utils::get_default_user_group_file_path(config_path))); + } +} diff --git a/backend/src/api/endpoints/user_api.rs b/backend/src/api/endpoints/user_api.rs index 526c9960e..6c67f42bb 100644 --- a/backend/src/api/endpoints/user_api.rs +++ b/backend/src/api/endpoints/user_api.rs @@ -3,7 +3,7 @@ use crate::{ api_utils::{get_user_target_by_username, get_username_from_auth_header, try_unwrap_body}, model::AppState, }, - auth::{validator_user, AuthBearer}, + auth::{validator_api_user, AuthBearer}, model::{AppConfig, ConfigTarget, PlaylistXtreamCategory}, repository::{ iter_raw_m3u_target_playlist, load_user_bouquet_as_json, save_user_bouquet, xtream_get_playlist_categories, @@ -169,14 +169,14 @@ async fn playlist_bouquet( .body(axum::body::Body::from("{}"))) } -pub fn user_api_register(app_state: Arc, web_ui_path: &str) -> axum::Router> { +pub fn user_api_register(app_state: &Arc, web_ui_path: &str) -> axum::Router> { axum::Router::new().nest( &concat_path_leading_slash(web_ui_path, "/api/v1/user"), axum::Router::new() .route("/playlist/categories", axum::routing::get(playlist_categories)) .route("/playlist/bouquet", axum::routing::get(playlist_bouquet)) .route("/playlist/bouquet", axum::routing::post(save_playlist_bouquet)) - .route_layer(axum::middleware::from_fn_with_state(app_state, validator_user)), + .route_layer(axum::middleware::from_fn_with_state(Arc::clone(app_state), validator_api_user)), ) // cfg.service(web::scope("/api/v1/user") diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index 72a1201ae..5b08f3402 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -3,19 +3,25 @@ use crate::{ api_utils::{internal_server_error, json_or_bin_response, try_unwrap_body}, endpoints::{ download_api, extract_accept_header::ExtractAcceptHeader, library_api::library_api_register, + rbac_api::rbac_api_register, user_api::user_api_register, v1_api_config::v1_api_config_register, - v1_api_playlist::{v1_api_playlist_register_protected, v1_api_playlist_register_public}, - v1_api_user::v1_api_user_register, + v1_api_config::v1_api_config_register_with_permissions, v1_api_playlist::{ + v1_api_playlist_register_public, + v1_api_playlist_register_protected, + v1_api_playlist_register_with_permissions, + }, + v1_api_user::{v1_api_user_register, v1_api_user_register_with_permissions}, }, model::AppState, }, - auth::validator_admin, processing::geoip::{update_geoip_db, GeoIpUpdateError}, utils::ip_checker::get_ips, VERSION, }; use axum::response::IntoResponse; +use crate::auth::permission_layer; use shared::{ + model::permission::Permission, model::{IpCheckDto, StatusCheck}, utils::concat_path_leading_slash, }; @@ -107,34 +113,51 @@ async fn ipinfo(axum::extract::State(app_state): axum::extract::State, + app_state: &Arc, web_ui_path: &str, ) -> axum::Router> { let public_router = v1_api_playlist_register_public(axum::Router::new()); - let mut protected_router = axum::Router::new(); - protected_router = protected_router + let system_read = axum::routing::Router::new() .route("/status", axum::routing::get(status)) .route("/streams", axum::routing::get(streams)) - .route("/geoip/update", axum::routing::get(geoip_update)) - .route("/file/download", axum::routing::post(download_api::queue_download_file)) .route("/file/download/info", axum::routing::get(download_api::download_file_info)) .route("/ipinfo", axum::routing::get(ipinfo)); - protected_router = v1_api_config_register(protected_router); - protected_router = v1_api_user_register(protected_router); - protected_router = v1_api_playlist_register_protected(protected_router); - protected_router = library_api_register(protected_router); - if web_auth_enabled { - protected_router = - protected_router.route_layer(axum::middleware::from_fn_with_state(Arc::clone(&app_state), validator_admin)); - } - let config = app_state.app_config.config.load(); - let mut base_router = axum::Router::new(); + let system_write = axum::routing::Router::new() + .route("/geoip/update", axum::routing::get(geoip_update)) + .route("/file/download", axum::routing::post(download_api::queue_download_file)); + + let mut router = axum::routing::Router::new(); + + if web_auth_enabled { + router = router + .merge(system_read.layer(permission_layer!(app_state, Permission::SystemRead))) + .merge(system_write.layer(permission_layer!(app_state, Permission::SystemWrite))) + .merge(v1_api_config_register_with_permissions(app_state)) + .merge(v1_api_user_register_with_permissions(axum::routing::Router::new(), app_state)) + .merge(v1_api_playlist_register_with_permissions(axum::routing::Router::new(), app_state)) + .merge(library_api_register(axum::routing::Router::new(), Some(app_state))) + .merge(rbac_api_register(Arc::clone(app_state))); + } else { + router = router + .merge(system_read) + .merge(system_write) + .merge(v1_api_config_register(axum::routing::Router::new())) + .merge(v1_api_user_register(axum::routing::Router::new())) + .merge(v1_api_playlist_register_protected(axum::routing::Router::new())) + .merge(library_api_register(axum::routing::Router::new(), None)); + } + + let config = app_state.app_config.config.load(); + let mut base_router = axum::routing::Router::new(); + if config.web_ui.as_ref().is_none_or(|c| c.user_ui_enabled) { base_router = base_router.merge(user_api_register(app_state, web_ui_path)); } + + let api_prefix = concat_path_leading_slash(web_ui_path, "api/v1"); base_router - .nest(&concat_path_leading_slash(web_ui_path, "api/v1"), public_router) - .nest(&concat_path_leading_slash(web_ui_path, "api/v1"), protected_router) + .nest(&api_prefix, public_router) + .nest(&api_prefix, router) } diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index d71f953cf..88758aa68 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -1,17 +1,12 @@ -use crate::{ - api::{ - api_utils::{internal_server_error, try_unwrap_body}, - config_file::ConfigFile, - model::AppState, - }, - model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource}, - utils, - utils::{ - persist_messaging_templates, prepare_sources_batch, prepare_users, read_api_proxy_file, - request::download_text_content, - xtream::{get_xtream_stream_url_base, xtream_login}, - }, -}; +use crate::{api::{ + api_utils::{internal_server_error, try_unwrap_body}, + config_file::ConfigFile, + model::AppState, +}, auth::{verify_token, AuthBearer, permission_layer}, model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource}, utils, utils::{ + persist_messaging_templates, prepare_sources_batch, prepare_users, read_api_proxy_file, + request::download_text_content, + xtream::{get_xtream_stream_url_base, xtream_login}, +}}; use axum::{ http::{header::IF_MATCH, HeaderMap, HeaderName, HeaderValue, StatusCode}, response::IntoResponse, @@ -21,6 +16,7 @@ use log::error; use serde_json::json; use shared::{ error::TuliproxError, + model::permission::{Permission, PermissionSet}, model::{ApiProxyConfigDto, ConfigDto, SourcesConfigDto, XtreamLoginRequest}, utils::{ HEADER_CONFIG_API_PROXY_REVISION, HEADER_CONFIG_MAIN_REVISION, HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH, @@ -86,6 +82,46 @@ fn require_matching_revision( None } +fn has_any_permission(permissions: PermissionSet, required: &[Permission]) -> bool { + required.iter().any(|permission| permissions.contains(*permission)) +} + +fn decode_permissions(app_state: &AppState, token: &str) -> Option { + let config = app_state.app_config.config.load(); + let web_auth = config.web_ui.as_ref()?.auth.as_ref()?; + verify_token(token, web_auth.secret.as_bytes()).map(|token_data| token_data.claims.permissions) +} + +fn filter_api_proxy_by_permissions(api_proxy: &mut ApiProxyConfigDto, permissions: PermissionSet) { + if !permissions.contains(Permission::ConfigRead) { + api_proxy.server.clear(); + } + if !permissions.contains(Permission::UserRead) { + api_proxy.user.clear(); + } +} + +fn filter_app_config_by_permissions(app_config: &mut shared::model::AppConfigDto, permissions: PermissionSet) { + if !permissions.contains(Permission::ConfigRead) { + app_config.config = ConfigDto::default(); + if let Some(api_proxy) = app_config.api_proxy.as_mut() { + api_proxy.server.clear(); + } + } + + if !permissions.contains(Permission::SourceRead) { + app_config.sources = SourcesConfigDto::default(); + app_config.mappings = None; + app_config.templates = None; + } + + if let Some(api_proxy) = app_config.api_proxy.as_mut() { + if !permissions.contains(Permission::UserRead) { + api_proxy.user.clear(); + } + } +} + pub(in crate::api::endpoints) async fn intern_save_config_api_proxy( backup_dir: &str, api_proxy: &ApiProxyConfigDto, @@ -234,7 +270,7 @@ async fn save_config_sources( response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_SOURCES_REVISION, &updated_revision) } -async fn get_config_api_proxy_config( +async fn get_config_api_proxy_config_public( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let paths = app_state.app_config.paths.load(); @@ -330,7 +366,7 @@ async fn save_config_api_proxy_config( response_with_revision_header(StatusCode::OK.into_response(), HEADER_CONFIG_API_PROXY_REVISION, &updated_revision) } -async fn config(axum::extract::State(app_state): axum::extract::State>) -> impl IntoResponse + Send { +async fn config_public(axum::extract::State(app_state): axum::extract::State>) -> impl IntoResponse + Send { let (config_file_path, sources_file_path, api_proxy_file_path) = { let paths = app_state.app_config.paths.load(); ( @@ -388,6 +424,115 @@ async fn config(axum::extract::State(app_state): axum::extract::State>, +) -> impl IntoResponse + Send { + let Some(permissions) = decode_permissions(&app_state, &token) else { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + }; + if !has_any_permission( + permissions, + &[Permission::ConfigRead, Permission::SourceRead, Permission::UserRead], + ) { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + let (config_file_path, sources_file_path, api_proxy_file_path) = { + let paths = app_state.app_config.paths.load(); + ( + paths.config_file_path.clone(), + paths.sources_file_path.clone(), + paths.api_proxy_file_path.clone(), + ) + }; + + let main_revision = match read_file_revision(&config_file_path).await { + Ok(revision) => revision, + Err(err) => { + error!("Failed to read revision for config.yml '{config_file_path}': {err}"); + return internal_server_error!(); + } + }; + let sources_revision = match read_file_revision(&sources_file_path).await { + Ok(revision) => revision, + Err(err) => { + error!("Failed to read revision for source.yml '{sources_file_path}': {err}"); + return internal_server_error!(); + } + }; + let api_proxy_revision = match read_file_revision(&api_proxy_file_path).await { + Ok(revision) => revision, + Err(err) => { + error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}"); + return internal_server_error!(); + } + }; + + let read_result = { + let paths = app_state.app_config.paths.load(); + utils::read_app_config_dto(&paths, true, false).await + }; + match read_result { + Ok(mut app_config) => { + if let Err(err) = prepare_sources_batch(&mut app_config.sources, false).await { + error!("Failed to prepare sources batch: {err}"); + internal_server_error!() + } else if let Err(err) = prepare_users(&mut app_config, &app_state.app_config).await { + error!("Failed to prepare users: {err}"); + internal_server_error!() + } else { + filter_app_config_by_permissions(&mut app_config, permissions); + let response = axum::response::Json(app_config).into_response(); + let response = response_with_revision_header(response, HEADER_CONFIG_MAIN_REVISION, &main_revision); + let response = response_with_revision_header(response, HEADER_CONFIG_SOURCES_REVISION, &sources_revision); + response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &api_proxy_revision) + } + } + Err(err) => { + error!("Failed to read config files: {err}"); + internal_server_error!() + } + } +} + +async fn get_config_api_proxy_config( + AuthBearer(token): AuthBearer, + axum::extract::State(app_state): axum::extract::State>, +) -> impl IntoResponse + Send { + let Some(permissions) = decode_permissions(&app_state, &token) else { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + }; + if !has_any_permission(permissions, &[Permission::ConfigRead, Permission::UserRead]) { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + let paths = app_state.app_config.paths.load(); + let api_proxy_file_path = paths.api_proxy_file_path.clone(); + let revision = match read_file_revision(&api_proxy_file_path).await { + Ok(revision) => revision, + Err(err) => { + error!("Failed to read revision for api-proxy.yml '{api_proxy_file_path}': {err}"); + return internal_server_error!(); + } + }; + match read_api_proxy_file(api_proxy_file_path.as_str(), true) { + Ok(Some(mut api_proxy_dto)) => { + filter_api_proxy_by_permissions(&mut api_proxy_dto, permissions); + let response = axum::response::Json(api_proxy_dto).into_response(); + response_with_revision_header(response, HEADER_CONFIG_API_PROXY_REVISION, &revision) + } + Ok(None) => { + error!("Failed to read api proxy config"); + internal_server_error!() + } + Err(err) => { + error!("Failed to read api proxy config: {err}"); + internal_server_error!() + } + } +} + async fn config_batch_content( axum::extract::Path(input_id): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, @@ -464,20 +609,52 @@ async fn get_xtream_login_info( pub fn v1_api_config_register(router: Router>) -> axum::Router> { router - .route("/config", axum::routing::get(config)) + .route("/config", axum::routing::get(config_public)) .route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content)) .route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info)) .route("/config/main", axum::routing::post(save_config_main)) .route("/config/sources", axum::routing::post(save_config_sources)) - .route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config)) + .route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config_public)) .route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config)) } +pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Router> { + let base_read = Router::new() + .route("/config", axum::routing::get(config)) + .route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config)); + + // 2. Source Domain (Read & Write) + let source_read = Router::new() + .route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content)) + .route("/config/xtream/login-info", axum::routing::post(get_xtream_login_info)) + .layer(permission_layer!(app_state, Permission::SourceRead)); + + let source_write = Router::new() + .route("/config/sources", axum::routing::post(save_config_sources)) + .layer(permission_layer!(app_state, Permission::SourceWrite)); + + let config_write = Router::new() + .route("/config/main", axum::routing::post(save_config_main)) + .route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config)) + .layer(permission_layer!(app_state, Permission::ConfigWrite)); + + Router::new() + .merge(base_read) + .merge(source_read) + .merge(source_write) + .merge(config_write) +} #[cfg(test)] mod tests { - use super::require_matching_revision; + use super::{filter_api_proxy_by_permissions, filter_app_config_by_permissions, require_matching_revision}; use axum::http::{HeaderMap, HeaderValue, StatusCode}; - use shared::utils::{HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH}; + use shared::{ + model::{ + ApiProxyConfigDto, ApiProxyServerInfoDto, AppConfigDto, ConfigDto, Permission, PermissionSet, SourcesConfigDto, + TargetUserDto, + }, + utils::{HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH}, + }; #[test] fn require_matching_revision_rejects_missing_if_match_header() { @@ -507,4 +684,79 @@ mod tests { ); assert!(result.is_none(), "exact revision match should be accepted"); } + + #[test] + fn filter_app_config_clears_unauthorized_sections() { + let permissions: PermissionSet = Permission::ConfigRead.into(); + let mut app_config = AppConfigDto { + config: ConfigDto { + storage_dir: Some(String::from("storage")), + ..ConfigDto::default() + }, + sources: SourcesConfigDto { + inputs: vec![], + sources: vec![], + provider: Some(vec![]), + templates: Some(vec![]), + }, + mappings: None, + templates: Some(Default::default()), + api_proxy: Some(ApiProxyConfigDto { + server: vec![ApiProxyServerInfoDto { + name: String::from("main"), + protocol: String::from("http"), + host: String::from("localhost"), + port: None, + timezone: String::from("UTC"), + message: String::from("hello"), + path: None, + }], + user: vec![TargetUserDto { + target: String::from("target-a"), + credentials: vec![], + }], + use_user_db: true, + auth_error_status: 401, + }), + }; + + filter_app_config_by_permissions(&mut app_config, permissions); + + assert_eq!(app_config.config.storage_dir.as_deref(), Some("storage")); + assert_eq!(app_config.sources, SourcesConfigDto::default()); + assert!(app_config.templates.is_none()); + + let api_proxy = app_config.api_proxy.expect("api proxy should remain present"); + assert_eq!(api_proxy.server.len(), 1); + assert!(api_proxy.user.is_empty()); + assert!(api_proxy.use_user_db); + } + + #[test] + fn filter_api_proxy_keeps_user_section_only_with_user_read() { + let permissions: PermissionSet = Permission::UserRead.into(); + let mut api_proxy = ApiProxyConfigDto { + server: vec![ApiProxyServerInfoDto { + name: String::from("main"), + protocol: String::from("http"), + host: String::from("localhost"), + port: None, + timezone: String::from("UTC"), + message: String::from("hello"), + path: None, + }], + user: vec![TargetUserDto { + target: String::from("target-a"), + credentials: vec![], + }], + use_user_db: true, + auth_error_status: 401, + }; + + filter_api_proxy_by_permissions(&mut api_proxy, permissions); + + assert!(api_proxy.server.is_empty()); + assert_eq!(api_proxy.user.len(), 1); + assert!(api_proxy.use_user_db); + } } diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index 9a518828d..c3096b24e 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -1,23 +1,19 @@ -use crate::{ - api::{ - api_utils::{create_api_proxy_user, json_or_bin_response}, - endpoints::{ - api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}, - extract_accept_header::ExtractAcceptHeader, - xmltv_api::serve_epg_web_ui, - xtream_api::xtream_get_stream_info_response, - }, - model::AppState, +use crate::{api::{ + api_utils::{create_api_proxy_user, json_or_bin_response}, + endpoints::{ + api_playlist_utils::{get_playlist_for_custom_provider, get_playlist_for_input, get_playlist_for_target}, + extract_accept_header::ExtractAcceptHeader, + xmltv_api::serve_epg_web_ui, + xtream_api::xtream_get_stream_info_response, }, - auth::create_access_token, - model::{parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputFlags, ConfigInputFlagsSet, ConfigInputOptions}, - repository::xtream_get_item_for_stream_id, -}; + model::AppState, +}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id}; use axum::{response::IntoResponse, Router}; use log::{debug, error}; use serde_json::json; use shared::{ model::{ + permission::Permission, InputType, PlaylistEpgRequest, PlaylistRequest, ProxyType, TargetType, UiPlaylistItem, WebplayerUrlRequest, XtreamCluster, }, @@ -36,10 +32,7 @@ fn create_config_input_for_m3u(url: &str) -> ConfigInput { url: String::from(url), enabled: true, options: Some(ConfigInputOptions { - flags: ConfigInputFlagsSet::from_variants(&[ - ConfigInputFlags::XtreamLiveStreamUsePrefix, - ConfigInputFlags::ResolveBackground, - ]), + flags: ConfigInputFlags::XtreamLiveStreamUsePrefix | ConfigInputFlags::ResolveBackground, resolve_delay: shared::utils::default_resolve_delay_secs(), probe_delay: shared::utils::default_probe_delay_secs(), probe_live_interval_hours: 120, @@ -58,10 +51,7 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> password: Some(String::from(password)), enabled: true, options: Some(ConfigInputOptions { - flags: ConfigInputFlagsSet::from_variants(&[ - ConfigInputFlags::XtreamLiveStreamUsePrefix, - ConfigInputFlags::ResolveBackground, - ]), + flags: ConfigInputFlags::XtreamLiveStreamUsePrefix | ConfigInputFlags::ResolveBackground, resolve_delay: shared::utils::default_resolve_delay_secs(), probe_delay: shared::utils::default_probe_delay_secs(), probe_live_interval_hours: 120, @@ -310,6 +300,35 @@ pub fn v1_api_playlist_register_public( router.route("/playlist/resource/{resource}", axum::routing::get(playlist_resource)) } +pub fn v1_api_playlist_register_with_permissions( + router: Router>, + app_state: &Arc, +) -> axum::Router> { + + let read_routes = Router::new() + .route("/live", axum::routing::post(playlist_content_live)) + .route("/vod", axum::routing::post(playlist_content_vod)) + .route("/series", axum::routing::post(playlist_content_series)) + .route("/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) + .route("/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) + .route("/webplayer", axum::routing::post(playlist_webplayer)) + .layer(permission_layer!(app_state, Permission::PlaylistRead)); + + let write_routes = Router::new() + .route("/update", axum::routing::post(playlist_update)) + .layer(permission_layer!(app_state, Permission::PlaylistWrite)); + + let epg_routes = Router::new() + .route("/epg", axum::routing::post(playlist_epg)) + .layer(permission_layer!(app_state, Permission::EpgRead)); + + router.nest("/playlist", + read_routes + .merge(write_routes) + .merge(epg_routes) + ) +} + async fn playlist_episode_item( axum::extract::Path(virtual_id): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, diff --git a/backend/src/api/endpoints/v1_api_user.rs b/backend/src/api/endpoints/v1_api_user.rs index 04c093761..20cca3833 100644 --- a/backend/src/api/endpoints/v1_api_user.rs +++ b/backend/src/api/endpoints/v1_api_user.rs @@ -1,15 +1,11 @@ -use crate::{ - api::{ - model::AppState, - panel_api::{sync_panel_api_alias_pool_for_target, target_has_alias_pool_min}, - }, - model::{ApiProxyConfig, ProxyUserCredentials, TargetUser}, - repository::store_api_user, -}; +use crate::{api::{ + model::AppState, + panel_api::{sync_panel_api_alias_pool_for_target, target_has_alias_pool_min}, +}, auth::permission_layer, model::{ApiProxyConfig, ProxyUserCredentials, TargetUser}, repository::store_api_user}; use axum::{response::IntoResponse, Router}; use serde_json::json; use shared::{ - model::{ApiProxyConfigDto, ProxyUserCredentialsDto}, + model::{permission::Permission, ApiProxyConfigDto, ProxyUserCredentialsDto}, utils::mask_credentials, }; use std::{path::Path, sync::Arc}; @@ -216,3 +212,22 @@ pub fn v1_api_user_register(router: Router>) -> axum::Router>, + app_state: &Arc, +) -> axum::Router> { + let user_write_routes = Router::new() + .route( + "/{target}", + axum::routing::post(save_config_api_proxy_user) + .put(save_config_api_proxy_user) + ) + .route( + "/{target}/{username}", + axum::routing::delete(delete_config_api_proxy_user) + ) + .layer(permission_layer!(app_state, Permission::UserWrite)); + + router.nest("/user", user_write_routes) +} diff --git a/backend/src/api/endpoints/web_index.rs b/backend/src/api/endpoints/web_index.rs index 2c70d0759..51d4121cb 100644 --- a/backend/src/api/endpoints/web_index.rs +++ b/backend/src/api/endpoints/web_index.rs @@ -3,11 +3,12 @@ use crate::{ api_utils::{serve_file, try_unwrap_body}, model::AppState, }, - auth::{create_jwt_admin, create_jwt_user, is_admin, verify_password, verify_token, AuthBearer}, + auth::{create_jwt_admin, create_jwt_api_user, create_jwt_web_user, verify_password, verify_token, AuthBearer}, + model::WebAuthConfig, }; use axum::{body::Body, http::Request, response::IntoResponse}; use base64::Engine; -use log::error; +use log::{debug, error}; use lol_html::{element, RewriteStrSettings}; //use base64::engine::general_purpose; use openssl::rand::rand_bytes; @@ -28,6 +29,8 @@ fn no_web_auth_token() -> impl axum::response::IntoResponse + Send { axum::Json(TokenResponse { token: TOKEN_NO_AUTH.to_string(), username: "admin".to_string() }).into_response() } +fn api_user_can_access_web_ui(ui_enabled: bool) -> bool { ui_enabled } + async fn token( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(mut req): axum::extract::Json, @@ -45,7 +48,28 @@ async fn token( if !(username.is_empty() || password.is_empty()) { if let Some(hash) = web_auth.get_user_password(username) { if verify_password(hash, password.as_bytes()) { - if let Ok(token) = create_jwt_admin(web_auth, username) { + let pwd_version = WebAuthConfig::pwd_version_from_hash(hash); + let permissions = web_auth.resolve_permissions(username); + let is_admin = web_auth + .t_users + .as_ref() + .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))) + .is_some_and(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin"))); + let user_groups = web_auth + .t_users + .as_ref() + .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))) + .map(|user| user.groups.clone()) + .unwrap_or_default(); + debug!( + "Web login success candidate: username='{username}', groups={user_groups:?}, is_admin={is_admin}, permissions={permissions}", + ); + let token_result = if is_admin { + create_jwt_admin(web_auth, username, pwd_version) + } else { + create_jwt_web_user(web_auth, username, permissions, pwd_version) + }; + if let Ok(token) = token_result { req.zeroize(); return axum::Json(TokenResponse { token, username: req.username.clone() }).into_response(); } @@ -53,7 +77,11 @@ async fn token( } if let Some(credentials) = app_state.app_config.get_user_credentials(username) { if credentials.password == password { - if let Ok(token) = create_jwt_user(web_auth, username) { + if !api_user_can_access_web_ui(credentials.ui_enabled) { + req.zeroize(); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + if let Ok(token) = create_jwt_api_user(web_auth, username) { req.zeroize(); return axum::Json(TokenResponse { token, username: req.username.clone() }).into_response(); } @@ -81,14 +109,55 @@ async fn token_refresh( let secret_key = web_auth.secret.as_ref(); let maybe_token_data = verify_token(&token, secret_key); if let Some(token_data) = maybe_token_data { - let username = token_data.claims.username.clone(); - let new_token = if is_admin(Some(token_data)) { - create_jwt_admin(web_auth, &username) + let claims = token_data.claims; + let username = claims.username.as_str(); + + if claims + .roles + .iter() + .any(|role| role.eq_ignore_ascii_case(shared::model::ROLE_API_USER)) + { + let Some(user) = app_state.app_config.get_user_credentials(username) else { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + }; + if !user.ui_enabled { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + if let Ok(token) = create_jwt_api_user(web_auth, username) { + return axum::Json(TokenResponse { token, username: claims.username }).into_response(); + } + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + } + + let token_pwd_version = claims.pwd_version; + let Some(users) = web_auth.t_users.as_ref() else { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + }; + let Some(user) = users.iter().find(|candidate| candidate.username.eq_ignore_ascii_case(username)) else { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + }; + + let current_pwd_version = WebAuthConfig::pwd_version_from_hash(&user.password_hash); + if token_pwd_version != 0 && token_pwd_version != current_pwd_version { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + } + + let is_admin = user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin")); + let resolved_permissions = web_auth.resolve_permissions(username); + debug!( + "Web token refresh: username='{}', groups={:?}, is_admin={}, permissions={}", + username, + user.groups, + is_admin, + resolved_permissions + ); + let new_token = if is_admin { + create_jwt_admin(web_auth, username, current_pwd_version) } else { - create_jwt_user(web_auth, &username) + create_jwt_web_user(web_auth, username, resolved_permissions, current_pwd_version) }; if let Ok(token) = new_token { - return axum::Json(TokenResponse { token, username: username.clone() }).into_response(); + return axum::Json(TokenResponse { token, username: user.username.clone() }).into_response(); } } axum::http::StatusCode::UNAUTHORIZED.into_response() @@ -123,6 +192,21 @@ fn inject_nonce_with_parser(html: String, nonce_b64: &str) -> String { lol_html::rewrite_str(&html, settings).unwrap_or(html) } +#[cfg(test)] +mod tests { + use super::api_user_can_access_web_ui; + + #[test] + fn rejects_api_user_when_ui_is_disabled() { + assert!(!api_user_can_access_web_ui(false)); + } + + #[test] + fn allows_api_user_when_ui_is_enabled() { + assert!(api_user_can_access_web_ui(true)); + } +} + async fn index( axum::extract::State(app_state): axum::extract::State>, ) -> impl axum::response::IntoResponse + Send { diff --git a/backend/src/api/endpoints/websocket_api.rs b/backend/src/api/endpoints/websocket_api.rs index 817ea553d..8b7e0ddad 100644 --- a/backend/src/api/endpoints/websocket_api.rs +++ b/backend/src/api/endpoints/websocket_api.rs @@ -3,7 +3,7 @@ use crate::{ endpoints::v1_api::create_status_check, model::{AppState, EventMessage}, }, - auth::{verify_token_admin, verify_token_user}, + auth::verify_token, }; use axum::{ extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, @@ -12,7 +12,8 @@ use axum::{ use log::{error, trace}; use shared::{ model::{ - ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserRole, WsCloseCode, PROTOCOL_VERSION, + Permission, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserRole, WsCloseCode, + PERM_ALL, ROLE_ADMIN, PROTOCOL_VERSION, }, utils::{concat_path_leading_slash, default_kick_secs}, }; @@ -46,19 +47,19 @@ pub fn ws_api_register(web_auth_enabled: bool, web_ui_path: &str) -> axum::Route } #[inline] -fn verify_auth_admin_token(auth_token: &str, secret_key: Option<&Vec>) -> bool { - match secret_key.as_ref() { - None => false, - Some(key) => verify_token_admin(auth_token, key.as_slice()), - } +fn set_websocket_auth(mem: &mut ProtocolHandlerMemory, auth_token: String, claims: &shared::model::Claims) { + mem.permissions = claims.permissions; + mem.role = if claims.roles.iter().any(|role| role == ROLE_ADMIN) { + UserRole::Admin + } else { + UserRole::User + }; + mem.token = Some(auth_token); } #[inline] -fn verify_auth_user_token(auth_token: &str, secret_key: Option<&Vec>) -> bool { - match secret_key.as_ref() { - None => false, - Some(key) => verify_token_user(auth_token, key.as_slice()), - } +fn websocket_requires_system_read(auth_required: bool, mem: &ProtocolHandlerMemory) -> bool { + !auth_required || mem.permissions.contains(Permission::SystemRead) } fn get_secret_key(app_state: &AppState, auth: bool) -> Option> { @@ -104,32 +105,51 @@ async fn handle_protocol_message( if let Message::Binary(bytes) = msg { match ProtocolMessage::from_bytes(bytes) { Ok(ProtocolMessage::Auth(auth_token)) => { - mem.token = None; - if !auth_required || verify_auth_admin_token(&auth_token, secret_key) { + if !auth_required { + mem.permissions = PERM_ALL; mem.role = UserRole::Admin; mem.token = Some(auth_token); - Some(ProtocolMessage::Authorized) - } else if verify_auth_user_token(&auth_token, secret_key) { - mem.role = UserRole::User; - mem.token = Some(auth_token); - Some(ProtocolMessage::Authorized) - } else { - Some(ProtocolMessage::Unauthorized) + return Some(ProtocolMessage::Authorized); } + + let Some(secret_key) = secret_key else { + return Some(ProtocolMessage::Unauthorized); + }; + + let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + return Some(ProtocolMessage::Unauthorized); + }; + + if !token_data.claims.permissions.contains(Permission::SystemRead) { + return Some(ProtocolMessage::Unauthorized); + } + + set_websocket_auth(mem, auth_token, &token_data.claims); + Some(ProtocolMessage::Authorized) } Ok(ProtocolMessage::StatusRequest(auth_token)) => { - if !auth_required || verify_auth_admin_token(&auth_token, secret_key) { - mem.role = UserRole::Admin; - mem.token = Some(auth_token); - let status = create_status_check(app_state).await; - Some(ProtocolMessage::StatusResponse(status)) - } else { - Some(ProtocolMessage::Unauthorized) + if auth_required { + let Some(secret_key) = secret_key else { + return Some(ProtocolMessage::Unauthorized); + }; + + let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + return Some(ProtocolMessage::Unauthorized); + }; + + if !token_data.claims.permissions.contains(Permission::SystemRead) { + return Some(ProtocolMessage::Unauthorized); + } + + set_websocket_auth(mem, auth_token, &token_data.claims); } + + let status = create_status_check(app_state).await; + Some(ProtocolMessage::StatusResponse(status)) } Ok(ProtocolMessage::UserAction(cmd)) => { - if let Some(token) = mem.token.as_ref() { - if !auth_required || verify_auth_admin_token(token, secret_key) { + if websocket_requires_system_read(auth_required, mem) { + if !auth_required || mem.token.is_some() { Some(ProtocolMessage::UserActionResponse(handle_user_action(app_state, cmd).await)) } else { Some(ProtocolMessage::UserActionResponse(false)) @@ -139,13 +159,26 @@ async fn handle_protocol_message( } } Ok(ProtocolMessage::ActiveProviderCountRequest(auth_token)) => { - if !auth_required || verify_auth_admin_token(&auth_token, secret_key) { + if auth_required { + let Some(secret_key) = secret_key else { + return Some(ProtocolMessage::Unauthorized); + }; + let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + return Some(ProtocolMessage::Unauthorized); + }; + set_websocket_auth(mem, auth_token, &token_data.claims); + if mem.permissions.contains(Permission::SystemRead) { + let connections = app_state.active_provider.get_provider_connections_count().await; + Some(ProtocolMessage::ActiveProviderCountResponse(connections)) + } else { + Some(ProtocolMessage::Unauthorized) + } + } else { + mem.permissions = PERM_ALL; mem.role = UserRole::Admin; mem.token = Some(auth_token); let connections = app_state.active_provider.get_provider_connections_count().await; Some(ProtocolMessage::ActiveProviderCountResponse(connections)) - } else { - Some(ProtocolMessage::Unauthorized) } } Ok(_) => { diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index 19cc59637..8da29f98d 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -515,7 +515,7 @@ pub async fn start_server(app_config: Arc, targets: Arc Result { - let authorization = req - .headers + pub(crate) fn from_headers(headers: &HeaderMap) -> Result { + let authorization = headers .get(axum::http::header::AUTHORIZATION) .ok_or((StatusCode::FORBIDDEN, "Authorization header is missing"))? .to_str() @@ -36,4 +36,8 @@ impl AuthBearer { _ => Err((StatusCode::FORBIDDEN, "`Authorization` header must be a bearer token")), } } + + fn decode_request_parts(req: &mut Parts) -> Result { + Self::from_headers(&req.headers) + } } diff --git a/backend/src/auth/authenticator.rs b/backend/src/auth/authenticator.rs index f149d5878..da1dee114 100644 --- a/backend/src/auth/authenticator.rs +++ b/backend/src/auth/authenticator.rs @@ -1,22 +1,39 @@ use std::sync::Arc; use chrono::{Local, Duration}; use jsonwebtoken::{Algorithm, DecodingKey, encode, decode, EncodingKey, Header, Validation, TokenData}; +use log::warn; use crate::api::api_utils::get_username_from_auth_header; use crate::model::WebAuthConfig; use crate::api::model::AppState; use crate::auth::AuthBearer; use shared::error::to_io_error; -use shared::model::{Claims, ROLE_ADMIN, ROLE_USER}; +use shared::model::permission::{permission_to_name, Permission, PermissionSet, PERM_ALL}; +use shared::model::{Claims, ROLE_ADMIN, ROLE_API_USER}; -pub fn create_jwt_admin(web_auth_config: &WebAuthConfig, username: &str) -> Result { - create_jwt(web_auth_config, username, vec![ROLE_ADMIN.to_string()]) +pub fn create_jwt_admin(web_auth_config: &WebAuthConfig, username: &str, pwd_version: u32) -> Result { + create_jwt(web_auth_config, username, vec![ROLE_ADMIN.to_string()], PERM_ALL, pwd_version) } -pub fn create_jwt_user(web_auth_config: &WebAuthConfig, username: &str) -> Result { - create_jwt(web_auth_config, username, vec![ROLE_USER.to_string()]) +pub fn create_jwt_api_user(web_auth_config: &WebAuthConfig, username: &str) -> Result { + create_jwt(web_auth_config, username, vec![ROLE_API_USER.to_string()], PermissionSet::new(), 0) } -fn create_jwt(web_auth_config: &WebAuthConfig, username: &str, roles: Vec) -> Result { +pub fn create_jwt_web_user( + web_auth_config: &WebAuthConfig, + username: &str, + permissions: PermissionSet, + pwd_version: u32, +) -> Result { + create_jwt(web_auth_config, username, Vec::new(), permissions, pwd_version) +} + +fn create_jwt( + web_auth_config: &WebAuthConfig, + username: &str, + roles: Vec, + permissions: PermissionSet, + pwd_version: u32, +) -> Result { let mut header = Header::new(Algorithm::HS256); header.typ = Some("JWT".to_string()); let now = Local::now(); @@ -32,7 +49,9 @@ fn create_jwt(web_auth_config: &WebAuthConfig, username: &str, roles: Vec Ok(jwt), @@ -59,16 +78,16 @@ pub fn is_admin(token_data: Option>) -> bool { has_role(token_data, ROLE_ADMIN) } -pub fn is_user(token_data: Option>) -> bool { - has_role(token_data, ROLE_USER) +pub fn is_api_user(token_data: Option>) -> bool { + has_role(token_data, ROLE_API_USER) } pub fn verify_token_admin(bearer: &str, secret_key: &[u8]) -> bool { has_role(verify_token(bearer, secret_key), ROLE_ADMIN) } -pub fn verify_token_user(bearer: &str, secret_key: &[u8]) -> bool { - has_role(verify_token(bearer, secret_key), ROLE_USER) +pub fn verify_token_api_user(bearer: &str, secret_key: &[u8]) -> bool { + has_role(verify_token(bearer, secret_key), ROLE_API_USER) } fn validate_request( @@ -98,7 +117,7 @@ pub async fn validator_admin( } } -pub async fn validator_user( +pub async fn validator_api_user( axum::extract::State(app_state): axum::extract::State>, AuthBearer(token): AuthBearer, request: axum::extract::Request, @@ -111,8 +130,33 @@ pub async fn validator_user( } } } - match validate_request(&app_state, &token, verify_token_user) { + match validate_request(&app_state, &token, verify_token_api_user) { Ok(()) => Ok(next.run(request).await), Err(()) => Err(axum::http::StatusCode::UNAUTHORIZED) } } + +pub async fn require_permission_inner( + permission: Permission, + axum::extract::State(app_state): axum::extract::State>, + AuthBearer(token): AuthBearer, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + let config = app_state.app_config.config.load(); + let Some(web_auth_config) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { + return Err(axum::http::StatusCode::UNAUTHORIZED); + }; + + let Some(token_data) = verify_token(&token, web_auth_config.secret.as_bytes()) else { + return Err(axum::http::StatusCode::UNAUTHORIZED); + }; + + if token_data.claims.permissions.contains(permission) { + return Ok(next.run(request).await); + } + + let denied_permission = permission_to_name(permission).unwrap_or("unknown"); + warn!("User '{}' denied permission '{denied_permission}'", token_data.claims.username); + Err(axum::http::StatusCode::FORBIDDEN) +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 0ae43c4e1..1cc8e3899 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -8,9 +8,22 @@ mod access_token; mod fingerprint; type Rejection = (StatusCode, &'static str); +#[macro_export] +macro_rules! permission_layer { + ($app_state:expr, $permission:expr ) => { + { + let app_state = ::std::sync::Arc::clone($app_state); + ::axum::middleware::from_fn_with_state(app_state, move |state, auth, request, next| { + $crate::auth::require_permission_inner($permission, state, auth, request, next) + }) + } + }; +} +pub use permission_layer; + pub use self::authenticator::*; pub use self::access_token::*; pub use self::password::*; pub use self::fingerprint::*; pub use self::auth_basic::*; -pub use self::auth_bearer::*; \ No newline at end of file +pub use self::auth_bearer::*; diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 1ca228b78..20db5024a 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -32,36 +32,8 @@ create_bitset!( ProbeLive ); -impl ConfigInputFlagsSet { - #[inline] - pub fn contains_any(&self, other: Self) -> bool { - (self.0 & other.0) != 0 - } - #[inline] - pub fn contains_all(&self, other: Self) -> bool { - (self.0 & other.0) == other.0 - } -} -impl std::ops::BitOr for ConfigInputFlags { - type Output = ConfigInputFlagsSet; - - #[inline] - fn bitor(self, rhs: Self) -> Self::Output { - ConfigInputFlagsSet::from_variants(&[self, rhs]) - } -} - -impl std::ops::BitOr for ConfigInputFlagsSet { - type Output = Self; - - #[inline] - fn bitor(mut self, rhs: ConfigInputFlags) -> Self::Output { - self.set(rhs); - self - } -} #[derive(Debug, Clone)] pub struct ConfigInputOptions { @@ -80,12 +52,12 @@ impl ConfigInputOptions { #[inline] pub fn has_any_flags(&self, flags: ConfigInputFlagsSet) -> bool { - self.flags.contains_any(flags) + self.flags.contains_any(&flags) } #[inline] pub fn has_all_flags(&self, flags: ConfigInputFlagsSet) -> bool { - self.flags.contains_all(flags) + self.flags.contains_all(&flags) } #[inline] diff --git a/backend/src/model/config/web_auth.rs b/backend/src/model/config/web_auth.rs index 192813a3f..6458387f2 100644 --- a/backend/src/model/config/web_auth.rs +++ b/backend/src/model/config/web_auth.rs @@ -1,11 +1,39 @@ +use std::fmt; use std::fs::File; -use std::io::BufRead; -use std::path::PathBuf; -use shared::error::{TuliproxError, info_err_res}; -use shared::model::{UserCredential, WebAuthConfigDto}; +use std::io::{BufRead, ErrorKind}; +use std::path::{Path, PathBuf}; + +use log::{debug, error, warn}; +use shared::error::{info_err_res, TuliproxError}; +use shared::model::permission::{permission_from_name, PermissionSet, PERM_ALL}; +use shared::model::WebAuthConfigDto; + use crate::model::macros; use crate::utils; +#[derive(Clone)] +pub struct WebUiUser { + pub username: String, + pub password_hash: String, + pub groups: Vec, +} + +impl fmt::Debug for WebUiUser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WebUiUser") + .field("username", &self.username) + .field("password_hash", &"*****") + .field("groups", &self.groups) + .finish() + } +} + +#[derive(Debug, Clone)] +pub struct RbacGroup { + pub name: String, + pub permissions: PermissionSet, +} + #[derive(Debug, Clone)] pub struct WebAuthConfig { pub enabled: bool, @@ -13,10 +41,13 @@ pub struct WebAuthConfig { pub secret: String, pub token_ttl_mins: u32, pub userfile: Option, - pub t_users: Option>, + pub groupfile: Option, + pub t_users: Option>, + pub t_groups: Option>, } macros::from_impl!(WebAuthConfig); + impl From<&WebAuthConfigDto> for WebAuthConfig { fn from(dto: &WebAuthConfigDto) -> Self { Self { @@ -25,7 +56,9 @@ impl From<&WebAuthConfigDto> for WebAuthConfig { secret: dto.secret.clone(), token_ttl_mins: dto.token_ttl_mins, userfile: dto.userfile.clone(), + groupfile: dto.groupfile.clone(), t_users: None, + t_groups: None, } } } @@ -38,53 +71,639 @@ impl From<&WebAuthConfig> for WebAuthConfigDto { secret: instance.secret.clone(), token_ttl_mins: instance.token_ttl_mins, userfile: instance.userfile.clone(), + groupfile: instance.groupfile.clone(), } } } impl WebAuthConfig { pub fn prepare(&mut self, config_path: &str) -> Result<(), TuliproxError> { - let userfile_name = self.userfile.as_ref().map_or_else(|| utils::get_default_user_file_path(config_path), std::borrow::ToOwned::to_owned); + let has_custom_userfile = !utils::is_blank_or_default_user_file_path(&self.userfile); + let has_custom_groupfile = !utils::is_blank_or_default_user_group_file_path(&self.groupfile); + let userfile_name = if has_custom_userfile { + self.userfile.as_ref().map_or_else(String::new, std::borrow::ToOwned::to_owned) + } else { + utils::get_default_user_file_path(config_path) + }; + let groupfile_name = if has_custom_groupfile { + self.groupfile.as_ref().map_or_else(String::new, std::borrow::ToOwned::to_owned) + } else { + utils::get_default_user_group_file_path(config_path) + }; self.userfile = Some(userfile_name.clone()); + self.groupfile = Some(groupfile_name.clone()); - let mut userfile_path = PathBuf::from(&userfile_name); + let userfile_path = if has_custom_userfile { + Self::resolve_auth_file_path(config_path, &userfile_name) + } else { + PathBuf::from(&userfile_name) + }; if !utils::path_exists(&userfile_path) { - userfile_path = PathBuf::from(config_path).join(&userfile_name); - if !utils::path_exists(&userfile_path) { - return info_err_res!("Could not find userfile {}", &userfile_name); - } + return info_err_res!("Could not find userfile {}", &userfile_name); } - if let Ok(file) = File::open(&userfile_path) { - let mut users = vec![]; - let reader = utils::file_reader(file); - // TODO maybe print out errors - for credentials in reader.lines().map_while(Result::ok) { - let mut parts = credentials.split(':'); - if let (Some(username), Some(password)) = (parts.next(), parts.next()) { - users.push(UserCredential { - username: username.trim().to_string(), - password: password.trim().to_string(), - }); - // debug!("Read ui user {}", username); + let Ok(file) = File::open(&userfile_path) else { + return info_err_res!("Could not read userfile {:?}", &userfile_path); + }; + let reader = utils::file_reader(file); + let mut users = vec![]; + for credentials in reader.lines() { + match credentials { + Ok(credentials) => { + if let Some(user) = Self::parse_user_line(&credentials) { + users.push(user); + } + } + Err(err) => { + warn!("Could not read line from userfile {}: {err}", userfile_path.display()); } } - - self.t_users = Some(users); - } else { - return info_err_res!("Could not read userfile {:?}", &userfile_path); } + self.t_users = Some(users); + + let groupfile_path = if has_custom_groupfile { + Self::resolve_auth_file_path(config_path, &groupfile_name) + } else { + PathBuf::from(&groupfile_name) + }; + debug!( + "Web auth prepare: config_path='{}', userfile='{}', resolved_userfile='{}', groupfile='{}', resolved_groupfile='{}'", + config_path, + userfile_name, + userfile_path.display(), + groupfile_name, + groupfile_path.display() + ); + self.t_groups = Some(Self::parse_groups(&groupfile_path)); + self.validate_user_groups(); + Ok(()) } pub fn get_user_password(&self, username: &str) -> Option<&str> { - if let Some(users) = &self.t_users { - for credential in users { - if credential.username.eq_ignore_ascii_case(username) { - return Some(credential.password.as_str()); + self.t_users.as_ref().and_then(|users| { + users.iter().find(|credential| credential.username.eq_ignore_ascii_case(username)).map(|credential| credential.password_hash.as_str()) + }) + } + + pub fn resolve_permissions(&self, username: &str) -> PermissionSet { + let Some(users) = &self.t_users else { + debug!("Web auth resolve_permissions('{username}'): no users loaded"); + return PermissionSet::new(); + }; + + let Some(user) = users.iter().find(|candidate| candidate.username.eq_ignore_ascii_case(username)) else { + debug!("Web auth resolve_permissions('{username}'): user not found"); + return PermissionSet::new(); + }; + + if user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin")) { + debug!("Web auth resolve_permissions('{username}'): admin user -> all permissions"); + return PERM_ALL; + } + + let mut permissions = PermissionSet::new(); + if let Some(groups) = &self.t_groups { + for group_name in &user.groups { + if let Some(group) = groups.iter().find(|candidate| candidate.name.eq_ignore_ascii_case(group_name)) { + permissions.union(group.permissions); + } else { + debug!("Web auth resolve_permissions('{username}'): group '{group_name}' not found"); + } + } + } else { + debug!("Web auth resolve_permissions('{username}'): no groups loaded"); + } + debug!( + "Web auth resolve_permissions('{username}'): user_groups={:?}, resolved_permissions={permissions}", + user.groups + ); + permissions + } + + pub fn pwd_version_from_hash(hash: &str) -> u32 { + let digest = blake3::hash(hash.as_bytes()); + let bytes = digest.as_bytes(); + u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) + } + + fn parse_user_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + return None; + } + + let mut parts = trimmed.splitn(3, ':'); + let (Some(username), Some(password_hash)) = (parts.next(), parts.next()) else { + return None; + }; + + let mut groups = parts + .next() + .map(|groups_str| { + groups_str + .split(',') + .map(str::trim) + .filter(|group| !group.is_empty()) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + + if groups.is_empty() { + groups.push(String::from("admin")); + } + + Some(WebUiUser { + username: username.trim().to_string(), + password_hash: password_hash.trim().to_string(), + groups, + }) + } + + fn resolve_auth_file_path(config_path: &str, file_name: &str) -> PathBuf { + let file_path = PathBuf::from(file_name); + if file_path.is_absolute() || utils::path_exists(&file_path) { + file_path + } else { + PathBuf::from(config_path).join(file_name) + } + } + + fn parse_groups(groupfile_path: &Path) -> Vec { + match File::open(groupfile_path) { + Ok(file) => { + let reader = utils::file_reader(file); + let mut groups: Vec = vec![]; + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + warn!("Could not read line from groups file {}: {err}", groupfile_path.display()); + continue; + } + }; + + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let mut parts = trimmed.splitn(2, ':'); + let (Some(name), Some(perms_str)) = (parts.next(), parts.next()) else { + continue; + }; + + let name = name.trim(); + if name.eq_ignore_ascii_case("admin") { + warn!("groups file {}: 'admin' is reserved and cannot be redefined, skipping", groupfile_path.display()); + continue; + } + + let mut permissions = PermissionSet::new(); + for perm_name in perms_str.split(',').map(str::trim).filter(|perm| !perm.is_empty()) { + match permission_from_name(perm_name) { + Some(permission) => permissions.set(permission), + None => warn!("groups file {}: unknown permission '{perm_name}' in group '{name}', ignoring", groupfile_path.display()), + } + } + + if let Some(existing_group) = groups.iter_mut().find(|group| group.name.eq_ignore_ascii_case(name)) { + warn!("groups file {}: duplicate group '{name}' redefined, replacing prior definition", groupfile_path.display()); + existing_group.permissions = permissions; + } else { + groups.push(RbacGroup { name: name.to_string(), permissions }); + } + } + groups + } + Err(err) if err.kind() == ErrorKind::NotFound => { + debug!("groups file {} not found, using only built-in admin permissions", groupfile_path.display()); + vec![] + } + Err(err) => { + error!("Could not read groups file {}: {err}, using only built-in admin permissions", groupfile_path.display()); + vec![] + } + } + } + + pub fn validate_user_groups(&self) { + let Some(users) = &self.t_users else { + return; + }; + + for user in users { + for group_name in &user.groups { + if group_name.eq_ignore_ascii_case("admin") { + continue; + } + + let group_exists = self.t_groups.as_ref().is_some_and(|groups| { + groups.iter().any(|group| group.name.eq_ignore_ascii_case(group_name)) + }); + + if !group_exists { + warn!("user '{}' references unknown group '{}', it will have no effect", user.username, group_name); } } } - None } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::permission::{Permission, PermissionSet, PERM_ALL}; + use std::{io::Write, path::Path}; + use tempfile::NamedTempFile; + + struct DirGuard { + previous_dir: PathBuf, + } + + impl DirGuard { + fn enter(path: &Path) -> std::io::Result { + let previous_dir = std::env::current_dir()?; + std::env::set_current_dir(path)?; + Ok(Self { previous_dir }) + } + } + + impl Drop for DirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.previous_dir); + } + } + + // --- parse_user_line tests --- + + #[test] + fn test_parse_user_line_no_groups_defaults_to_admin() { + let user = WebAuthConfig::parse_user_line("admin:$argon2id$hash123"); + assert!(user.is_some()); + let user = user.expect("should parse"); + assert_eq!(user.username, "admin"); + assert_eq!(user.password_hash, "$argon2id$hash123"); + assert_eq!(user.groups, vec!["admin"]); + } + + #[test] + fn test_parse_user_line_with_groups() { + let user = WebAuthConfig::parse_user_line("alice:hash123:viewer,source_manager"); + assert!(user.is_some()); + let user = user.expect("should parse"); + assert_eq!(user.username, "alice"); + assert_eq!(user.password_hash, "hash123"); + assert_eq!(user.groups, vec!["viewer", "source_manager"]); + } + + #[test] + fn test_parse_user_line_empty_line() { + assert!(WebAuthConfig::parse_user_line("").is_none()); + } + + #[test] + fn test_parse_user_line_comment() { + assert!(WebAuthConfig::parse_user_line("# this is a comment").is_none()); + } + + #[test] + fn test_parse_user_line_whitespace_only() { + assert!(WebAuthConfig::parse_user_line(" ").is_none()); + } + + #[test] + fn test_parse_user_line_no_password() { + // Only username, no colon separator for password + assert!(WebAuthConfig::parse_user_line("justusername").is_none()); + } + + #[test] + fn test_parse_user_line_trims_whitespace() { + let user = WebAuthConfig::parse_user_line(" bob : hash456 : groupA , groupB "); + assert!(user.is_some()); + let user = user.expect("should parse"); + assert_eq!(user.username, "bob"); + assert_eq!(user.password_hash, "hash456"); + assert_eq!(user.groups, vec!["groupA", "groupB"]); + } + + #[test] + fn test_parse_user_line_empty_groups_defaults_to_admin() { + let user = WebAuthConfig::parse_user_line("user:hash:"); + assert!(user.is_some()); + let user = user.expect("should parse"); + assert_eq!(user.groups, vec!["admin"]); + } + + // --- parse_groups tests --- + + #[test] + fn test_parse_groups_valid() { + let mut file = NamedTempFile::new().expect("create temp file"); + writeln!(file, "viewer:config.read,source.read").expect("write"); + writeln!(file, "editor:config.read,config.write").expect("write"); + let groups = WebAuthConfig::parse_groups(file.path()); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].name, "viewer"); + assert!(groups[0].permissions.contains(Permission::ConfigRead)); + assert!(groups[0].permissions.contains(Permission::SourceRead)); + assert!(!groups[0].permissions.contains(Permission::ConfigWrite)); + assert_eq!(groups[1].name, "editor"); + assert!(groups[1].permissions.contains(Permission::ConfigRead)); + assert!(groups[1].permissions.contains(Permission::ConfigWrite)); + } + + #[test] + fn test_parse_groups_skips_comments_and_empty() { + let mut file = NamedTempFile::new().expect("create temp file"); + writeln!(file, "# comment").expect("write"); + writeln!(file).expect("write"); + writeln!(file, "viewer:config.read").expect("write"); + let groups = WebAuthConfig::parse_groups(file.path()); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].name, "viewer"); + } + + #[test] + fn test_parse_groups_admin_reserved() { + let mut file = NamedTempFile::new().expect("create temp file"); + writeln!(file, "admin:config.read").expect("write"); + writeln!(file, "viewer:source.read").expect("write"); + let groups = WebAuthConfig::parse_groups(file.path()); + // admin line should be skipped + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].name, "viewer"); + } + + #[test] + fn test_parse_groups_duplicate_replaces() { + let mut file = NamedTempFile::new().expect("create temp file"); + writeln!(file, "viewer:config.read").expect("write"); + writeln!(file, "viewer:source.read").expect("write"); + let groups = WebAuthConfig::parse_groups(file.path()); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].name, "viewer"); + // Second definition replaces first + assert!(!groups[0].permissions.contains(Permission::ConfigRead)); + assert!(groups[0].permissions.contains(Permission::SourceRead)); + } + + #[test] + fn test_parse_groups_unknown_permission_ignored() { + let mut file = NamedTempFile::new().expect("create temp file"); + writeln!(file, "viewer:config.read,fake.perm,source.read").expect("write"); + let groups = WebAuthConfig::parse_groups(file.path()); + assert_eq!(groups.len(), 1); + assert!(groups[0].permissions.contains(Permission::ConfigRead)); + assert!(groups[0].permissions.contains(Permission::SourceRead)); + } + + #[test] + fn test_parse_groups_missing_file() { + let groups = WebAuthConfig::parse_groups(std::path::Path::new("/nonexistent/groups.txt")); + assert!(groups.is_empty()); + } + + // --- resolve_permissions tests --- + + #[test] + fn test_resolve_permissions_admin_user() { + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![WebUiUser { + username: "admin".to_string(), + password_hash: "hash".to_string(), + groups: vec!["admin".to_string()], + }]), + t_groups: Some(vec![]), + }; + let perms = config.resolve_permissions("admin"); + assert_eq!(perms, PERM_ALL); + } + + #[test] + fn test_resolve_permissions_grouped_user() { + let mut viewer_perms = PermissionSet::new(); + viewer_perms.set(Permission::ConfigRead); + viewer_perms.set(Permission::SourceRead); + + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![WebUiUser { + username: "alice".to_string(), + password_hash: "hash".to_string(), + groups: vec!["viewer".to_string()], + }]), + t_groups: Some(vec![RbacGroup { + name: "viewer".to_string(), + permissions: viewer_perms, + }]), + }; + let perms = config.resolve_permissions("alice"); + assert!(perms.contains(Permission::ConfigRead)); + assert!(perms.contains(Permission::SourceRead)); + assert!(!perms.contains(Permission::ConfigWrite)); + } + + #[test] + fn test_resolve_permissions_multiple_groups_union() { + let mut viewer_perms = PermissionSet::new(); + viewer_perms.set(Permission::ConfigRead); + + let mut editor_perms = PermissionSet::new(); + editor_perms.set(Permission::SourceWrite); + + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![WebUiUser { + username: "bob".to_string(), + password_hash: "hash".to_string(), + groups: vec!["viewer".to_string(), "editor".to_string()], + }]), + t_groups: Some(vec![ + RbacGroup { + name: "viewer".to_string(), + permissions: viewer_perms, + }, + RbacGroup { + name: "editor".to_string(), + permissions: editor_perms, + }, + ]), + }; + let perms = config.resolve_permissions("bob"); + assert!(perms.contains(Permission::ConfigRead)); + assert!(perms.contains(Permission::SourceWrite)); + } + + #[test] + fn test_resolve_permissions_admin_overrides_groups() { + let mut viewer_perms = PermissionSet::new(); + viewer_perms.set(Permission::ConfigRead); + + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![WebUiUser { + username: "superuser".to_string(), + password_hash: "hash".to_string(), + groups: vec!["admin".to_string(), "viewer".to_string()], + }]), + t_groups: Some(vec![RbacGroup { + name: "viewer".to_string(), + permissions: viewer_perms, + }]), + }; + let perms = config.resolve_permissions("superuser"); + assert_eq!(perms, PERM_ALL); + } + + #[test] + fn test_resolve_permissions_unknown_user() { + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![]), + t_groups: Some(vec![]), + }; + let perms = config.resolve_permissions("nobody"); + assert!(perms.is_empty()); + } + + #[test] + fn test_resolve_permissions_case_insensitive_username() { + let config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: Some(vec![WebUiUser { + username: "Admin".to_string(), + password_hash: "hash".to_string(), + groups: vec!["admin".to_string()], + }]), + t_groups: Some(vec![]), + }; + let perms = config.resolve_permissions("admin"); + assert_eq!(perms, PERM_ALL); + } + + #[test] + fn test_prepare_uses_default_group_file_without_double_prefix() { + let tempdir = tempfile::tempdir().expect("create temp dir"); + let config_path = tempdir.path().join("config"); + std::fs::create_dir_all(&config_path).expect("create config dir"); + + let userfile_path = config_path.join("user.txt"); + std::fs::write(&userfile_path, "mod1:hash:mod\n").expect("write user file"); + + let groupfile_path = config_path.join("groups.txt"); + std::fs::write(&groupfile_path, "mod:config.read\n").expect("write groups file"); + + let mut config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: Some("user.txt".to_string()), + groupfile: None, + t_users: None, + t_groups: None, + }; + + config.prepare(config_path.to_str().expect("config path utf-8")).expect("prepare config"); + + let perms = config.resolve_permissions("mod1"); + assert!(perms.contains(Permission::ConfigRead)); + } + + #[test] + fn test_prepare_treats_default_groupfile_string_as_default_path() { + let tempdir = tempfile::tempdir().expect("create temp dir"); + let workdir = tempdir.path().join("work"); + let config_path = workdir.join("config"); + std::fs::create_dir_all(&config_path).expect("create config dir"); + + let userfile_path = config_path.join("user.txt"); + std::fs::write(&userfile_path, "mod1:hash:mod\n").expect("write user file"); + + let groupfile_path = config_path.join("groups.txt"); + std::fs::write(&groupfile_path, "mod:config.read\n").expect("write groups file"); + + let mut config = WebAuthConfig { + enabled: true, + issuer: String::new(), + secret: String::new(), + token_ttl_mins: 60, + userfile: Some("./config/user.txt".to_string()), + groupfile: Some("./config/groups.txt".to_string()), + t_users: None, + t_groups: None, + }; + + let _guard = DirGuard::enter(&workdir).expect("enter workdir"); + let prepare_result = config.prepare(config_path.to_str().expect("config path utf-8")); + + prepare_result.expect("prepare config"); + + let perms = config.resolve_permissions("mod1"); + assert!(perms.contains(Permission::ConfigRead)); + } + + // --- pwd_version_from_hash tests --- + + #[test] + fn test_pwd_version_from_hash_deterministic() { + let v1 = WebAuthConfig::pwd_version_from_hash("$argon2id$hash123"); + let v2 = WebAuthConfig::pwd_version_from_hash("$argon2id$hash123"); + assert_eq!(v1, v2); + } + + #[test] + fn test_pwd_version_from_hash_different_for_different_hashes() { + let v1 = WebAuthConfig::pwd_version_from_hash("hash_a"); + let v2 = WebAuthConfig::pwd_version_from_hash("hash_b"); + assert_ne!(v1, v2); + } + + #[test] + fn test_web_ui_user_debug_redacts_password_hash() { + let user = WebUiUser { + username: "alice".to_string(), + password_hash: "secret-hash".to_string(), + groups: vec!["admin".to_string()], + }; + + let debug = format!("{user:?}"); + assert!(debug.contains("alice")); + assert!(debug.contains("*****")); + assert!(!debug.contains("secret-hash")); + } +} diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index ac6057210..3df9e1fad 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -2,7 +2,7 @@ use crate::{ api::{ model::{ ActiveProviderManager, AppState, EventManager, EventMessage, MetadataUpdateManager, PlaylistStorageState, - ProviderIdType, ResolveReason, ResolveReasonSet, UpdateGuard, UpdateTask, + ProviderIdType, ResolveReason, UpdateGuard, UpdateTask, }, sync_panel_api_exp_dates, }, @@ -1176,7 +1176,7 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, if queued_live_keys.insert(provider_id.clone()) { let task = UpdateTask::ProbeLive { id: provider_id.clone(), - reason: ResolveReasonSet::from_variants(&[ResolveReason::Probe]), + reason: ResolveReason::Probe.into(), delay: probe_delay, interval: interval_secs, }; @@ -1246,7 +1246,7 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, unique_id: unique_id.clone(), url: item.header.url.to_string(), item_type: item.header.item_type, - reason: ResolveReasonSet::from_variants(&[ResolveReason::MissingDetails]), + reason: ResolveReason::MissingDetails.into(), delay: opts.probe_delay, }; if mgr.should_skip_enqueue(input_name.clone(), &task).await { diff --git a/backend/src/processing/processor/resolve_options.rs b/backend/src/processing/processor/resolve_options.rs index 72657e130..c1968d352 100644 --- a/backend/src/processing/processor/resolve_options.rs +++ b/backend/src/processing/processor/resolve_options.rs @@ -22,7 +22,7 @@ impl ResolveOptions { impl Default for ResolveOptions { fn default() -> Self { Self { - flags: ResolveOptionsFlagsSet::from_variants(&[ResolveOptionsFlags::Background]), + flags: ResolveOptionsFlags::Background.into(), resolve_delay: shared::utils::default_resolve_delay_secs(), } } diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index 894eac6d8..81fab991a 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -2,10 +2,7 @@ use crate::utils::debug_if_enabled; use log::{debug, error, trace}; use path_clean::PathClean; use shared::error::str_to_io_error; -use shared::utils::{ - API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, DEFAULT_HOME_ENV_VAR, DEFAULT_WEB_DIR, DEFAULT_WEB_ROOT_ENV_VAR, - MAPPING_FILE, SOURCE_FILE, TEMPLATE_FILE, USER_FILE, -}; +use shared::utils::{API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, DEFAULT_HOME_ENV_VAR, DEFAULT_WEB_DIR, DEFAULT_WEB_ROOT_ENV_VAR, MAPPING_FILE, SOURCE_FILE, TEMPLATE_FILE, USER_FILE, USER_GROUP_FILE}; use std::borrow::Cow; use std::collections::HashSet; use std::fs::{File, OpenOptions}; @@ -101,6 +98,11 @@ pub fn get_default_user_file_path(config_path: &str) -> String { get_default_file_path(config_path, USER_FILE) } +#[inline] +pub fn get_default_user_group_file_path(config_path: &str) -> String { + get_default_file_path(config_path, USER_GROUP_FILE) +} + #[inline] pub fn get_default_config_path() -> String { get_default_path(CONFIG_PATH).to_str().unwrap_or(CONFIG_PATH).to_string() diff --git a/config/README.md b/config/README.md index baedf7ffe..e76b50795 100644 --- a/config/README.md +++ b/config/README.md @@ -1,5 +1,15 @@ Sample configuration. -Login user credentials -test: test.secret -nobody: nobody.secret +## Login user credentials + +The `user.txt` file uses the format `username:argon2_hash[:group1,group2,...]`: + +- `test:test.secret` (no groups = defaults to `admin`) +- `nobody:nobody.secret` + +Generate password hashes with `tuliprox --genpwd`. + +## RBAC groups + +The optional `groups.txt` file defines permission groups in `group_name:permission1,permission2,...` format. +See the [Config Reference](docs/src/configuration/main-config.md) for the full permission list and file format. diff --git a/docs/src/configuration/main-config.md b/docs/src/configuration/main-config.md index 3ec1240c6..12f957b95 100644 --- a/docs/src/configuration/main-config.md +++ b/docs/src/configuration/main-config.md @@ -329,6 +329,7 @@ Controls the Web UI itself: - `secret` - `token_ttl_mins` - `userfile` +- `groupfile` Example: @@ -341,11 +342,60 @@ web_ui: issuer: tuliprox secret: "" userfile: user.txt + groupfile: groups.txt ``` -The `userfile` format is `username:password_hash` per line. Password hashes can be generated with `tuliprox --genpwd`. +### `userfile` format + +The `userfile` uses an extended format that is backward compatible with the previous `username:hash` format: + +```text +# username:argon2_hash[:group1,group2,...] +admin:$argon2id$v=19$... # no groups field -> defaults to "admin" +alice:$argon2id$v=19$...:viewer +bob:$argon2id$v=19$...:user_manager,source_manager +``` + +- Lines starting with `#` are comments, empty lines are ignored. +- The optional third field (after the second `:`) assigns group memberships. +- If the third field is missing, the user defaults to the `admin` group (backward compatibility). +- Users belonging to the `admin` group have full access; other groups are silently ignored for admin users. +- Users can belong to multiple groups; permissions are the union of all group permissions. + +### `groupfile` format + +The `groupfile` (default: `groups.txt` in the same directory as `userfile`) defines permission groups: + +```text +# group_name:permission1,permission2,... +viewer:config.read,source.read,user.read,playlist.read,library.read,system.read,epg.read +user_manager:user.read,user.write +source_manager:source.read,source.write +config_manager:config.read,config.write +playlist_manager:playlist.read,playlist.write +full_manager:config.read,config.write,source.read,source.write,user.read,user.write,playlist.read,playlist.write,library.read,library.write,system.read,system.write,epg.read +``` + +- The `admin` group is built-in (always grants all permissions) and must not be defined in this file. +- If the file does not exist, only the built-in `admin` group is available. +- Groups and users can also be managed through the RBAC admin panel in the Web UI. + +### Permission domains + +| Domain | `.read` | `.write` | +|------------|----------------------|---------------------------------| +| `config` | view main config | edit main config | +| `source` | view sources | edit sources | +| `user` | list proxy users | create/edit/delete proxy users | +| `playlist` | view playlists | update/refresh playlists | +| `library` | view library | scan/manage library | +| `system` | view status/streams | GeoIP update, file downloads | +| `epg` | view EPG data | *(reserved for future use)* | + +Write does not imply read. A group must explicitly grant both `domain.read` and `domain.write` if users need to view and edit content. + ## Access control and stream fallback ### `user_access_control` diff --git a/docs/src/configuration/overview.md b/docs/src/configuration/overview.md index bff07b156..32191c1a8 100644 --- a/docs/src/configuration/overview.md +++ b/docs/src/configuration/overview.md @@ -8,6 +8,8 @@ Tuliprox is driven by a small set of files rather than one giant document. - `source.yml`: inputs, providers, aliases, targets - `mapping.yml`: optional mapping logic - `template.yml`: reusable expressions and templates +- `user.txt`: Web UI login credentials with optional group assignments +- `groups.txt`: RBAC permission group definitions (optional) ## Practical split diff --git a/docs/src/features.md b/docs/src/features.md index b31b84313..3c0bf8285 100644 --- a/docs/src/features.md +++ b/docs/src/features.md @@ -49,6 +49,17 @@ Tuliprox can: - scan local media - combine local library content with IPTV-oriented outputs +## Access control + +Tuliprox supports role-based access control (RBAC) for the Web UI: + +- fine-grained permissions across 7 domains (config, source, user, playlist, library, system, epg) +- custom groups with configurable permission sets +- user-to-group assignments with union-based permission resolution +- compact bitmask encoding in JWT claims for low-overhead permission checks +- backward-compatible user file format +- Web UI admin panel for user and group management + ## Operational features Tuliprox also includes: diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 0c490ae2e..a82efaa02 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -61,7 +61,10 @@ Options: - `config/config.yml`: application and server configuration - `config/source.yml`: inputs, providers, targets - `config/api-proxy.yml`: users and published server URLs -- `mapping.yml` / `template.yml`: optional mapping and template rules +- `config/mapping.yml` / `config/template.yml`: optional mapping and template rules +- `config/user.txt`: Web UI login credentials (`username:hash[:groups]`). If `:groups` is omitted, the user falls back to + the legacy `admin` assignment. Examples: `admin:$argon2id$...` and `editor:$argon2id$...:operators` +- `config/groups.txt`: RBAC permission group definitions (optional) ## Default project layout diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 2ef51a0ae..24a4eb129 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -255,6 +255,7 @@ "ZM": "Zambia", "ZW": "Zimbabwe" }, + "UNAUTHORIZED": "Not authorized", "EXPLANATION": { "API_PROXY_CONFIG": { "USE_USER_DB": "If enabled, API proxy users are loaded from the user database.", @@ -625,7 +626,8 @@ "ISSUER": "The issuer name embedded in JWT auth tokens.", "SECRET": "The secret key used to sign and verify JWT auth tokens. Changing this logs out everyone.", "TOKEN_TTL_MINS": "How many minutes a login session remains valid (default 3600).", - "USERFILE": "Path to the text file containing WebUI users (default user.txt)." + "USERFILE": "Path to the text file containing WebUI users (default user.txt).", + "GROUPFILE": "Path to the text file containing WebUI groups and permissions (default groups.txt)." }, "WEB_UI_CONFIG": { "ENABLED": "Enables or disables the central web-based management interface.", @@ -676,6 +678,7 @@ "WEB_ROOT": "Web-Root: Contains the web-ui files. They are normally placed in the same directory under ./web" }, "LABEL": { + "ALL_TARGETS": "All targets", "ACCEPT_INSECURE_SSL_CERTIFICATES": "Accept Insecure SSL Certificates", "ACTIVE": "Active", "ACTIVE_PROVIDER_CONNECTIONS": "Active provider connections", @@ -1015,6 +1018,22 @@ "RATE_LIMIT": "Rate Limit", "RATE_LIMIT_MS": "Rate Limit (ms)", "READ_EXISTING": "Read Existing", + "RBAC": "WebUI User", + "RBAC_ACTIONS": "Actions", + "RBAC_ADD_GROUP": "Add Group", + "RBAC_ADD_USER": "Add User", + "RBAC_BUILTIN": "Built-in", + "RBAC_DOMAIN": "Domain", + "RBAC_EDIT_GROUP": "Edit Group", + "RBAC_EDIT_USER": "Edit User", + "RBAC_GROUP_MANAGEMENT": "Group Management", + "RBAC_LEAVE_BLANK": "Leave blank to keep current", + "RBAC_LOADING": "Loading...", + "RBAC_NO_PERMISSIONS": "No permissions", + "RBAC_PERMISSIONS": "Permissions", + "RBAC_READ": "Read", + "RBAC_USERS": "Users", + "RBAC_WRITE": "Write", "RECONNECT": "Reconnect", "RECURSIVE": "Recursive", "REDIRECT": "Redirect", @@ -1125,6 +1144,7 @@ "URL_IPV4": "Url IPv4", "URL_IPV6": "Url IPv6", "USER": "User", + "GROUPFILE": "Groupfile", "USERFILE": "Userfile", "USERLIST": "Userlist", "USERNAME": "Username", @@ -1204,10 +1224,26 @@ "LOGIN": { "MESSAGE": "Enter Your Credentials" }, + "NO_ACCESS_MESSAGE": "Your account has no permissions assigned. Contact an administrator.", + "NO_ACCESS_TITLE": "No Access", "NO_CONTENT": "No content", "PLAYLIST": { "WEBPLAYER_URL_COPY_TO_CLIPBOARD": "The copied WebPlayer url is valid for 30 seconds." }, + "RBAC": { + "ADMIN_HINT": "User is Admin \u2014 additional groups have no effect.", + "GROUP_CREATED": "Group created successfully", + "GROUP_DELETED": "Group deleted successfully", + "GROUP_NAME_REQUIRED": "Group name is required", + "GROUP_UPDATED": "Group updated successfully", + "PASSWORD_MIN_LENGTH": "Password must be at least 8 characters", + "PASSWORDS_DONT_MATCH": "Passwords do not match", + "USER_CREATED": "User created successfully", + "USER_DELETED": "User deleted successfully", + "USER_UPDATED": "User updated successfully", + "USERNAME_REQUIRED": "Username is required", + "WRITE_WITHOUT_READ": "Write is enabled without Read" + }, "PLAYLIST_UPDATE": { "FAIL": "Playlist update failed!", "FAIL_FINISH": "Playlist update failed!", diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 9ff8972ff..7da2b71c7 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -185,6 +185,12 @@ ], "path": "M14 8c0-2.21-1.79-4-4-4S6 5.79 6 8s1.79 4 4 4 4-1.79 4-4zm3 2v2h6v-2h-6zM2 18v2h16v-2c0-2.66-5.33-4-8-4s-8 1.34-8 4z" }, + { + "keys": [ + "Group" + ], + "path": "M9 13.75c-2.34 0-7 1.17-7 3.5V19h14v-1.75c0-2.33-4.66-3.5-7-3.5zM4.34 17c.84-.58 2.87-1.25 4.66-1.25s3.82.67 4.66 1.25H4.34zM9 12c1.93 0 3.5-1.57 3.5-3.5S10.93 5 9 5 5.5 6.57 5.5 8.5 7.07 12 9 12zm0-5c.83 0 1.5.67 1.5 1.5S9.83 10 9 10s-1.5-.67-1.5-1.5S8.17 7 9 7zm7.04 6.81c1.16.84 1.96 1.96 1.96 3.44V19h4v-1.75c0-2.02-3.5-3.17-5.96-3.44zM15 12c1.93 0 3.5-1.57 3.5-3.5S16.93 5 15 5c-.54 0-1.04.13-1.5.35.63.89 1 1.98 1 3.15s-.37 2.26-1 3.15c.46.22.96.35 1.5.35z" + }, { "keys": [ "WebSearch" @@ -563,5 +569,9 @@ { "keys": [ "Dns" ], "path": "M20 13H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1zM7 19c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zM20 3H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1h16c.55 0 1-.45 1-1V4c0-.55-.45-1-1-1zM7 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" + }, + { + "keys": [ "Shield" ], + "path": "M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" } ] diff --git a/frontend/scss/_default.scss b/frontend/scss/_default.scss index 2ca9058e3..3e4663105 100644 --- a/frontend/scss/_default.scss +++ b/frontend/scss/_default.scss @@ -54,6 +54,7 @@ select { :not(input[type="text"]) { caret-color: transparent; } + input[type="text"] { caret-color: auto; } @@ -63,13 +64,13 @@ option { } .tp__delete_action { - color: var(--attention-color) !important; - fill: currentColor !important; + color: var(--attention-color) !important; + fill: currentcolor !important; } .tp__update_action { color: var(--active-color) !important; - fill: currentColor !important; + fill: currentcolor !important; } .svg-icon { @@ -88,13 +89,13 @@ li { background: transparent; } -.tp__full-width { +.tp__full-width { box-sizing: border-box; width: 100%; align-items: center; } -.tp__full-height { +.tp__full-height { box-sizing: border-box; height: 100%; align-items: center; @@ -106,10 +107,10 @@ li { } .no-select { - user-select: none; /* Standard */ - -webkit-user-select: none; /* Chrome, Safari, Opera */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* IE/Edge */ + user-select: none; /* Standard */ + -webkit-user-select: none; /* Chrome, Safari, Opera */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE/Edge */ } @media (max-width: size.$mobile-breakpoint) { @@ -117,3 +118,27 @@ li { font-size: 1.1rem; } } + +.tp__unauthorized { + height: 100%; + width: 100%; + font-size: 2rem; + color: var(--text-color); + display: flex; + justify-content: center; + align-items: center; +} + +.tp__inline-toolbar { + display: flex; + flex-flow: row nowrap; + gap: var(--gap-micro); + box-sizing: border-box; + + .tp__icon-button { + &:hover { + background-color: var(--icon-button-hover-background-color); + border-radius: 50%; + } + } +} diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 1581c3643..aa19e09f3 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -80,7 +80,7 @@ --background-color: #0b1220; --particle-flow-background-color: #070f1d; --particle-flow-background-accent-color-1: #6ea8ff; - --particle-flow-background-accent-color-2: #9ec7ff; + --particle-flow-background-accent-color-2: #273240; --particle-flow-background-alpha-boost: 1.0; --border-color: rgba(96, 165, 250, 0.24); --input-background-color: rgba(15, 23, 42, 0.0); @@ -428,9 +428,9 @@ body[data-theme="bright"] { --primary-color: #2f6fa8; --secondary-color: #d46a6a; --background-color: #e9eff4; - --particle-flow-background-color: #edf3f8; - --particle-flow-background-accent-color-1: #5f8fb8; - --particle-flow-background-accent-color-2: #8aaecf; + --particle-flow-background-color: #477fad; + --particle-flow-background-accent-color-1: #154679; + --particle-flow-background-accent-color-2: #9bb8cf; --particle-flow-background-alpha-boost: 1.45; --text-color: #1f2f3f; @@ -463,7 +463,7 @@ body[data-theme="bright"] { --action-item-background-color: rgba(37, 99, 235, 0.06); --action-item-color: #0f172a; - --input-background-color: #ffffff; + --input-background-color: transparent; --input-border-color: #cbd5e1; --input-focus-border-color: #3b82f6; diff --git a/frontend/scss/app/components/_form.scss b/frontend/scss/app/components/_form.scss index 06580b0f7..d68be5df7 100644 --- a/frontend/scss/app/components/_form.scss +++ b/frontend/scss/app/components/_form.scss @@ -56,6 +56,10 @@ } } +.tp__form-field__label { + @include tp-field-label-base; +} + .tp__field-label { display: flex; align-items: center; diff --git a/frontend/scss/app/components/_input.scss b/frontend/scss/app/components/_input.scss index abd97c0ca..e49a5c102 100644 --- a/frontend/scss/app/components/_input.scss +++ b/frontend/scss/app/components/_input.scss @@ -13,6 +13,7 @@ border-radius: var(--border-radius); background-color: var(--input-background-color); align-items: center; + padding-right: var(--padding-mini); &:focus-within { border-color: var(--input-focus-border-color); diff --git a/frontend/scss/app/components/_particle_flow_background.scss b/frontend/scss/app/components/_particle_flow_background.scss index 25f8f2284..fd326bb41 100644 --- a/frontend/scss/app/components/_particle_flow_background.scss +++ b/frontend/scss/app/components/_particle_flow_background.scss @@ -19,5 +19,5 @@ color-mix(in srgb, var(--particle-flow-background-color), var(--particle-flow-background-accent-color-2) 12%) 0%, transparent 45% ), - linear-gradient(180deg, var(--particle-flow-background-color) 0%, var(--background-color) 84%); + linear-gradient(180deg, var(--particle-flow-background-color) 0%, var(--particle-flow-background-accent-color-2) 84%); } diff --git a/frontend/scss/app/components/_table.scss b/frontend/scss/app/components/_table.scss index ec381350b..7923a4a0b 100644 --- a/frontend/scss/app/components/_table.scss +++ b/frontend/scss/app/components/_table.scss @@ -70,6 +70,9 @@ } } + &__cell--center { + text-align: center; + } &__number-cell { text-align: right; } diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index 0b4e7065e..308e05213 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -278,6 +278,7 @@ gap: var(--gap-default); box-sizing: border-box; } + &__card-header { display: flex; flex-flow: row; @@ -298,6 +299,7 @@ display: flex; flex-flow: row; gap: var(--gap-default); + &-item { font-size: 1.1rem; color: var(--text-color); @@ -312,6 +314,25 @@ } } -.tp__form-page__toolbar { - @extend %toolbar-base; +.tp__form-page { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + width: 100%; + overflow: hidden; + + &__body { + display: flex; + flex-flow: column; + gap: var(--gap-default); + box-sizing: border-box; + width: 100%; + overflow: auto; + } + + &__toolbar { + @extend %toolbar-base; + padding-top: var(--padding-default); + } } diff --git a/frontend/src/app/components/authentication.rs b/frontend/src/app/components/authentication.rs index 8bc86eb31..7ca1a2e91 100644 --- a/frontend/src/app/components/authentication.rs +++ b/frontend/src/app/components/authentication.rs @@ -1,8 +1,13 @@ use crate::{app::components::login::Login, hooks::use_service_context}; +use shared::model::permission::Permission; use std::future; use yew::{prelude::*, suspense::use_future}; use yew_hooks::{use_async_with_options, UseAsyncOptions}; +fn should_connect_websocket(success: bool, setup_mode: bool, can_read_system: bool) -> bool { + success && !setup_mode && can_read_system +} + #[derive(Properties, Clone, PartialEq)] pub struct AuthenticationProps { pub children: Children, @@ -22,7 +27,11 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { .auth .auth_subscribe(&mut |success| { authenticated_state.set(success); - if success && !services_ctx.config.ui_config.setup_mode { + if should_connect_websocket( + success, + services_ctx.config.ui_config.setup_mode, + services_ctx.auth.has_permission(Permission::SystemRead), + ) { services_ctx.websocket.connect_ws_with_backoff(); } future::ready(()) @@ -57,3 +66,16 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { html! {} } } + +#[cfg(test)] +mod tests { + use super::should_connect_websocket; + + #[test] + fn websocket_connects_only_for_authenticated_non_setup_users_with_system_read() { + assert!(should_connect_websocket(true, false, true)); + assert!(!should_connect_websocket(false, false, true)); + assert!(!should_connect_websocket(true, true, true)); + assert!(!should_connect_websocket(true, false, false)); + } +} diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 00aacc317..d64fa2a83 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -221,6 +221,7 @@ mod tests { enabled: true, issuer: "tuliprox".to_string(), secret: "top-secret".to_string(), + groupfile: Some("groups.txt".to_string()), ..Default::default() }), content_security_policy: Some(ContentSecurityPolicyConfigDto { @@ -252,6 +253,7 @@ mod tests { assert_eq!(web_ui.path.as_deref(), Some("/dashboard")); assert_eq!(web_ui.player_server.as_deref(), Some("http://player.local")); assert_eq!(web_ui.auth.as_ref().map(|auth| auth.secret.as_str()), Some("top-secret")); + assert_eq!(web_ui.auth.as_ref().and_then(|auth| auth.groupfile.as_deref()), Some("groups.txt")); } #[test] diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index 3ac8567f1..a550ace5e 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -26,7 +26,7 @@ use crate::{ utils::set_timeout, }; use log::warn; -use shared::model::{ApiProxyConfigDto, AppConfigDto, ConfigDto, SourcesConfigDto}; +use shared::model::{permission::Permission, ApiProxyConfigDto, AppConfigDto, ConfigDto, SourcesConfigDto}; use std::str::FromStr; use yew::{platform::spawn_local, prelude::*}; @@ -73,6 +73,8 @@ pub fn ConfigView() -> Html { let services_ctx = use_service_context(); let config_ctx = use_context::().expect("ConfigContext not found"); let setup_mode = services_ctx.config.ui_config.setup_mode; + let can_edit_config = + setup_mode || services_ctx.auth.has_any_permissions(Permission::ConfigWrite | Permission::SourceWrite); let active_tab = use_state(|| ConfigPage::Main); let edit_mode = use_state(|| setup_mode); @@ -157,7 +159,7 @@ pub fn ConfigView() -> Html { let handle_config_edit = { let set_edit_mode = edit_mode.clone(); Callback::from(move |_| { - if setup_mode { + if !can_edit_config { return; } set_edit_mode.set(!*set_edit_mode); @@ -461,14 +463,14 @@ pub fn ConfigView() -> Html {

{ translate.t(LABEL_CONFIG) }

- {html_if!(geo_ip_enabled, { + {html_if!(services_ctx.auth.has_permission(Permission::SystemWrite) && geo_ip_enabled, { })}
- { html_if!(!setup_mode, { + { html_if!(!setup_mode && can_edit_config, { Html { on_tab_change={Some(handle_tab_change)} class="tp__config-view__tabset"/> - { html_if!(*edit_mode || setup_mode, { + { html_if!(services_ctx.auth.has_any_permissions(Permission::ConfigWrite | Permission::SourceWrite) && (*edit_mode || setup_mode), {
Html { html! {
{translate.t(LABEL_HDHOMERUN_CONFIG)}
-
+
{if edit_mode { html! { <> @@ -166,7 +166,7 @@ pub fn HdHomerunConfigView() -> Html {
})} -
+
{render_devices(&form_state.form.devices)}
diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 5de5d1396..ad9ec33b9 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -8,6 +8,7 @@ use crate::{ }, input::Input, select::Select, + text_button::TextButton, Card, Chip, DropDownOption, DropDownSelection, IconButton, NoContent, }, ConfigContext, @@ -25,7 +26,8 @@ use yew::prelude::*; const LABEL_SCHEDULE: &str = "LABEL.SCHEDULE"; const LABEL_TARGETS: &str = "LABEL.TARGETS"; const LABEL_TYPE: &str = "LABEL.TYPE"; -const LABEL_ALL: &str = "LABEL.ALL"; +const LABEL_ALL_TARGETS: &str = "LABEL.ALL_TARGETS"; +const ALL_TARGETS_SENTINEL: &str = "__all_targets__"; generate_form_reducer!( state: SchedulesConfigFormState { form: SchedulesConfigDto }, @@ -42,8 +44,9 @@ pub fn SchedulesConfigView() -> Html { let config_ctx = use_context::().expect("Config context not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); let selected_targets = use_state(|| None::>); - let selected_schedule = use_state(|| None); + let selected_schedule = use_state(|| None::); let selected_type = use_state(|| ScheduleTaskType::PlaylistUpdate); + let editing_index = use_state(|| None::); let all_targets = use_memo(config_ctx.config.clone(), move |config| { config @@ -56,17 +59,27 @@ pub fn SchedulesConfigView() -> Html { .collect::>() }); - let target_options = - use_memo(((*selected_targets).clone(), all_targets.clone()), move |(selected, all_targets)| { - all_targets - .iter() - .map(|t| DropDownOption { - id: t.clone(), - label: html! { t.clone() }, - selected: (*selected).as_ref().is_some_and(|s| s.contains(t)), - }) - .collect::>() - }); + let all_targets_label = translate.t(LABEL_ALL_TARGETS); + let target_options = use_memo( + ((*selected_targets).clone(), all_targets.clone(), all_targets_label.clone()), + move |(selected, all_targets, all_targets_label)| { + let all_targets_selected = selected.as_ref().is_none_or(|targets| { + targets.is_empty() || targets.iter().any(|target| target == ALL_TARGETS_SENTINEL) + }); + std::iter::once(DropDownOption { + id: ALL_TARGETS_SENTINEL.to_string(), + label: html! { all_targets_label.clone() }, + selected: all_targets_selected, + }) + .chain(all_targets.iter().map(|t| DropDownOption { + id: t.clone(), + label: html! { t.clone() }, + selected: + selected.as_ref().is_some_and(|targets| targets.iter().any(|selected_target| selected_target == t)), + })) + .collect::>() + }, + ); let form_state: UseReducerHandle = use_reducer(|| SchedulesConfigFormState { form: SchedulesConfigDto::default(), modified: false }); @@ -77,6 +90,10 @@ pub fn SchedulesConfigView() -> Html { { let form_state = form_state.clone(); + let editing_index = editing_index.clone(); + let selected_schedule = selected_schedule.clone(); + let selected_targets = selected_targets.clone(); + let selected_type = selected_type.clone(); let config = config_ctx.config.as_ref().map(|c| c.config.clone()); use_effect_with((config, *config_view_ctx.edit_mode), move |(cfg, _mode)| { if let Some(conf) = cfg { @@ -85,6 +102,10 @@ pub fn SchedulesConfigView() -> Html { } else { form_state.dispatch(SchedulesConfigFormAction::SetAll(SchedulesConfigDto::default())); } + editing_index.set(None); + selected_schedule.set(None); + selected_targets.set(None); + selected_type.set(ScheduleTaskType::PlaylistUpdate); || () }); } @@ -105,8 +126,20 @@ pub fn SchedulesConfigView() -> Html { let set_selected_targets = selected_targets.clone(); Callback::from(move |(_name, selections): (String, DropDownSelection)| match selections { DropDownSelection::Empty => set_selected_targets.set(None), - DropDownSelection::Single(options) => set_selected_targets.set(Some(vec![options])), - DropDownSelection::Multi(options) => set_selected_targets.set(Some(options)), + DropDownSelection::Single(option) => { + if option == ALL_TARGETS_SENTINEL { + set_selected_targets.set(None); + } else { + set_selected_targets.set(Some(vec![option])); + } + } + DropDownSelection::Multi(options) => { + if options.iter().any(|option| option == ALL_TARGETS_SENTINEL) { + set_selected_targets.set(None); + } else { + set_selected_targets.set(Some(options)); + } + } }) }; @@ -117,27 +150,43 @@ pub fn SchedulesConfigView() -> Html { let set_selected_targets = selected_targets.clone(); let set_selected_schedule = selected_schedule.clone(); let selected_type = selected_type.clone(); + let editing_index = editing_index.clone(); Callback::from(move |_| { if let Some(schedule) = (*set_selected_schedule).as_ref() { let task_type = *selected_type; - let targets = - if task_type == ScheduleTaskType::PlaylistUpdate { (*set_selected_targets).clone() } else { None }; + let targets = if task_type == ScheduleTaskType::PlaylistUpdate { + (*set_selected_targets).clone().filter(|targets| { + !targets.is_empty() && !targets.iter().any(|target| target == ALL_TARGETS_SENTINEL) + }) + } else { + None + }; match Schedule::from_str(schedule) { Ok(_) => { let dto = ScheduleConfigDto { schedule: schedule.clone(), task_type, targets }; let mut new_schedules = form_state.data().schedules.as_ref().cloned().unwrap_or_default(); + let edited_index = *editing_index; - let exists = new_schedules.iter().any(|s| { - s.schedule == dto.schedule + let exists = new_schedules.iter().enumerate().any(|(idx, s)| { + Some(idx) != edited_index + && s.schedule == dto.schedule && s.task_type == dto.task_type && s.targets.as_deref() == dto.targets.as_deref() }); if exists { services.toastr.warning(translate.t("MESSAGES.SCHEDULE_EXISTS")); } else { - new_schedules.push(dto); + if let Some(idx) = edited_index { + if idx < new_schedules.len() { + new_schedules[idx] = dto; + } else { + new_schedules.push(dto); + } + } else { + new_schedules.push(dto); + } form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); - // clear editor + editing_index.set(None); set_selected_schedule.set(None); set_selected_targets.set(None); } @@ -154,10 +203,14 @@ pub fn SchedulesConfigView() -> Html { let handle_remove = { let form_state = form_state.clone(); - Callback::from(move |target: String| { + Callback::from(move |target_index: usize| { if let Some(schedules) = form_state.data().schedules.as_ref() { - let new_schedules: Vec = - schedules.iter().filter(|&s| s.schedule != target).cloned().collect(); + let new_schedules: Vec = schedules + .iter() + .enumerate() + .filter(|(idx, _)| *idx != target_index) + .map(|(_, schedule)| schedule.clone()) + .collect(); if new_schedules.is_empty() { form_state.dispatch(SchedulesConfigFormAction::Schedules(None)); } else { @@ -167,19 +220,69 @@ pub fn SchedulesConfigView() -> Html { }) }; + let handle_edit = { + let editing_index = editing_index.clone(); + let selected_schedule = selected_schedule.clone(); + let selected_targets = selected_targets.clone(); + let selected_type = selected_type.clone(); + let form_state = form_state.clone(); + Callback::from(move |target_index: usize| { + if let Some(entry) = form_state.data().schedules.as_ref().and_then(|schedules| schedules.get(target_index)) + { + editing_index.set(Some(target_index)); + selected_schedule.set(Some(entry.schedule.clone())); + selected_type.set(entry.task_type); + selected_targets.set(if entry.task_type == ScheduleTaskType::PlaylistUpdate { + entry.targets.clone().or_else(|| Some(vec![ALL_TARGETS_SENTINEL.to_string()])) + } else { + None + }); + } + }) + }; + + let handle_cancel_edit = { + let editing_index = editing_index.clone(); + let selected_schedule = selected_schedule.clone(); + let selected_targets = selected_targets.clone(); + let selected_type = selected_type.clone(); + Callback::from(move |_| { + editing_index.set(None); + selected_schedule.set(None); + selected_targets.set(None); + selected_type.set(ScheduleTaskType::PlaylistUpdate); + }) + }; + let render_view_mode = |deletable: bool| match form_state.data().schedules.as_ref() { Some(schedules) => { - let render_schedule_row = |entry: &ScheduleConfigDto| { + let is_editing = editing_index.is_some(); + let render_schedule_row = |(index, entry): (usize, &ScheduleConfigDto)| { let handle_remove_clone = handle_remove.clone(); - let schedule = entry.schedule.clone(); + let handle_edit_clone = handle_edit.clone(); html! { { html_if!(deletable, { - - - + <> + + {html_if!(!is_editing, { +
+ + +
+ })} + + })} { entry.schedule.clone() } { match entry.task_type { @@ -197,7 +300,7 @@ pub fn SchedulesConfigView() -> Html { } }, _ => html! { - + }, } } @@ -212,16 +315,16 @@ pub fn SchedulesConfigView() -> Html { - {html_if!(deletable, {})} + {html_if!(deletable, { + + })} - for entry in schedules.iter() { - { render_schedule_row(entry) } - } + { for schedules.iter().enumerate().map(render_schedule_row) }
{ translate.t(LABEL_SCHEDULE) } { translate.t(LABEL_TYPE) } { translate.t(LABEL_TARGETS) }
@@ -233,6 +336,7 @@ pub fn SchedulesConfigView() -> Html { let render_edit_mode = || { let set_selected_type = selected_type.clone(); + let is_editing = editing_index.is_some(); let types = Rc::new(vec![ DropDownOption { id: "PlaylistUpdate".to_string(), @@ -279,7 +383,39 @@ pub fn SchedulesConfigView() -> Html { /> }}) })} - + { if is_editing { + html! { +
+ + +
+ } + } else { + let handle_add_schedule_click = { + let handle_add_schedule = handle_add_schedule.clone(); + Callback::from(move |(name, _event)| handle_add_schedule.emit(name)) + }; + html! { + + } + }}
} }; diff --git a/frontend/src/app/components/config/webui_config_view.rs b/frontend/src/app/components/config/webui_config_view.rs index f7b10f40c..49d30c2dc 100644 --- a/frontend/src/app/components/config/webui_config_view.rs +++ b/frontend/src/app/components/config/webui_config_view.rs @@ -25,6 +25,7 @@ const LABEL_ISSUER: &str = "LABEL.ISSUER"; const LABEL_SECRET: &str = "LABEL.SECRET"; const LABEL_TOKEN_TTL_MINS: &str = "LABEL.TOKEN_TTL_MINS"; const LABEL_USERFILE: &str = "LABEL.USERFILE"; +const LABEL_GROUPFILE: &str = "LABEL.GROUPFILE"; const LABEL_PLAYER_SERVER: &str = "LABEL.PLAYER_SERVER"; const LABEL_KICK_DURATION: &str = "LABEL.KICK_DURATION"; const LABEL_USER_UI_ENABLED: &str = "LABEL.USER_UI_ENABLED"; @@ -56,6 +57,7 @@ generate_form_reducer!( Secret => secret: String, TokenTtlMins => token_ttl_mins: u32, Userfile => userfile: Option, + Groupfile => groupfile: Option, } ); @@ -171,6 +173,7 @@ pub fn WebUiConfigView() -> Html { { config_field_hide!(auth_state.form, translate.t(LABEL_SECRET), secret) } { config_field!(auth_state.form, translate.t(LABEL_TOKEN_TTL_MINS), token_ttl_mins) } { config_field_optional!(auth_state.form, translate.t(LABEL_USERFILE), userfile) } + { config_field_optional!(auth_state.form, translate.t(LABEL_GROUPFILE), groupfile) } } @@ -200,6 +203,7 @@ pub fn WebUiConfigView() -> Html { { edit_field_text!(auth_state, translate.t(LABEL_SECRET), secret, WebUiAuthConfigFormAction::Secret, true) } { edit_field_number!(auth_state, translate.t(LABEL_TOKEN_TTL_MINS), token_ttl_mins, WebUiAuthConfigFormAction::TokenTtlMins) } { edit_field_text_option!(auth_state, translate.t(LABEL_USERFILE), userfile, WebUiAuthConfigFormAction::Userfile) } + { edit_field_text_option!(auth_state, translate.t(LABEL_GROUPFILE), groupfile, WebUiAuthConfigFormAction::Groupfile) } } diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index dcb500cf0..780e0c655 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -2,9 +2,9 @@ use crate::{ app::{ components::{ config::ConfigView, loading_indicator::BusyIndicator, theme::Theme, AppIcon, DashboardView, EpgView, - IconButton, InputRow, Panel, ParticleFlowBackground, PlaylistExplorerView, PlaylistSettingsView, - PlaylistUpdateView, Setup, Sidebar, SourceEditor, StatsView, StreamsView, ToastrView, UserlistView, - WebsocketStatus, + IconButton, InputRow, NoAccess, Panel, ParticleFlowBackground, PlaylistExplorerView, PlaylistSettingsView, + PlaylistUpdateView, RbacView, Setup, Sidebar, SourceEditor, StatsView, StreamsView, ToastrView, + UserlistView, WebsocketStatus, }, context::{ConfigContext, PlaylistContext, StatusContext}, }, @@ -16,6 +16,7 @@ use crate::{ services::{ToastCloseMode, ToastOptions}, }; use shared::model::{ + permission::{Permission, PERM_ALL}, ApiProxyConfigDto, AppConfigDto, ConfigInputDto, LibraryScanSummaryStatus, PlaylistUpdateState, StatusCheck, SystemInfo, }; @@ -84,7 +85,15 @@ pub fn Home() -> Html { }); } - let _ = use_server_status(status.clone(), system_info.clone(), !setup_mode); + let can_read_system_status = services.auth.has_permission(Permission::SystemRead); + let can_read_config = services.auth.has_permission(Permission::ConfigRead); + let can_read_users = services.auth.has_permission(Permission::UserRead); + let can_read_sources = services.auth.has_permission(Permission::SourceRead); + let can_write_playlist = services.auth.has_permission(Permission::PlaylistWrite); + let can_read_playlist = services.auth.has_permission(Permission::PlaylistRead); + let can_read_epg = services.auth.has_permission(Permission::EpgRead); + let is_admin = services.auth.is_admin(); + let _ = use_server_status(status.clone(), system_info.clone(), !setup_mode && can_read_system_status); { // first register for config update @@ -170,6 +179,36 @@ pub fn Home() -> Html { return html! {}; } + // Check if non-admin user has any permissions at all + let has_any_permission = services.auth.has_any_permissions(PERM_ALL); + + if !has_any_permission && !setup_mode { + return html! { +
+
+
+
+ { + if let Some(ref title) = services.config.ui_config.app_title { + html! { { title } } + } else { + html! { } + } + } +
+
+ + +
+
+
+ +
+
+
+ }; + } + // combine_views_stats_streams=true means embed streams in stats (no separate page), so show_streams_page = !combine_views_stats_streams. // The default unwrap_or(true) correctly preserves backward compatibility (separate pages by default). let show_streams_page = config_context @@ -222,6 +261,7 @@ pub fn Home() -> Html {
{ html_if!(setup_mode, { }) } + { html_if!(setup_mode || can_read_config, { { if setup_mode { @@ -231,6 +271,7 @@ pub fn Home() -> Html { } } + })} { if setup_mode { html! {} @@ -240,32 +281,51 @@ pub fn Home() -> Html { + { html_if!(can_read_system_status, { - { html_if!(show_streams_page, { - + })} + { html_if!(show_streams_page && can_read_system_status, { + })} + { html_if!(can_read_users, { + })} + { html_if!(can_read_sources, { + })} + { html_if!(can_write_playlist, { + })} + { html_if!(can_read_playlist, { + <> + + })} + { html_if!(can_read_epg, { + })} + { html_if!(is_admin, { + + + + })} } } diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 982bf1204..2c058985b 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -25,12 +25,14 @@ mod loading_indicator; mod loading_screen; mod login; mod menu_item; +mod no_access; mod no_content; mod number_input; mod panel; mod playlist; mod popup_menu; mod radio_button_group; +mod rbac; mod reveal_content; mod role_based_content; mod search; @@ -67,9 +69,9 @@ pub(crate) use self::{ accordion::*, accordion_panel::*, authentication::*, breadcrumbs::*, card::*, cell_value::*, chip::*, cluster_flags_input::*, collapse_panel::*, csv_table::*, custom_dialog::*, dashboard::*, date_input_action::*, drop_down_icon_button::*, field_explanation::*, field_id::*, filter::*, hide_content::*, home::*, icon_button::*, - key_value_editor::*, loading_screen::*, login::*, no_content::*, panel::*, particle_flow_background::*, - playlist::*, radio_button_group::*, reveal_content::*, role_based_content::*, search::*, setup::*, sidebar::*, - source_editor::*, svg_icon::*, table::*, tabset::*, tag_list::*, text_button::*, textarea::*, title_card::*, - toastr::*, toggle_switch::*, userlist::*, websocket_status::*, + key_value_editor::*, loading_screen::*, login::*, no_access::*, no_content::*, panel::*, + particle_flow_background::*, playlist::*, radio_button_group::*, rbac::*, reveal_content::*, role_based_content::*, + search::*, select::*, setup::*, sidebar::*, source_editor::*, svg_icon::*, table::*, tabset::*, tag_list::*, + text_button::*, textarea::*, title_card::*, toastr::*, toggle_switch::*, userlist::*, websocket_status::*, }; pub use self::{confirm_dialog::*, content_dialog::*}; diff --git a/frontend/src/app/components/no_access.rs b/frontend/src/app/components/no_access.rs new file mode 100644 index 000000000..357954f92 --- /dev/null +++ b/frontend/src/app/components/no_access.rs @@ -0,0 +1,13 @@ +use crate::i18n::use_translation; +use yew::prelude::*; + +#[component] +pub fn NoAccess() -> Html { + let translate = use_translation(); + html! { +
+

{ translate.t("MESSAGES.NO_ACCESS_TITLE") }

+

{ translate.t("MESSAGES.NO_ACCESS_MESSAGE") }

+
+ } +} diff --git a/frontend/src/app/components/playlist/playlist_update_view.rs b/frontend/src/app/components/playlist/playlist_update_view.rs index 939a86070..4f7dfa0fc 100644 --- a/frontend/src/app/components/playlist/playlist_update_view.rs +++ b/frontend/src/app/components/playlist/playlist_update_view.rs @@ -7,7 +7,7 @@ use crate::{ html_if, i18n::use_translation, }; -use shared::model::ConfigTargetDto; +use shared::model::{permission::Permission, ConfigTargetDto}; use std::rc::Rc; use yew::{platform::spawn_local, prelude::*}; use yew_hooks::use_list; @@ -21,6 +21,8 @@ pub fn PlaylistUpdateView() -> Html { let playlist_ctx = use_context::().expect("Playlist context not found"); let config_ctx = use_context::().expect("Config context not found"); let services_ctx = use_service_context(); + let can_write_playlist = services_ctx.auth.has_permission(Permission::PlaylistWrite); + let can_write_library = services_ctx.auth.has_permission(Permission::LibraryWrite); let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.UPDATE")])); let selected_targets = use_list::>(vec![]); @@ -48,6 +50,9 @@ pub fn PlaylistUpdateView() -> Html { let services = services_ctx.clone(); let selected_targets = selected_targets.clone(); Callback::from(move |_| { + if !can_write_playlist { + return; + } let selected_targets = selected_targets.clone(); let services = services.clone(); let translate = translate.clone(); @@ -73,6 +78,9 @@ pub fn PlaylistUpdateView() -> Html { let services = services_ctx.clone(); let translate = translate.clone(); Callback::from(move |name: String| { + if !can_write_library { + return; + } let services = services.clone(); let translate = translate.clone(); wasm_bindgen_futures::spawn_local(async move { @@ -94,17 +102,19 @@ pub fn PlaylistUpdateView() -> Html {

{ translate.t("LABEL.UPDATE")}

- {html_if!(library_enabled, { + {html_if!(can_write_library && library_enabled, { })}
- + { html_if!(can_write_playlist, { + + })}
diff --git a/frontend/src/app/components/rbac/group_management.rs b/frontend/src/app/components/rbac/group_management.rs new file mode 100644 index 000000000..06831e945 --- /dev/null +++ b/frontend/src/app/components/rbac/group_management.rs @@ -0,0 +1,511 @@ +use crate::{ + app::components::{ + menu_item::MenuItem, popup_menu::PopupMenu, AppIcon, Card, Panel, Table, TableDefinition, TextButton, + ToggleSwitch, + }, + config_field_custom, edit_field_text, generate_form_reducer, + hooks::use_service_context, + html_if, + i18n::use_translation, + model::DialogResult, + services::{CreateGroupRequest, DialogService}, +}; +use shared::model::RbacGroupDto; +use std::{collections::HashSet, rc::Rc}; +use wasm_bindgen_futures::spawn_local; +use yew::prelude::*; + +const PERMISSION_DOMAINS: &[&str] = &["config", "source", "user", "playlist", "library", "system", "epg"]; + +const GROUP_HEADERS: [&str; 3] = ["LABEL.EMPTY", "LABEL.NAME", "LABEL.RBAC_PERMISSIONS"]; +const GROUP_DISPLAY_PANEL: &str = "display"; +const GROUP_EDIT_PANEL: &str = "edit"; + +#[derive(Debug, Clone, Eq, PartialEq)] +enum GroupAction { + Edit, + Delete, +} + +impl std::fmt::Display for GroupAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Edit => write!(f, "edit"), + Self::Delete => write!(f, "delete"), + } + } +} + +#[derive(Clone, PartialEq)] +enum FormMode { + Hidden, + Add, + Edit(String), +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct GroupFormDto { + name: String, + permissions: Vec, +} + +generate_form_reducer!( + state: GroupFormState { form: GroupFormDto }, + action_name: GroupFormAction, + fields { + Name => name: String, + Permissions => permissions: Vec, + } +); + +fn summarize_permissions(perms: &[String], no_perms_label: &str) -> String { + if perms.is_empty() { + return no_perms_label.to_string(); + } + let perm_set: HashSet<&str> = perms.iter().map(String::as_str).collect(); + let mut parts: Vec = Vec::new(); + for domain in PERMISSION_DOMAINS { + let read_key = format!("{domain}.read"); + let write_key = format!("{domain}.write"); + let has_read = perm_set.contains(read_key.as_str()); + let has_write = perm_set.contains(write_key.as_str()); + match (has_read, has_write) { + (true, true) => parts.push(format!("{domain}:rw")), + (true, false) => parts.push(format!("{domain}:r")), + (false, true) => parts.push(format!("{domain}:w")), + _ => {} + } + } + if parts.is_empty() { + no_perms_label.to_string() + } else { + parts.join(", ") + } +} + +fn collect_write_without_read_warnings(perms: &[String]) -> Vec { + let perm_set: HashSet<&str> = perms.iter().map(String::as_str).collect(); + PERMISSION_DOMAINS + .iter() + .filter(|domain| { + let write_key = format!("{domain}.write"); + let read_key = format!("{domain}.read"); + perm_set.contains(write_key.as_str()) && !perm_set.contains(read_key.as_str()) + }) + .map(|domain| (*domain).to_string()) + .collect() +} + +fn active_panel(mode: &FormMode) -> &'static str { + match mode { + FormMode::Hidden => GROUP_DISPLAY_PANEL, + FormMode::Add | FormMode::Edit(_) => GROUP_EDIT_PANEL, + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct GroupManagementProps { + pub groups: Option>, + pub on_groups_changed: Callback<()>, +} + +#[component] +pub fn GroupManagement(props: &GroupManagementProps) -> Html { + let services = use_service_context(); + let translate = use_translation(); + let dialog = use_context::().expect("Dialog service not found"); + + let form_mode = use_state(|| FormMode::Hidden); + let form_state: UseReducerHandle = + use_reducer(|| GroupFormState { form: GroupFormDto::default(), modified: false }); + + let popup_anchor_ref = use_state(|| None::); + let popup_is_open = use_state(|| false); + let selected_group = use_state(|| None::>); + + let on_groups_changed = props.on_groups_changed.clone(); + + let reset_form = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + move || { + form_mode.set(FormMode::Hidden); + form_state.dispatch(GroupFormAction::SetAll(GroupFormDto::default())); + } + }; + + let on_add_click = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + Callback::from(move |_: String| { + form_mode.set(FormMode::Add); + form_state.dispatch(GroupFormAction::SetAll(GroupFormDto::default())); + }) + }; + + let on_edit_group = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + let groups = props.groups.clone(); + move |name: String| { + if let Some(ref group_list) = groups { + if let Some(group) = group_list.iter().find(|g| g.name == name) { + form_mode.set(FormMode::Edit(name)); + form_state.dispatch(GroupFormAction::SetAll(GroupFormDto { + name: group.name.clone(), + permissions: group.permissions.clone(), + })); + } + } + } + }; + + let on_cancel = { + let reset_form = reset_form.clone(); + Callback::from(move |_: String| reset_form()) + }; + + let on_permission_toggle = { + let form_state = form_state.clone(); + Callback::from(move |(perm, value): (String, bool)| { + let mut current = form_state.form.permissions.clone(); + if value { + if !current.contains(&perm) { + current.push(perm); + } + } else { + current.retain(|p| p != &perm); + } + form_state.dispatch(GroupFormAction::Permissions(current)); + }) + }; + + let on_save = { + let services = services.clone(); + let translate = translate.clone(); + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + let reset_form = reset_form.clone(); + let on_groups_changed = on_groups_changed.clone(); + Callback::from(move |_: String| { + let name = form_state.form.name.clone(); + let permissions = form_state.form.permissions.clone(); + let write_without_read_warnings = collect_write_without_read_warnings(&permissions); + let mode = (*form_mode).clone(); + let services = services.clone(); + let translate = translate.clone(); + let reset_form = reset_form.clone(); + let on_groups_changed = on_groups_changed.clone(); + + if name.trim().is_empty() { + services.toastr.error(translate.t("MESSAGES.RBAC.GROUP_NAME_REQUIRED")); + return; + } + if !write_without_read_warnings.is_empty() { + services.toastr.error(format!( + "{}: {}", + write_without_read_warnings.join(", "), + translate.t("MESSAGES.RBAC.WRITE_WITHOUT_READ") + )); + return; + } + + spawn_local(async move { + let req = CreateGroupRequest { name: name.clone(), permissions }; + let result = match &mode { + FormMode::Add => services.rbac.create_group(req).await, + FormMode::Edit(original_name) => services.rbac.update_group(original_name, req).await, + FormMode::Hidden => return, + }; + + match result { + Ok(()) => { + let msg = match &mode { + FormMode::Add => translate.t("MESSAGES.RBAC.GROUP_CREATED"), + _ => translate.t("MESSAGES.RBAC.GROUP_UPDATED"), + }; + services.toastr.success(msg); + reset_form(); + on_groups_changed.emit(()); + } + Err(err) => { + services.toastr.error(format!("{err}")); + } + } + }); + }) + }; + + // Popup menu handlers + let handle_popup_close = { + let popup_is_open = popup_is_open.clone(); + Callback::from(move |()| popup_is_open.set(false)) + }; + + let handle_popup_onclick = { + let set_selected = selected_group.clone(); + let set_anchor = popup_anchor_ref.clone(); + let set_open = popup_is_open.clone(); + Callback::from(move |(dto, event): (Rc, MouseEvent)| { + if let Some(target) = event.target_dyn_into::() { + set_selected.set(Some(dto)); + set_anchor.set(Some(target)); + set_open.set(true); + } + }) + }; + + let handle_menu_click = { + let popup_is_open = popup_is_open.clone(); + let selected_group = selected_group.clone(); + let on_edit_group = on_edit_group.clone(); + let dialog = dialog.clone(); + let translate = translate.clone(); + let services = services.clone(); + let on_groups_changed = on_groups_changed.clone(); + Callback::from(move |(name, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + match name.as_str() { + "edit" => { + if let Some(dto) = &*selected_group { + on_edit_group(dto.name.clone()); + } + } + "delete" => { + let dialog = dialog.clone(); + let translate = translate.clone(); + let services = services.clone(); + let selected_group = selected_group.clone(); + let on_groups_changed = on_groups_changed.clone(); + spawn_local(async move { + let result = dialog.confirm(&translate.t("MESSAGES.CONFIRM_DELETE")).await; + if result == DialogResult::Ok { + if let Some(dto) = &*selected_group { + match services.rbac.delete_group(&dto.name).await { + Ok(()) => { + services.toastr.success(translate.t("MESSAGES.RBAC.GROUP_DELETED")); + selected_group.set(None); + on_groups_changed.emit(()); + } + Err(err) => { + services.toastr.error(format!("{err}")); + } + } + } + } + }); + } + _ => {} + } + popup_is_open.set(false); + }) + }; + + // Table definition + let render_header_cell = { + let translate = translate.clone(); + Callback::::from(move |col| { + html! { + if col < GROUP_HEADERS.len() { + { translate.t(GROUP_HEADERS[col]) } + } + } + }) + }; + + let no_perms_label = translate.t("LABEL.RBAC_NO_PERMISSIONS"); + let render_data_cell = { + let popup_onclick = handle_popup_onclick.clone(); + let no_perms_label = no_perms_label.clone(); + Callback::<(usize, usize, Rc), Html>::from( + move |(_row, col, dto): (usize, usize, Rc)| match col { + 0 => { + if dto.builtin { + return html! {}; + } + let popup_onclick = popup_onclick.clone(); + html! { + + } + } + 1 => { + html! { + <> + { &dto.name } + { if dto.builtin { + html! { {" built-in"} } + } else { + html! {} + }} + + } + } + 2 => html! { summarize_permissions(&dto.permissions, &no_perms_label) }, + _ => html! {}, + }, + ) + }; + + let is_sortable = Callback::::from(|_col: usize| false); + let on_sort = Callback::, ()>::from(|_| {}); + + let table_definition = { + let render_header = render_header_cell.clone(); + let render_data = render_data_cell.clone(); + let is_sortable = is_sortable.clone(); + let on_sort = on_sort.clone(); + let num_cols = GROUP_HEADERS.len(); + let groups_val = props.groups.clone(); + use_memo(groups_val, move |group_list| { + let items = + group_list.as_ref().map(|list| Rc::new(list.iter().map(|g| Rc::new(g.clone())).collect::>())); + TableDefinition:: { + items, + num_cols, + is_sortable, + on_sort, + render_header_cell: render_header, + render_data_cell: render_data, + } + }) + }; + + // Memoize write-without-read warnings + let perms_for_warnings = form_state.form.permissions.clone(); + let write_without_read_warnings = use_memo(perms_for_warnings, |perms| collect_write_without_read_warnings(perms)); + + let is_builtin_selected = selected_group.as_ref().is_some_and(|g| g.builtin); + let active_panel = active_panel(&form_mode); + let is_edit = matches!(*form_mode, FormMode::Edit(_)); + + html! { + + +
+

{ translate.t("LABEL.RBAC_GROUP_MANAGEMENT") }

+ +
+ + definition={table_definition} /> +
+ + +
+

{ + if is_edit { + translate.t("LABEL.RBAC_EDIT_GROUP") + } else { + translate.t("LABEL.RBAC_ADD_GROUP") + } + }

+ +
+
+ { if is_edit { + config_field_custom!(translate.t("LABEL.NAME"), form_state.form.name.clone()) + } else { + edit_field_text!(form_state, translate.t("LABEL.NAME"), name, GroupFormAction::Name) + }} +
+ + + + + + + + + + + + + { for PERMISSION_DOMAINS.iter().map(|domain| { + let read_perm = format!("{domain}.read"); + let write_perm = format!("{domain}.write"); + let read_checked = form_state.form.permissions.contains(&read_perm); + let write_checked = form_state.form.permissions.contains(&write_perm); + let is_epg_domain = *domain == "epg"; + + let on_read = { + let toggle = on_permission_toggle.clone(); + let perm = read_perm.clone(); + Callback::from(move |value: bool| toggle.emit((perm.clone(), value))) + }; + let on_write = { + let toggle = on_permission_toggle.clone(); + let perm = write_perm.clone(); + Callback::from(move |value: bool| toggle.emit((perm.clone(), value))) + }; + + html! { + + + + + + } + })} + +
{ translate.t("LABEL.RBAC_DOMAIN") }{ translate.t("LABEL.RBAC_READ") }{ translate.t("LABEL.RBAC_WRITE") }
{ domain } + + + { html_if!(!is_epg_domain, { + + })} +
+
+ + { if !write_without_read_warnings.is_empty() { + html! { +
+ { for write_without_read_warnings.iter().map(|domain| { + html! { +
+ { format!("{}: {}", domain, translate.t("MESSAGES.RBAC.WRITE_WITHOUT_READ")) } +
+ } + })} +
+ } + } else { + html! {} + }} +
+ +
+ + +
+
+
+ + { html_if!(!is_builtin_selected, { + <> + +
+ + + })} +
+
+ } +} diff --git a/frontend/src/app/components/rbac/mod.rs b/frontend/src/app/components/rbac/mod.rs new file mode 100644 index 000000000..d56417985 --- /dev/null +++ b/frontend/src/app/components/rbac/mod.rs @@ -0,0 +1,5 @@ +mod group_management; +mod rbac_view; +mod user_management; + +pub(crate) use self::rbac_view::*; diff --git a/frontend/src/app/components/rbac/rbac_view.rs b/frontend/src/app/components/rbac/rbac_view.rs new file mode 100644 index 000000000..09c85379c --- /dev/null +++ b/frontend/src/app/components/rbac/rbac_view.rs @@ -0,0 +1,86 @@ +use super::{group_management::GroupManagement, user_management::UserManagement}; +use crate::{ + app::components::{TabItem, TabSet}, + hooks::use_service_context, + i18n::use_translation, +}; +use shared::model::{permission::Permission, RbacGroupDto}; +use wasm_bindgen_futures::spawn_local; +use yew::{prelude::*, suspense::use_future}; + +#[component] +pub fn RbacView() -> Html { + let translate = use_translation(); + let services = use_service_context(); + let can_read_users = services.auth.has_permission(Permission::UserRead); + + let groups = use_state(|| None::>); + + // Fetch groups once for both tabs + { + let services = services.clone(); + let groups = groups.clone(); + let _ = use_future(move || async move { + if can_read_users { + if let Ok(Some(result)) = services.rbac.list_groups().await { + groups.set(Some(result)); + } + } + }); + } + + let on_groups_changed = { + let deps = (services.clone(), groups.clone(), can_read_users); + use_callback(deps, |(), (services, groups, can_read_users)| { + if !*can_read_users { + return; + } + let services = services.clone(); + let groups = groups.clone(); + spawn_local(async move { + if let Ok(Some(result)) = services.rbac.list_groups().await { + groups.set(Some(result)); + } + }); + }) + }; + + let tabs = { + let translate = translate.clone(); + let groups_val = (*groups).clone(); + use_memo( + (translate.clone(), groups_val, on_groups_changed.clone()), + move |(translate, groups_val, on_groups_changed)| { + vec![ + TabItem { + id: "users".to_string(), + title: translate.t("LABEL.RBAC_USERS"), + icon: "UserOutline".to_string(), + children: html! { }, + active_class: None, + inactive_class: None, + }, + TabItem { + id: "groups".to_string(), + title: translate.t("LABEL.GROUPS"), + icon: "Group".to_string(), + children: html! { }, + active_class: None, + inactive_class: None, + }, + ] + }, + ) + }; + + html! { +
+
+

{ translate.t("LABEL.RBAC") }

+
+
+ +
+
+ } +} diff --git a/frontend/src/app/components/rbac/user_management.rs b/frontend/src/app/components/rbac/user_management.rs new file mode 100644 index 000000000..54dfd6eac --- /dev/null +++ b/frontend/src/app/components/rbac/user_management.rs @@ -0,0 +1,494 @@ +use crate::{ + app::components::{ + config::HasFormData, menu_item::MenuItem, popup_menu::PopupMenu, AppIcon, Card, DropDownOption, + DropDownSelection, Panel, Select, Table, TableDefinition, TextButton, + }, + config_field_child, config_field_custom, edit_field_text, generate_form_reducer, + hooks::use_service_context, + html_if, + i18n::use_translation, + model::DialogResult, + services::{CreateUserRequest, DialogService, UpdateUserRequest}, +}; +use shared::model::{permission::Permission, RbacGroupDto, WebUiUserDto}; +use std::rc::Rc; +use wasm_bindgen_futures::spawn_local; +use yew::{prelude::*, suspense::use_future}; + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct UserFormDto { + username: String, + password: String, + confirm_password: String, + groups: Vec, +} + +generate_form_reducer!( + state: UserFormState { form: UserFormDto }, + action_name: UserFormAction, + fields { + Username => username: String, + Password => password: String, + ConfirmPassword => confirm_password: String, + Groups => groups: Vec, + } +); + +#[derive(Clone, PartialEq)] +enum FormMode { + Hidden, + Add, + Edit(String), +} + +const USER_HEADERS: [&str; 3] = ["LABEL.EMPTY", "LABEL.USERNAME", "LABEL.GROUPS"]; +const USER_DISPLAY_PANEL: &str = "display"; +const USER_EDIT_PANEL: &str = "edit"; + +#[derive(Debug, Clone, Eq, PartialEq)] +enum UserAction { + Edit, + Delete, +} + +impl std::fmt::Display for UserAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Edit => write!(f, "edit"), + Self::Delete => write!(f, "delete"), + } + } +} + +fn active_panel(mode: &FormMode) -> &'static str { + match mode { + FormMode::Hidden => USER_DISPLAY_PANEL, + FormMode::Add | FormMode::Edit(_) => USER_EDIT_PANEL, + } +} + +#[derive(Properties, PartialEq, Clone)] +pub struct UserManagementProps { + pub groups: Option>, +} + +#[component] +pub fn UserManagement(props: &UserManagementProps) -> Html { + let services = use_service_context(); + let translate = use_translation(); + let dialog = use_context::().expect("Dialog service not found"); + let can_read_users = services.auth.has_permission(Permission::UserRead); + let can_write_users = services.auth.has_permission(Permission::UserWrite); + + let users = use_state(|| None::>); + let form_mode = use_state(|| FormMode::Hidden); + let form_state: UseReducerHandle = + use_reducer(|| UserFormState { form: UserFormDto::default(), modified: false }); + + let popup_anchor_ref = use_state(|| None::); + let popup_is_open = use_state(|| false); + let selected_user = use_state(|| None::>); + + // Fetch users on mount + { + let services = services.clone(); + let users = users.clone(); + let _ = use_future(move || async move { + if can_read_users { + if let Ok(Some(result)) = services.rbac.list_users().await { + users.set(Some(result)); + } + } + }); + } + + let refetch_users = { + let services = services.clone(); + let users = users.clone(); + move || { + if !can_read_users { + return; + } + let services = services.clone(); + let users = users.clone(); + spawn_local(async move { + if let Ok(Some(result)) = services.rbac.list_users().await { + users.set(Some(result)); + } + }); + } + }; + + let reset_form = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + move || { + form_mode.set(FormMode::Hidden); + form_state.dispatch(UserFormAction::SetAll(UserFormDto::default())); + } + }; + + let on_add_click = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + Callback::from(move |_: String| { + if !can_write_users { + return; + } + form_mode.set(FormMode::Add); + form_state.dispatch(UserFormAction::SetAll(UserFormDto::default())); + }) + }; + + let on_edit_user = { + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + let users = users.clone(); + move |username: String| { + if !can_write_users { + return; + } + if let Some(ref user_list) = *users { + if let Some(user) = user_list.iter().find(|u| u.username == username) { + form_mode.set(FormMode::Edit(username)); + form_state.dispatch(UserFormAction::SetAll(UserFormDto { + username: user.username.clone(), + password: String::new(), + confirm_password: String::new(), + groups: user.groups.clone(), + })); + } + } + } + }; + + let on_cancel = { + let reset_form = reset_form.clone(); + Callback::from(move |_: String| reset_form()) + }; + + let on_group_select = { + let form_state = form_state.clone(); + Callback::from(move |(_name, selection): (String, DropDownSelection)| { + let selected = match selection { + DropDownSelection::Empty => vec![], + DropDownSelection::Single(id) => vec![id], + DropDownSelection::Multi(ids) => ids, + }; + form_state.dispatch(UserFormAction::Groups(selected)); + }) + }; + + let on_save = { + let services = services.clone(); + let translate = translate.clone(); + let form_mode = form_mode.clone(); + let form_state = form_state.clone(); + let reset_form = reset_form.clone(); + let refetch_users = refetch_users.clone(); + Callback::from(move |_: String| { + let username = form_state.form.username.clone(); + let password = form_state.form.password.clone(); + let confirm = form_state.form.confirm_password.clone(); + let groups = form_state.form.groups.clone(); + let mode = (*form_mode).clone(); + let services = services.clone(); + let translate = translate.clone(); + let reset_form = reset_form.clone(); + let refetch_users = refetch_users.clone(); + + if !can_write_users { + services.toastr.error(translate.t("UNAUTHORIZED")); + return; + } + if username.trim().is_empty() { + services.toastr.error(translate.t("MESSAGES.RBAC.USERNAME_REQUIRED")); + return; + } + + match &mode { + FormMode::Add => { + if password.len() < 8 { + services.toastr.error(translate.t("MESSAGES.RBAC.PASSWORD_MIN_LENGTH")); + return; + } + if password != confirm { + services.toastr.error(translate.t("MESSAGES.RBAC.PASSWORDS_DONT_MATCH")); + return; + } + } + FormMode::Edit(_) => { + if !password.is_empty() { + if password.len() < 8 { + services.toastr.error(translate.t("MESSAGES.RBAC.PASSWORD_MIN_LENGTH")); + return; + } + if password != confirm { + services.toastr.error(translate.t("MESSAGES.RBAC.PASSWORDS_DONT_MATCH")); + return; + } + } + } + FormMode::Hidden => return, + } + + spawn_local(async move { + let result = match &mode { + FormMode::Add => services.rbac.create_user(CreateUserRequest { username, password, groups }).await, + FormMode::Edit(uname) => { + let pwd = if password.is_empty() { None } else { Some(password) }; + services.rbac.update_user(uname, UpdateUserRequest { password: pwd, groups }).await + } + FormMode::Hidden => return, + }; + + match result { + Ok(()) => { + let msg = match &mode { + FormMode::Add => translate.t("MESSAGES.RBAC.USER_CREATED"), + _ => translate.t("MESSAGES.RBAC.USER_UPDATED"), + }; + services.toastr.success(msg); + reset_form(); + refetch_users(); + } + Err(err) => { + services.toastr.error(format!("{err}")); + } + } + }); + }) + }; + + // Popup menu handlers + let handle_popup_close = { + let popup_is_open = popup_is_open.clone(); + Callback::from(move |()| popup_is_open.set(false)) + }; + + let handle_popup_onclick = { + let set_selected = selected_user.clone(); + let set_anchor = popup_anchor_ref.clone(); + let set_open = popup_is_open.clone(); + Callback::from(move |(dto, event): (Rc, MouseEvent)| { + if let Some(target) = event.target_dyn_into::() { + set_selected.set(Some(dto)); + set_anchor.set(Some(target)); + set_open.set(true); + } + }) + }; + + let current_username = services.auth.get_username(); + + let handle_menu_click = { + let popup_is_open = popup_is_open.clone(); + let selected_user = selected_user.clone(); + let on_edit_user = on_edit_user.clone(); + let dialog = dialog.clone(); + let translate = translate.clone(); + let services = services.clone(); + let refetch_users = refetch_users.clone(); + Callback::from(move |(name, e): (String, MouseEvent)| { + e.prevent_default(); + e.stop_propagation(); + if !can_write_users { + popup_is_open.set(false); + return; + } + match name.as_str() { + "edit" => { + if let Some(dto) = &*selected_user { + on_edit_user(dto.username.clone()); + } + } + "delete" => { + let dialog = dialog.clone(); + let translate = translate.clone(); + let services = services.clone(); + let selected_user = selected_user.clone(); + let refetch_users = refetch_users.clone(); + spawn_local(async move { + let result = dialog.confirm(&translate.t("MESSAGES.CONFIRM_DELETE")).await; + if result == DialogResult::Ok { + if let Some(dto) = &*selected_user { + match services.rbac.delete_user(&dto.username).await { + Ok(()) => { + services.toastr.success(translate.t("MESSAGES.RBAC.USER_DELETED")); + refetch_users(); + } + Err(err) => { + services.toastr.error(format!("{err}")); + } + } + } + } + }); + } + _ => {} + } + popup_is_open.set(false); + }) + }; + + // Table definition + let render_header_cell = { + let translate = translate.clone(); + Callback::::from(move |col| { + html! { + if col < USER_HEADERS.len() { + { translate.t(USER_HEADERS[col]) } + } + } + }) + }; + + let render_data_cell = { + let popup_onclick = handle_popup_onclick.clone(); + Callback::<(usize, usize, Rc), Html>::from( + move |(_row, col, dto): (usize, usize, Rc)| match col { + 0 => { + if !can_write_users { + return html! {}; + } + let popup_onclick = popup_onclick.clone(); + html! { + + } + } + 1 => html! { &dto.username }, + 2 => html! { dto.groups.join(", ") }, + _ => html! {}, + }, + ) + }; + + let is_sortable = Callback::::from(|_col: usize| false); + let on_sort = Callback::, ()>::from(|_| {}); + + let table_definition = { + let render_header = render_header_cell.clone(); + let render_data = render_data_cell.clone(); + let is_sortable = is_sortable.clone(); + let on_sort = on_sort.clone(); + let num_cols = USER_HEADERS.len(); + use_memo((*users).clone(), move |user_list| { + let items = + user_list.as_ref().map(|list| Rc::new(list.iter().map(|u| Rc::new(u.clone())).collect::>())); + TableDefinition:: { + items, + num_cols, + is_sortable, + on_sort, + render_header_cell: render_header, + render_data_cell: render_data, + } + }) + }; + + // Memoize group options for Select multi-select + let selected_groups = form_state.form.groups.clone(); + let groups_for_memo = props.groups.clone(); + let group_options: Rc> = use_memo((groups_for_memo, selected_groups), |(groups, selected)| { + groups + .as_ref() + .map(|g| { + g.iter() + .map(|grp| DropDownOption::new(&grp.name, html! { &grp.name }, selected.contains(&grp.name))) + .collect() + }) + .unwrap_or_default() + }); + + let user_is_admin = form_state.form.groups.iter().any(|g| g == "admin"); + let is_self_selected = selected_user.as_ref().is_some_and(|u| u.username == current_username); + let active_panel = active_panel(&form_mode); + let is_edit = matches!(*form_mode, FormMode::Edit(_)); + + html! { + + +
+

{ translate.t("LABEL.RBAC_USERS") }

+ { html_if!(can_write_users, { + + })} +
+ + definition={table_definition} /> +
+ + +
+

{ + if is_edit { + translate.t("LABEL.RBAC_EDIT_USER") + } else { + translate.t("LABEL.RBAC_ADD_USER") + } + }

+ +
+ { if is_edit { + config_field_custom!(translate.t("LABEL.USERNAME"), form_state.data().username.clone()) + } else { + edit_field_text!(form_state, translate.t("LABEL.USERNAME"), username, UserFormAction::Username) + }} + { edit_field_text!(form_state, translate.t("LABEL.PASSWORD"), password, UserFormAction::Password, true) } + { edit_field_text!(form_state, translate.t("LABEL.REPEAT_PASSWORD"), confirm_password, UserFormAction::ConfirmPassword, true) } + + { config_field_child!(translate.t("LABEL.GROUPS"), { + html! { + <> +