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
This commit is contained in:
Dionisio
2026-02-11 00:15:26 +01:00
parent 8ef62109a3
commit f60c0df9f9
16 changed files with 1048 additions and 23 deletions
+1
View File
@@ -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;
+58
View File
@@ -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<String>,
}
/// 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<String>,
pub scopes: Option<String>,
pub auto_provision: Option<bool>,
pub admin_groups: Option<String>,
pub disable_password_login: Option<bool>,
pub provider_name: Option<String>,
}
/// 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<String>,
pub authorization_endpoint: Option<String>,
pub token_endpoint: Option<String>,
pub userinfo_endpoint: Option<String>,
/// Suggested provider name (derived from issuer hostname)
pub provider_name_suggestion: Option<String>,
}
@@ -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<dyn SettingsRepository>,
env_oidc_config: OidcConfig,
auth_app_service: Arc<AuthApplicationService>,
server_base_url: String,
}
impl AdminSettingsService {
pub fn new(
settings_repo: Arc<dyn SettingsRepository>,
env_oidc_config: OidcConfig,
auth_app_service: Arc<AuthApplicationService>,
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<String> {
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<OidcConfig, DomainError> {
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<OidcSettingsDto, DomainError> {
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<OidcTestResultDto, DomainError> {
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<String>,
authorization_endpoint: Option<String>,
token_endpoint: Option<String>,
userinfo_endpoint: Option<String>,
}
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,
})
}
}
@@ -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<Arc<dyn OidcServicePort>>,
config: Option<OidcConfig>,
}
pub struct AuthApplicationService {
user_storage: Arc<dyn UserStoragePort>,
session_storage: Arc<dyn SessionStoragePort>,
password_hasher: Arc<dyn PasswordHasherPort>,
token_service: Arc<dyn TokenServicePort>,
folder_service: Option<Arc<dyn FolderUseCase>>,
oidc_service: Option<Arc<dyn OidcServicePort>>,
oidc_config: Option<OidcConfig>,
oidc: RwLock<OidcState>,
}
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<dyn OidcServicePort>, oidc_config: OidcConfig) -> Self {
self.oidc_service = Some(oidc_service);
self.oidc_config = Some(oidc_config);
pub fn with_oidc(self, oidc_service: Arc<dyn OidcServicePort>, 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<dyn OidcServicePort>, 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<OidcConfig> {
let state = self.oidc.read().unwrap();
state.config.clone()
}
/// Returns the OIDC service if available
pub fn oidc_service(&self) -> Option<&Arc<dyn OidcServicePort>> {
self.oidc_service.as_ref()
/// Returns an Arc clone of the OIDC service if available
pub fn oidc_service(&self) -> Option<Arc<dyn OidcServicePort>> {
let state = self.oidc.read().unwrap();
state.service.clone()
}
pub async fn register(&self, dto: RegisterDto) -> Result<UserDto, DomainError> {
@@ -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<String, DomainError> {
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<AuthResponseDto, DomainError> {
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
+1
View File
@@ -1,3 +1,4 @@
pub mod admin_settings_service;
pub mod auth_application_service;
pub mod batch_operations;
pub mod calendar_service;
+26
View File
@@ -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::<bool>().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::<bool>().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::<bool>().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 {
+46
View File
@@ -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<Arc<PgPool>>,
pub auth_service: Option<AuthServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
pub trash_service: Option<Arc<dyn TrashUseCase>>,
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
@@ -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,
+1
View File
@@ -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;
@@ -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<Option<String>, DomainError>;
/// Get all settings for a given category
async fn get_by_category(&self, category: &str) -> Result<HashMap<String, String>, 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>;
}
@@ -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;
@@ -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<PgPool>,
}
impl SettingsPgRepository {
pub fn new(pool: Arc<PgPool>) -> Self {
Self { pool }
}
}
#[async_trait]
impl SettingsRepository for SettingsPgRepository {
async fn get(&self, key: &str) -> Result<Option<String>, 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<HashMap<String, String>, 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(())
}
}
@@ -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<AppState> {
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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Json(dto): Json<SaveOidcSettingsDto>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
Json(dto): Json<TestOidcConnectionDto>,
) -> Result<impl IntoResponse, AppError> {
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<AppState>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
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,
})))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod admin_handler;
pub mod file_handler;
pub mod folder_handler;
pub mod i18n_handler;
+6
View File
@@ -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<AppState> {
// 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())