From f60c0df9f9b2bb139233e614b24b384e4b6ab18b Mon Sep 17 00:00:00 2001 From: Dionisio Date: Wed, 11 Feb 2026 00:15:26 +0100 Subject: [PATCH] feat(admin): add admin settings panel for OIDC configuration - Admin UI at /admin.html with settings management interface - REST API: GET/PUT /api/admin/settings/oidc, POST .../test, GET .../general - DB-backed settings in auth.admin_settings table (PostgreSQL) - OIDC auto-discovery from issuer URL (.well-known/openid-configuration) - Hot-reload: OIDC config changes apply without server restart - Role-based access: admin-only endpoints with 403 for regular users - Client secret stored securely, never exposed in GET responses - Env var override detection shown in admin UI - Clean architecture: repository trait, PG implementation, service, handler --- db/schema.sql | 14 + src/application/dtos/mod.rs | 1 + src/application/dtos/settings_dto.rs | 58 +++ .../services/admin_settings_service.rs | 270 ++++++++++++++ .../services/auth_application_service.rs | 77 ++-- src/application/services/mod.rs | 1 + src/common/config.rs | 26 ++ src/common/di.rs | 46 +++ src/domain/repositories/mod.rs | 1 + .../repositories/settings_repository.rs | 27 ++ src/infrastructure/repositories/pg/mod.rs | 2 + .../repositories/pg/settings_pg_repository.rs | 88 +++++ src/interfaces/api/handlers/admin_handler.rs | 118 ++++++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/routes.rs | 6 + static/admin.html | 335 ++++++++++++++++++ 16 files changed, 1048 insertions(+), 23 deletions(-) create mode 100644 src/application/dtos/settings_dto.rs create mode 100644 src/application/services/admin_settings_service.rs create mode 100644 src/domain/repositories/settings_repository.rs create mode 100644 src/infrastructure/repositories/pg/settings_pg_repository.rs create mode 100644 src/interfaces/api/handlers/admin_handler.rs create mode 100644 static/admin.html diff --git a/db/schema.sql b/db/schema.sql index 6f5486db..a6a5ed7e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -120,6 +120,20 @@ COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilizati COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization'; COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization'; +-- Admin settings (key-value store for platform configuration) +CREATE TABLE IF NOT EXISTS auth.admin_settings ( + key VARCHAR(255) PRIMARY KEY, + value TEXT NOT NULL, + category VARCHAR(50) NOT NULL DEFAULT 'general', + is_secret BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by VARCHAR(36) +); + +CREATE INDEX IF NOT EXISTS idx_admin_settings_category ON auth.admin_settings(category); + +COMMENT ON TABLE auth.admin_settings IS 'Platform configuration settings managed via admin panel'; + -- OIDC identity linking columns ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_provider VARCHAR(255); ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255); diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 4cc446ca..33f26476 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -8,6 +8,7 @@ pub mod i18n_dto; pub mod pagination; pub mod recent_dto; pub mod search_dto; +pub mod settings_dto; pub mod share_dto; pub mod trash_dto; pub mod user_dto; diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs new file mode 100644 index 00000000..8c348224 --- /dev/null +++ b/src/application/dtos/settings_dto.rs @@ -0,0 +1,58 @@ +use serde::{Serialize, Deserialize}; + +// ============================================================================ +// OIDC Settings DTOs (Admin Panel) +// ============================================================================ + +/// Current OIDC settings returned to admin UI (secrets masked) +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcSettingsDto { + pub enabled: bool, + pub issuer_url: String, + pub client_id: String, + /// True if a client secret is configured (never reveals the actual value) + pub client_secret_set: bool, + pub scopes: String, + pub auto_provision: bool, + pub admin_groups: String, + pub disable_password_login: bool, + pub provider_name: String, + /// Auto-generated callback URL the admin must register in their IdP + pub callback_url: String, + /// Field names overridden by environment variables (read-only in UI) + pub env_overrides: Vec, +} + +/// Request body for saving OIDC settings from the admin panel +#[derive(Debug, Serialize, Deserialize)] +pub struct SaveOidcSettingsDto { + pub enabled: bool, + pub issuer_url: String, + pub client_id: String, + /// Only update if provided and non-empty (None = keep existing) + pub client_secret: Option, + pub scopes: Option, + pub auto_provision: Option, + pub admin_groups: Option, + pub disable_password_login: Option, + pub provider_name: Option, +} + +/// Request body for testing OIDC discovery +#[derive(Debug, Serialize, Deserialize)] +pub struct TestOidcConnectionDto { + pub issuer_url: String, +} + +/// Result of OIDC connection test +#[derive(Debug, Serialize, Deserialize)] +pub struct OidcTestResultDto { + pub success: bool, + pub message: String, + pub issuer: Option, + pub authorization_endpoint: Option, + pub token_endpoint: Option, + pub userinfo_endpoint: Option, + /// Suggested provider name (derived from issuer hostname) + pub provider_name_suggestion: Option, +} diff --git a/src/application/services/admin_settings_service.rs b/src/application/services/admin_settings_service.rs new file mode 100644 index 00000000..1c64b6a9 --- /dev/null +++ b/src/application/services/admin_settings_service.rs @@ -0,0 +1,270 @@ +use std::sync::Arc; + +use crate::domain::repositories::settings_repository::SettingsRepository; +use crate::application::services::auth_application_service::AuthApplicationService; +use crate::application::dtos::settings_dto::{ + OidcSettingsDto, SaveOidcSettingsDto, OidcTestResultDto, TestOidcConnectionDto, +}; +use crate::infrastructure::services::oidc_service::OidcService; +use crate::common::config::OidcConfig; +use crate::common::errors::{DomainError, ErrorKind}; + +/// Admin settings service — manages platform configuration in the database. +/// +/// Configuration priority: **env vars > DB settings > defaults**. +/// Supports hot-reloading OIDC configuration without server restart. +pub struct AdminSettingsService { + settings_repo: Arc, + env_oidc_config: OidcConfig, + auth_app_service: Arc, + server_base_url: String, +} + +impl AdminSettingsService { + pub fn new( + settings_repo: Arc, + env_oidc_config: OidcConfig, + auth_app_service: Arc, + server_base_url: String, + ) -> Self { + Self { + settings_repo, + env_oidc_config, + auth_app_service, + server_base_url, + } + } + + /// Auto-generated OIDC callback URL + fn callback_url(&self) -> String { + let base = self.server_base_url.trim_end_matches('/'); + format!("{}/api/auth/oidc/callback", base) + } + + /// Detect which OIDC fields are overridden by environment variables + fn get_env_overrides(&self) -> Vec { + let mut out = Vec::new(); + let vars = [ + ("OXICLOUD_OIDC_ENABLED", "enabled"), + ("OXICLOUD_OIDC_ISSUER_URL", "issuer_url"), + ("OXICLOUD_OIDC_CLIENT_ID", "client_id"), + ("OXICLOUD_OIDC_CLIENT_SECRET", "client_secret"), + ("OXICLOUD_OIDC_SCOPES", "scopes"), + ("OXICLOUD_OIDC_AUTO_PROVISION", "auto_provision"), + ("OXICLOUD_OIDC_ADMIN_GROUPS", "admin_groups"), + ("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN", "disable_password_login"), + ("OXICLOUD_OIDC_PROVIDER_NAME", "provider_name"), + ]; + for (env_key, field_name) in &vars { + if std::env::var(env_key).is_ok() { + out.push(field_name.to_string()); + } + } + out + } + + /// Apply environment variable overrides on top of a config + fn apply_env_overrides(&self, config: &mut OidcConfig) { + let e = &self.env_oidc_config; + if std::env::var("OXICLOUD_OIDC_ENABLED").is_ok() { config.enabled = e.enabled; } + if std::env::var("OXICLOUD_OIDC_ISSUER_URL").is_ok() { config.issuer_url = e.issuer_url.clone(); } + if std::env::var("OXICLOUD_OIDC_CLIENT_ID").is_ok() { config.client_id = e.client_id.clone(); } + if std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").is_ok() { config.client_secret = e.client_secret.clone(); } + if std::env::var("OXICLOUD_OIDC_SCOPES").is_ok() { config.scopes = e.scopes.clone(); } + if std::env::var("OXICLOUD_OIDC_REDIRECT_URI").is_ok() { config.redirect_uri = e.redirect_uri.clone(); } + if std::env::var("OXICLOUD_OIDC_FRONTEND_URL").is_ok() { config.frontend_url = e.frontend_url.clone(); } + if std::env::var("OXICLOUD_OIDC_AUTO_PROVISION").is_ok() { config.auto_provision = e.auto_provision; } + if std::env::var("OXICLOUD_OIDC_ADMIN_GROUPS").is_ok() { config.admin_groups = e.admin_groups.clone(); } + if std::env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN").is_ok() { config.disable_password_login = e.disable_password_login; } + if std::env::var("OXICLOUD_OIDC_PROVIDER_NAME").is_ok() { config.provider_name = e.provider_name.clone(); } + } + + /// Load the effective OIDC config: DB settings + env var overrides + defaults. + pub async fn load_effective_oidc_config(&self) -> Result { + let db = self.settings_repo.get_by_category("oidc").await?; + let d = OidcConfig::default(); + + let mut config = OidcConfig { + enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled), + issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or(d.issuer_url), + client_id: db.get("oidc.client_id").cloned().unwrap_or(d.client_id), + client_secret: db.get("oidc.client_secret").cloned().unwrap_or(d.client_secret), + redirect_uri: self.callback_url(), + scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), + frontend_url: self.server_base_url.clone(), + auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision), + admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or(d.admin_groups), + disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login), + provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name), + }; + + // Env vars override DB + self.apply_env_overrides(&mut config); + Ok(config) + } + + /// Get OIDC settings for display in admin UI (secrets masked). + pub async fn get_oidc_settings(&self) -> Result { + let db = self.settings_repo.get_by_category("oidc").await?; + let d = OidcConfig::default(); + + let has_secret = db.get("oidc.client_secret").map(|s| !s.is_empty()).unwrap_or(false) + || std::env::var("OXICLOUD_OIDC_CLIENT_SECRET").map(|s| !s.is_empty()).unwrap_or(false); + + Ok(OidcSettingsDto { + enabled: db.get("oidc.enabled").and_then(|v| v.parse().ok()).unwrap_or(d.enabled), + issuer_url: db.get("oidc.issuer_url").cloned().unwrap_or_default(), + client_id: db.get("oidc.client_id").cloned().unwrap_or_default(), + client_secret_set: has_secret, + scopes: db.get("oidc.scopes").cloned().unwrap_or(d.scopes), + auto_provision: db.get("oidc.auto_provision").and_then(|v| v.parse().ok()).unwrap_or(d.auto_provision), + admin_groups: db.get("oidc.admin_groups").cloned().unwrap_or_default(), + disable_password_login: db.get("oidc.disable_password_login").and_then(|v| v.parse().ok()).unwrap_or(d.disable_password_login), + provider_name: db.get("oidc.provider_name").cloned().unwrap_or(d.provider_name), + callback_url: self.callback_url(), + env_overrides: self.get_env_overrides(), + }) + } + + /// Save OIDC settings to DB and hot-reload the OIDC service. + pub async fn save_oidc_settings( + &self, + dto: SaveOidcSettingsDto, + updated_by: &str, + ) -> Result<(), DomainError> { + let cat = "oidc"; + let by = Some(updated_by); + + self.settings_repo.set("oidc.enabled", &dto.enabled.to_string(), cat, false, by).await?; + self.settings_repo.set("oidc.issuer_url", &dto.issuer_url, cat, false, by).await?; + self.settings_repo.set("oidc.client_id", &dto.client_id, cat, false, by).await?; + + if let Some(ref secret) = dto.client_secret { + if !secret.is_empty() { + self.settings_repo.set("oidc.client_secret", secret, cat, true, by).await?; + } + } + if let Some(ref v) = dto.scopes { + self.settings_repo.set("oidc.scopes", v, cat, false, by).await?; + } + if let Some(v) = dto.auto_provision { + self.settings_repo.set("oidc.auto_provision", &v.to_string(), cat, false, by).await?; + } + if let Some(ref v) = dto.admin_groups { + self.settings_repo.set("oidc.admin_groups", v, cat, false, by).await?; + } + if let Some(v) = dto.disable_password_login { + self.settings_repo.set("oidc.disable_password_login", &v.to_string(), cat, false, by).await?; + } + if let Some(ref v) = dto.provider_name { + self.settings_repo.set("oidc.provider_name", v, cat, false, by).await?; + } + + // Hot-reload OIDC service + let eff = self.load_effective_oidc_config().await?; + if eff.enabled && !eff.issuer_url.is_empty() + && !eff.client_id.is_empty() && !eff.client_secret.is_empty() + { + let svc = Arc::new(OidcService::new(eff.clone())); + self.auth_app_service.reload_oidc(svc, eff); + tracing::info!("OIDC service hot-reloaded with new configuration"); + } else if !eff.enabled { + self.auth_app_service.disable_oidc(); + tracing::info!("OIDC service disabled via admin panel"); + } + + Ok(()) + } + + /// Test OIDC connection by fetching the discovery document. + pub async fn test_oidc_connection( + &self, + dto: TestOidcConnectionDto, + ) -> Result { + let issuer = dto.issuer_url.trim_end_matches('/'); + let discovery_url = format!("{}/.well-known/openid-configuration", issuer); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "OIDC", format!("HTTP client error: {}", e), + ))?; + + let resp = match client.get(&discovery_url).send().await { + Ok(r) => r, + Err(e) => { + return Ok(OidcTestResultDto { + success: false, + message: format!("Cannot reach the OIDC provider: {}. Check your Issuer URL.", e), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + }; + + if !resp.status().is_success() { + return Ok(OidcTestResultDto { + success: false, + message: format!( + "OIDC discovery returned HTTP {} — the Issuer URL may be incorrect.", + resp.status() + ), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + + #[derive(serde::Deserialize)] + struct Discovery { + issuer: Option, + authorization_endpoint: Option, + token_endpoint: Option, + userinfo_endpoint: Option, + } + + let disc: Discovery = match resp.json().await { + Ok(d) => d, + Err(e) => { + return Ok(OidcTestResultDto { + success: false, + message: format!("Invalid discovery document: {}", e), + issuer: None, + authorization_endpoint: None, + token_endpoint: None, + userinfo_endpoint: None, + provider_name_suggestion: None, + }); + } + }; + + // Suggest provider name from hostname + let suggestion = issuer + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .and_then(|host| { + let parts: Vec<&str> = host.split('.').collect(); + let name = if parts.len() >= 2 { parts[0] } else { host }; + let mut c = name.chars(); + c.next().map(|f| f.to_uppercase().to_string() + c.as_str()) + }); + + Ok(OidcTestResultDto { + success: true, + message: "OIDC provider is reachable and returned a valid discovery document.".into(), + issuer: disc.issuer, + authorization_endpoint: disc.authorization_endpoint, + token_endpoint: disc.token_endpoint, + userinfo_endpoint: disc.userinfo_endpoint, + provider_name_suggestion: suggestion, + }) + } +} diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 82e1d6cf..4ba9989c 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::RwLock; use crate::domain::entities::user::{User, UserRole}; use crate::domain::entities::session::Session; use crate::application::ports::auth_ports::{UserStoragePort, SessionStoragePort, PasswordHasherPort, TokenServicePort, OidcServicePort, OidcIdClaims}; @@ -8,14 +9,19 @@ use crate::application::ports::inbound::FolderUseCase; use crate::common::errors::{DomainError, ErrorKind}; use crate::common::config::OidcConfig; +/// Interior state for OIDC — protected by RwLock for hot-reload. +struct OidcState { + service: Option>, + config: Option, +} + pub struct AuthApplicationService { user_storage: Arc, session_storage: Arc, password_hasher: Arc, token_service: Arc, folder_service: Option>, - oidc_service: Option>, - oidc_config: Option, + oidc: RwLock, } impl AuthApplicationService { @@ -31,8 +37,7 @@ impl AuthApplicationService { password_hasher, token_service, folder_service: None, - oidc_service: None, - oidc_config: None, + oidc: RwLock::new(OidcState { service: None, config: None }), } } @@ -43,30 +48,51 @@ impl AuthApplicationService { } /// Configura el servicio OIDC - pub fn with_oidc(mut self, oidc_service: Arc, oidc_config: OidcConfig) -> Self { - self.oidc_service = Some(oidc_service); - self.oidc_config = Some(oidc_config); + pub fn with_oidc(self, oidc_service: Arc, oidc_config: OidcConfig) -> Self { + { + let mut state = self.oidc.write().unwrap(); + state.service = Some(oidc_service); + state.config = Some(oidc_config); + } self } + /// Hot-reload OIDC configuration at runtime (called from admin settings service) + pub fn reload_oidc(&self, oidc_service: Arc, oidc_config: OidcConfig) { + let mut state = self.oidc.write().unwrap(); + state.service = Some(oidc_service); + state.config = Some(oidc_config); + } + + /// Disable OIDC at runtime (called from admin settings service) + pub fn disable_oidc(&self) { + let mut state = self.oidc.write().unwrap(); + state.service = None; + state.config = None; + } + /// Returns whether OIDC is configured and enabled pub fn oidc_enabled(&self) -> bool { - self.oidc_service.is_some() && self.oidc_config.as_ref().map_or(false, |c| c.enabled) + let state = self.oidc.read().unwrap(); + state.service.is_some() && state.config.as_ref().map_or(false, |c| c.enabled) } /// Returns whether password login is disabled (OIDC-only mode) pub fn password_login_disabled(&self) -> bool { - self.oidc_config.as_ref().map_or(false, |c| c.disable_password_login) + let state = self.oidc.read().unwrap(); + state.config.as_ref().map_or(false, |c| c.disable_password_login) } - /// Returns the OIDC config if available - pub fn oidc_config(&self) -> Option<&OidcConfig> { - self.oidc_config.as_ref() + /// Returns a clone of the OIDC config if available + pub fn oidc_config(&self) -> Option { + let state = self.oidc.read().unwrap(); + state.config.clone() } - /// Returns the OIDC service if available - pub fn oidc_service(&self) -> Option<&Arc> { - self.oidc_service.as_ref() + /// Returns an Arc clone of the OIDC service if available + pub fn oidc_service(&self) -> Option> { + let state = self.oidc.read().unwrap(); + state.service.clone() } pub async fn register(&self, dto: RegisterDto) -> Result { @@ -559,7 +585,7 @@ impl AuthApplicationService { /// Generate the OIDC authorization URL for redirecting the user to the IdP. /// The `state` parameter is a signed JWT to prevent CSRF. pub fn oidc_authorize_url(&self, state: &str) -> Result { - let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new( + let oidc = self.oidc_service().ok_or_else(|| DomainError::new( ErrorKind::InternalError, "OIDC", "OIDC service not configured", ))?; oidc.get_authorize_url(state) @@ -578,12 +604,17 @@ impl AuthApplicationService { /// Handle the OIDC callback: exchange code, validate ID token, /// find or create user (JIT provisioning), and issue internal tokens. pub async fn oidc_callback(&self, code: &str) -> Result { - let oidc = self.oidc_service.as_ref().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "OIDC service not configured", - ))?; - let oidc_config = self.oidc_config.as_ref().ok_or_else(|| DomainError::new( - ErrorKind::InternalError, "OIDC", "OIDC config not available", - ))?; + // Clone the Arc and config out of the RwLock so we don't hold the lock across await points + let (oidc, oidc_config) = { + let state = self.oidc.read().unwrap(); + let svc = state.service.clone().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "OIDC service not configured", + ))?; + let cfg = state.config.clone().ok_or_else(|| DomainError::new( + ErrorKind::InternalError, "OIDC", "OIDC config not available", + ))?; + (svc, cfg) + }; // 1. Exchange authorization code for tokens let token_set = oidc.exchange_code(code).await?; @@ -649,7 +680,7 @@ impl AuthApplicationService { } // Determine role from OIDC groups - let role = self.map_oidc_role(&claims.groups, oidc_config); + let role = self.map_oidc_role(&claims.groups, &oidc_config); let quota = if role == UserRole::Admin { 107374182400 // 100GB diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index ab05c9b4..1b9647e3 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_settings_service; pub mod auth_application_service; pub mod batch_operations; pub mod calendar_service; diff --git a/src/common/config.rs b/src/common/config.rs index 596831cc..b0ccb2ed 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -309,6 +309,32 @@ impl Default for OidcConfig { } } +impl OidcConfig { + /// Load OIDC configuration from environment variables only + pub fn from_env() -> Self { + use std::env; + let mut cfg = Self::default(); + if let Ok(v) = env::var("OXICLOUD_OIDC_ENABLED") { + cfg.enabled = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_ISSUER_URL") { cfg.issuer_url = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_ID") { cfg.client_id = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_CLIENT_SECRET") { cfg.client_secret = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_REDIRECT_URI") { cfg.redirect_uri = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_SCOPES") { cfg.scopes = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_FRONTEND_URL") { cfg.frontend_url = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_AUTO_PROVISION") { + cfg.auto_provision = v.parse::().unwrap_or(true); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_ADMIN_GROUPS") { cfg.admin_groups = v; } + if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN") { + cfg.disable_password_login = v.parse::().unwrap_or(false); + } + if let Ok(v) = env::var("OXICLOUD_OIDC_PROVIDER_NAME") { cfg.provider_name = v; } + cfg + } +} + /// Configuración de funcionalidades (feature flags) #[derive(Debug, Clone)] pub struct FeaturesConfig { diff --git a/src/common/di.rs b/src/common/di.rs index 28da05cb..ee8bfa70 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use sqlx::PgPool; use crate::application::services::auth_application_service::AuthApplicationService; +use crate::application::services::admin_settings_service::AdminSettingsService; use crate::infrastructure::services::path_service::PathService; use crate::infrastructure::repositories::folder_fs_repository::FolderFsRepository; @@ -566,6 +567,7 @@ impl AppServiceFactory { applications: apps, db_pool: db_pool.clone(), auth_service: auth_services, + admin_settings_service: None, trash_service, share_service, favorites_service, @@ -578,6 +580,47 @@ impl AppServiceFactory { contact_use_case: None, }; + // 10b. Wire admin settings service when auth + DB are available + if let (Some(ref auth_svc), Some(ref pool)) = (&app_state.auth_service, &db_pool) { + let settings_repo = Arc::new( + crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()) + ); + let server_base_url = std::env::var("OXICLOUD_BASE_URL").unwrap_or_else(|_| { + format!("http://{}:{}", self.config.server_host, self.config.server_port) + }); + + // Load OIDC config from env vars (the snapshot from startup) + let env_oidc = crate::common::config::OidcConfig::from_env(); + + let admin_svc = Arc::new(AdminSettingsService::new( + settings_repo.clone(), + env_oidc, + auth_svc.auth_application_service.clone(), + server_base_url, + )); + + // Hot-reload OIDC from DB settings if configured + match admin_svc.load_effective_oidc_config().await { + Ok(eff) if eff.enabled && !eff.issuer_url.is_empty() + && !eff.client_id.is_empty() && !eff.client_secret.is_empty() => + { + let oidc_svc = Arc::new( + crate::infrastructure::services::oidc_service::OidcService::new(eff.clone()) + ); + auth_svc.auth_application_service.reload_oidc(oidc_svc, eff); + tracing::info!("OIDC config loaded from admin settings (database)"); + } + Ok(_) => { + tracing::info!("No active OIDC config in admin settings — using env vars or defaults"); + } + Err(e) => { + tracing::warn!("Failed to load OIDC settings from database (table may not exist yet): {}", e); + } + } + + app_state.admin_settings_service = Some(admin_svc); + } + // 11. Wire CalDAV/CardDAV services when database is available if let Some(ref pool) = db_pool { // CalDAV @@ -689,6 +732,7 @@ pub struct AppState { pub applications: ApplicationServices, pub db_pool: Option>, pub auth_service: Option, + pub admin_settings_service: Option>, pub trash_service: Option>, pub share_service: Option>, pub favorites_service: Option>, @@ -827,6 +871,7 @@ impl Default for AppState { applications: application_services, db_pool: None, auth_service: None, + admin_settings_service: None, trash_service: None, share_service: None, favorites_service: None, @@ -853,6 +898,7 @@ impl AppState { applications, db_pool: None, auth_service: None, + admin_settings_service: None, trash_service: None, share_service: None, favorites_service: None, diff --git a/src/domain/repositories/mod.rs b/src/domain/repositories/mod.rs index ac80f1d2..565a9325 100644 --- a/src/domain/repositories/mod.rs +++ b/src/domain/repositories/mod.rs @@ -7,4 +7,5 @@ pub mod folder_repository; pub mod session_repository; pub mod share_repository; pub mod trash_repository; +pub mod settings_repository; pub mod user_repository; \ No newline at end of file diff --git a/src/domain/repositories/settings_repository.rs b/src/domain/repositories/settings_repository.rs new file mode 100644 index 00000000..a99ae155 --- /dev/null +++ b/src/domain/repositories/settings_repository.rs @@ -0,0 +1,27 @@ +use std::collections::HashMap; +use async_trait::async_trait; +use crate::common::errors::DomainError; + +/// Repository for platform settings stored in the database. +/// Settings are key-value pairs organized by category (e.g., "oidc", "general"). +#[async_trait] +pub trait SettingsRepository: Send + Sync + 'static { + /// Get a single setting value by key + async fn get(&self, key: &str) -> Result, DomainError>; + + /// Get all settings for a given category + async fn get_by_category(&self, category: &str) -> Result, DomainError>; + + /// Set a setting value (upsert) + async fn set( + &self, + key: &str, + value: &str, + category: &str, + is_secret: bool, + updated_by: Option<&str>, + ) -> Result<(), DomainError>; + + /// Delete a setting by key + async fn delete(&self, key: &str) -> Result<(), DomainError>; +} diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index d8357ac0..e2b14931 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -7,6 +7,7 @@ mod contact_persistence_dto; mod favorites_pg_repository; mod recent_items_pg_repository; mod session_pg_repository; +mod settings_pg_repository; mod transaction_utils; mod user_pg_repository; @@ -19,4 +20,5 @@ pub use contact_persistence_dto::*; pub use favorites_pg_repository::FavoritesPgRepository; pub use recent_items_pg_repository::RecentItemsPgRepository; pub use session_pg_repository::SessionPgRepository; +pub use settings_pg_repository::SettingsPgRepository; pub use user_pg_repository::UserPgRepository; diff --git a/src/infrastructure/repositories/pg/settings_pg_repository.rs b/src/infrastructure/repositories/pg/settings_pg_repository.rs new file mode 100644 index 00000000..3aba433e --- /dev/null +++ b/src/infrastructure/repositories/pg/settings_pg_repository.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::PgPool; + +use crate::domain::repositories::settings_repository::SettingsRepository; +use crate::common::errors::{DomainError, ErrorKind}; + +pub struct SettingsPgRepository { + pool: Arc, +} + +impl SettingsPgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } +} + +#[async_trait] +impl SettingsRepository for SettingsPgRepository { + async fn get(&self, key: &str) -> Result, DomainError> { + let row = sqlx::query_scalar::<_, String>( + "SELECT value FROM auth.admin_settings WHERE key = $1" + ) + .bind(key) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "Settings", format!("DB error: {}", e), + ))?; + + Ok(row) + } + + async fn get_by_category(&self, category: &str) -> Result, DomainError> { + let rows = sqlx::query_as::<_, (String, String)>( + "SELECT key, value FROM auth.admin_settings WHERE category = $1" + ) + .bind(category) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "Settings", format!("DB error: {}", e), + ))?; + + Ok(rows.into_iter().collect()) + } + + async fn set( + &self, + key: &str, + value: &str, + category: &str, + is_secret: bool, + updated_by: Option<&str>, + ) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO auth.admin_settings (key, value, category, is_secret, updated_by, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (key) DO UPDATE + SET value = $2, category = $3, is_secret = $4, updated_by = $5, updated_at = NOW()" + ) + .bind(key) + .bind(value) + .bind(category) + .bind(is_secret) + .bind(updated_by) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "Settings", format!("DB error: {}", e), + ))?; + + Ok(()) + } + + async fn delete(&self, key: &str) -> Result<(), DomainError> { + sqlx::query("DELETE FROM auth.admin_settings WHERE key = $1") + .bind(key) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::new( + ErrorKind::InternalError, "Settings", format!("DB error: {}", e), + ))?; + + Ok(()) + } +} diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs new file mode 100644 index 00000000..2d671357 --- /dev/null +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -0,0 +1,118 @@ +use axum::{ + Router, + routing::{get, put, post}, + extract::{State, Json}, + http::{StatusCode, HeaderMap, header}, + response::IntoResponse, +}; + +use crate::common::di::AppState; +use crate::application::dtos::settings_dto::{SaveOidcSettingsDto, TestOidcConnectionDto}; +use crate::interfaces::errors::AppError; + +/// Admin API routes — all require admin role. +pub fn admin_routes() -> Router { + Router::new() + .route("/settings/oidc", get(get_oidc_settings)) + .route("/settings/oidc", put(save_oidc_settings)) + .route("/settings/oidc/test", post(test_oidc_connection)) + .route("/settings/general", get(get_general_settings)) +} + +/// Validate JWT and require admin role. Returns (user_id, role). +async fn admin_guard(state: &AppState, headers: &HeaderMap) -> Result<(String, String), AppError> { + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let token = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| AppError::unauthorized("Authorization token required"))?; + + let claims = auth.token_service.validate_token(token) + .map_err(|e| AppError::unauthorized(&format!("Invalid token: {}", e)))?; + + if claims.role != "admin" { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Admin access required", + "Forbidden", + )); + } + + Ok((claims.sub, claims.role)) +} + +/// GET /api/admin/settings/oidc — get OIDC settings for the admin panel +async fn get_oidc_settings( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state.admin_settings_service.as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let settings = svc.get_oidc_settings().await + .map_err(|e| AppError::internal_error(&format!("Failed to load settings: {}", e)))?; + + Ok(Json(settings)) +} + +/// PUT /api/admin/settings/oidc — save OIDC settings + hot-reload +async fn save_oidc_settings( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + let (user_id, _) = admin_guard(&state, &headers).await?; + + let svc = state.admin_settings_service.as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + svc.save_oidc_settings(dto, &user_id).await + .map_err(|e| AppError::internal_error(&format!("Failed to save settings: {}", e)))?; + + Ok((StatusCode::OK, Json(serde_json::json!({ + "message": "OIDC settings saved and applied successfully" + })))) +} + +/// POST /api/admin/settings/oidc/test — test OIDC discovery +async fn test_oidc_connection( + State(state): State, + headers: HeaderMap, + Json(dto): Json, +) -> Result { + admin_guard(&state, &headers).await?; + + let svc = state.admin_settings_service.as_ref() + .ok_or_else(|| AppError::internal_error("Admin settings service not available"))?; + + let result = svc.test_oidc_connection(dto).await + .map_err(|e| AppError::internal_error(&format!("Connection test failed: {}", e)))?; + + Ok(Json(result)) +} + +/// GET /api/admin/settings/general — system overview +async fn get_general_settings( + State(state): State, + headers: HeaderMap, +) -> Result { + admin_guard(&state, &headers).await?; + + let auth = state.auth_service.as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let user_count = auth.auth_application_service.count_all_users().await.unwrap_or(0); + let oidc_configured = auth.auth_application_service.oidc_enabled(); + + Ok(Json(serde_json::json!({ + "server_version": env!("CARGO_PKG_VERSION"), + "auth_enabled": true, + "total_users": user_count, + "oidc_configured": oidc_configured, + }))) +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index b5b2fe8b..337d2778 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod admin_handler; pub mod file_handler; pub mod folder_handler; pub mod i18n_handler; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 14580b16..1318c27e 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -28,6 +28,7 @@ use crate::interfaces::api::handlers::file_handler::FileHandler; use crate::interfaces::api::handlers::i18n_handler::I18nHandler; use crate::interfaces::api::handlers::chunked_upload_handler::ChunkedUploadHandler; use crate::interfaces::api::handlers::trash_handler; +use crate::interfaces::api::handlers::admin_handler; use crate::interfaces::api::handlers::batch_handler::{ self, BatchHandlerState }; @@ -282,6 +283,11 @@ pub fn create_api_routes(app_state: &AppState) -> Router { // NOTE: CalDAV and CardDAV routes are mounted at top-level (/caldav, /carddav) // in main.rs for protocol compliance, NOT under /api. + // Admin settings routes (protected by admin_guard inside the handler) + let admin_router = admin_handler::admin_routes() + .with_state(app_state.clone()); + router = router.nest("/admin", admin_router); + router .layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 00000000..cc6408e4 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,335 @@ + + + + + +OxiCloud — Admin Settings + + + +
+
+

⚙️ Admin Settings

+ ← Back to OxiCloud +
+ +
Loading…
+

Access Denied

Administrator privileges required.

Sign in
+ + +
+ + + +