Private
Public Access
1
0

feat(M10): Settings page - Azure SSO, SMTP, polling, IP whitelist, TLS strategy

This commit is contained in:
2026-04-23 21:40:37 +00:00
parent 7b7fac315e
commit 84ab92f4f0
13 changed files with 1656 additions and 20 deletions

View File

@ -24,5 +24,6 @@ rand = { workspace = true }
totp-rs = { workspace = true }
base64 = { workspace = true }
hex = { workspace = true }
sha2 = { workspace = true }
ipnet = { workspace = true }
parking_lot = "0.12"
sha2 = { workspace = true }

View File

@ -13,6 +13,7 @@ use axum::{
response::{IntoResponse, Json, Response},
};
use ipnet::IpNet;
use parking_lot::RwLock;
use serde_json::json;
use std::net::IpAddr;
use std::str::FromStr;
@ -64,8 +65,8 @@ impl UserRole {
pub struct AuthConfig {
/// Ed25519 public key PEM for JWT verification.
pub verify_key_pem: String,
/// IP whitelist (empty = allow all).
pub ip_whitelist: Vec<IpNet>,
/// IP whitelist (empty = allow all). RwLock for runtime updates.
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
}
impl AuthConfig {
@ -77,17 +78,29 @@ impl AuthConfig {
Self {
verify_key_pem,
ip_whitelist,
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
}
}
/// Check if an IP address is allowed by the whitelist.
/// If the whitelist is empty, all IPs are allowed.
pub fn is_ip_allowed(&self, ip: &IpAddr) -> bool {
if self.ip_whitelist.is_empty() {
let whitelist = self.ip_whitelist.read();
if whitelist.is_empty() {
return true;
}
self.ip_whitelist.iter().any(|net| net.contains(ip))
whitelist.iter().any(|net| net.contains(ip))
}
/// Update the IP whitelist at runtime without restart.
pub fn update_ip_whitelist(&self, entries: Vec<String>) {
let nets: Vec<IpNet> = entries
.iter()
.filter_map(|cidr| IpNet::from_str(cidr).ok())
.collect();
let count = nets.len();
*self.ip_whitelist.write() = nets;
tracing::info!(count, "IP whitelist updated at runtime");
}
}

View File

@ -31,3 +31,10 @@ ulid = { workspace = true }
chrono = { workspace = true }
ipnet = { workspace = true }
dashmap = { version = "6" }
reqwest = { workspace = true }
lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] }
sha2 = { workspace = true }
base64 = { workspace = true }
url = { workspace = true }
urlencoding = "2"
rand = { workspace = true }

View File

@ -22,6 +22,7 @@ use pm_auth::{
rbac::{AuthConfig, require_auth},
};
use routes::ws::WsTicket;
use routes::azure_sso::SsoSession;
use serde_json::{json, Value};
use std::{
net::SocketAddr,
@ -42,6 +43,8 @@ pub struct AppState {
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
}
@ -90,6 +93,7 @@ async fn main() -> anyhow::Result<()> {
});
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
let sso_sessions: Arc<DashMap<String, SsoSession>> = Arc::new(DashMap::new());
// Background task: purge expired WS tickets every 30 seconds.
{
@ -115,6 +119,7 @@ async fn main() -> anyhow::Result<()> {
signing_key_pem,
auth_config,
ws_tickets,
sso_sessions,
ca: Arc::new(ca),
};
@ -163,6 +168,8 @@ pub fn build_router(state: AppState) -> Router {
.merge(routes::ws::ticket_router())
// Reports
.nest("/reports", routes::reports::router())
// Settings (admin-only)
.nest("/settings", routes::settings::router())
// Apply auth middleware to all the above
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();
@ -173,6 +180,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/status/health", get(health_handler))
// Public auth routes (no JWT needed)
.nest("/api/v1/auth", routes::auth::public_router())
// Public Azure SSO routes (no JWT needed)
.nest("/api/v1/auth/azure", routes::azure_sso::public_router())
// Protected API routes (JWT required)
.nest("/api/v1", protected_api)
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware

View File

@ -0,0 +1,471 @@
//! Azure SSO OAuth2/OIDC flow routes.
//!
//! Public routes (no auth required):
//! GET /api/v1/auth/azure/login — redirect to Azure AD authorization URL
//! GET /api/v1/auth/azure/callback — handle Azure AD callback
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Json, Redirect},
routing::get,
Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use chrono::Utc;
use pm_auth::{jwt::issue_access_token, refresh};
use pm_core::audit::{log_event, AuditAction};
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::AppState;
// ============================================================
// Data structures
// ============================================================
#[derive(Clone)]
pub struct SsoSession {
pub code_verifier: String,
pub created_at: chrono::DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
#[allow(dead_code)]
access_token: Option<String>,
id_token: Option<String>,
#[allow(dead_code)]
token_type: Option<String>,
#[allow(dead_code)]
expires_in: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct IdTokenClaims {
email: Option<String>,
name: Option<String>,
oid: Option<String>,
preferred_username: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct DbUserForSso {
id: Uuid,
username: String,
display_name: String,
role: String,
is_active: bool,
mfa_enabled: bool,
}
// ============================================================
// Router
// ============================================================
pub fn public_router() -> Router<AppState> {
Router::new()
.route("/login", get(azure_login))
.route("/callback", get(azure_callback))
}
// ============================================================
// GET /api/v1/auth/azure/login
// ============================================================
async fn azure_login(
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, Json<Value>)> {
// Read Azure SSO config from DB
let row: Option<(bool, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, tenant_id, client_id, redirect_uri, scopes FROM azure_sso_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (enabled, tenant_id, client_id, redirect_uri, scopes) = match row {
Some(r) => r,
None => {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Azure SSO is not configured" } })),
));
}
};
if !enabled {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Azure SSO is not enabled" } })),
));
}
// Generate PKCE code_verifier (32 random bytes → base64url)
let mut verifier_bytes = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut verifier_bytes);
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
// code_challenge = BASE64URL(SHA256(code_verifier))
let challenge_digest = Sha256::digest(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(challenge_digest);
// Generate state token
let state_token = Uuid::new_v4().to_string();
// Store (state_token, code_verifier) in sso_sessions DashMap
state.sso_sessions.insert(
state_token.clone(),
SsoSession {
code_verifier,
created_at: Utc::now(),
},
);
// Build authorization URL
let encoded_scopes = urlencoding::encode(&scopes);
let auth_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/authorize?client_id={}&response_type=code&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
tenant_id, client_id, redirect_uri, encoded_scopes, code_challenge, state_token
);
// Redirect to Azure AD
Ok(Redirect::to(&auth_url))
}
// ============================================================
// GET /api/v1/auth/azure/callback
// ============================================================
#[derive(Debug, Deserialize)]
struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
async fn azure_callback(
State(state): State<AppState>,
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Check for error from Azure AD
if let Some(error) = params.error {
let desc = params.error_description.unwrap_or_default();
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "sso_error", "message": format!("Azure AD error: {} - {}", error, desc) } })),
));
}
let code = params.code.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "bad_request", "message": "Missing authorization code" } })),
)
})?;
let state_token = params.state.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "bad_request", "message": "Missing state parameter" } })),
)
})?;
// Look up code_verifier from sso_sessions
let sso_session = state
.sso_sessions
.remove(&state_token)
.map(|(_, v)| v)
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "bad_request", "message": "Invalid or expired state token" } })),
)
})?;
// Read Azure SSO config (including client_secret for token exchange)
let row: Option<(bool, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, tenant_id, client_id, client_secret, redirect_uri FROM azure_sso_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (_enabled, tenant_id, client_id, client_secret, redirect_uri) = match row {
Some(r) => r,
None => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Azure SSO not configured" } })),
));
}
};
// Exchange code for tokens
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
tenant_id
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "HTTP client error" } })),
)
})?;
let params = [
("grant_type", "authorization_code".to_string()),
("code", code.clone()),
("redirect_uri", redirect_uri.clone()),
("client_id", client_id.clone()),
("client_secret", client_secret.clone()),
("code_verifier", sso_session.code_verifier.clone()),
];
let form_params: Vec<(&str, String)> = params.to_vec();
let token_resp = client
.post(&token_url)
.form(&form_params)
.send()
.await
.map_err(|e| {
tracing::error!(error = %e, "Token exchange request failed");
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: {}", e) } })),
)
})?;
if !token_resp.status().is_success() {
let status = token_resp.status();
let body = token_resp.text().await.unwrap_or_default();
tracing::error!(status = %status, body = %body, "Token exchange failed");
return Err((
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: HTTP {}", status) } })),
));
}
let token_data: TokenResponse = token_resp
.json()
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to parse token response");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Failed to parse token response" } })),
)
})?;
// Decode id_token JWT (without verification — trust HTTPS channel)
let id_token = token_data.id_token.ok_or_else(|| {
(
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "code": "sso_error", "message": "No id_token in response" } })),
)
})?;
let claims = decode_jwt_payload(&id_token).map_err(|e| {
tracing::error!(error = %e, "Failed to decode id_token");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Failed to decode id_token" } })),
)
})?;
let email = claims.email.unwrap_or_default();
let name = claims.name.unwrap_or_default();
let oid = claims.oid.unwrap_or_default();
let preferred_username = claims.preferred_username.unwrap_or_else(|| email.clone());
if email.is_empty() || oid.is_empty() {
return Err((
StatusCode::BAD_GATEWAY,
Json(json!({ "error": { "code": "sso_error", "message": "Missing email or oid in id_token" } })),
));
}
// Look up or create user
let user_opt: Option<DbUserForSso> = sqlx::query_as(
r#"SELECT id, username, display_name, role, is_active, mfa_enabled
FROM users WHERE email = $1 AND auth_provider = 'azure_sso'"#,
)
.bind(&email)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to look up SSO user");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let user = match user_opt {
Some(u) if !u.is_active => {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "account_disabled", "message": "Account is disabled" } })),
));
}
Some(u) => u,
None => {
// Auto-create user with role=operator, auth_provider=azure_sso
let id: Uuid = sqlx::query_scalar(
r#"INSERT INTO users (username, display_name, email, role, auth_provider, azure_oid)
VALUES ($1, $2, $3, 'operator', 'azure_sso', $4)
RETURNING id"#,
)
.bind(&preferred_username)
.bind(&name)
.bind(&email)
.bind(&oid)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to create SSO user");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Failed to create user" } })),
)
})?;
log_event(
&state.db,
AuditAction::UserCreated,
None,
Some("azure_sso"),
Some("user"),
Some(&id.to_string()),
json!({ "auth_provider": "azure_sso", "email": email }),
None,
None,
)
.await;
DbUserForSso {
id,
username: preferred_username,
display_name: name,
role: "operator".to_string(),
is_active: true,
mfa_enabled: false,
}
}
};
// Update last_login_at and azure_oid
sqlx::query("UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2")
.bind(&oid)
.bind(user.id)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to update last_login_at");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
// Issue JWT access token + refresh token
let access_ttl = state.config.security.jwt_access_ttl_secs as i64;
let access_token = issue_access_token(
user.id,
&user.username,
&user.role,
access_ttl,
&state.signing_key_pem,
)
.map_err(|e| {
tracing::error!(error = %e, "Failed to issue access token");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Token issuance failed" } })),
)
})?;
let raw_refresh = refresh::issue(&state.db, user.id, None, None)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to issue refresh token");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Refresh token issuance failed" } })),
)
})?;
log_event(
&state.db,
AuditAction::UserLogin,
Some(user.id),
Some(&user.username),
None,
None,
json!({ "auth_provider": "azure_sso" }),
None,
None,
)
.await;
Ok(Json(json!({
"access_token": access_token,
"refresh_token": raw_refresh.0,
"token_type": "Bearer",
"expires_in": access_ttl,
"user": {
"id": user.id.to_string(),
"username": user.username,
"display_name": user.display_name,
"role": user.role,
"mfa_enabled": user.mfa_enabled,
}
})))
}
// ============================================================
// Helpers
// ============================================================
/// Decode JWT payload without verification (trust HTTPS channel from Azure AD).
fn decode_jwt_payload(token: &str) -> Result<IdTokenClaims, String> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return Err("Invalid JWT format".to_string());
}
let payload_b64 = parts[1];
// Add padding if needed
let mut payload_b64_padded = payload_b64.to_string();
while payload_b64_padded.len() % 4 != 0 {
payload_b64_padded.push('=');
}
let payload_bytes = base64::engine::general_purpose::STANDARD
.decode(&payload_b64_padded)
.map_err(|e| format!("Base64 decode error: {}", e))?;
serde_json::from_slice(&payload_bytes)
.map_err(|e| format!("JSON parse error: {}", e))
}

View File

@ -8,6 +8,8 @@ pub mod maintenance_windows;
pub mod jobs;
pub mod status;
pub mod users;
pub mod settings;
pub mod azure_sso;
pub mod ws;
pub mod reports;

View File

@ -0,0 +1,694 @@
//! Settings management routes.
//!
//! GET /api/v1/settings — get all settings (admin only)
//! PUT /api/v1/settings — update settings (admin only)
//! POST /api/v1/settings/azure-sso/test — test Azure SSO connectivity (admin only)
//! POST /api/v1/settings/smtp/test — send test email (admin only)
//! GET /api/v1/settings/ip-whitelist — get IP whitelist (admin only)
//! PUT /api/v1/settings/ip-whitelist — update IP whitelist (admin only)
use axum::{
extract::State,
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use lettre::{
message::{header::ContentType, Mailbox},
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use pm_core::audit::{log_event, AuditAction};
use pm_auth::rbac::AuthUser;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::AppState;
// ============================================================
// Data structures
// ============================================================
#[derive(Debug, Serialize)]
pub struct SettingsResponse {
pub azure_sso: AzureSsoConfig,
pub smtp: SmtpConfig,
pub polling: PollingConfig,
pub ip_whitelist: Vec<String>,
pub web_tls_strategy: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AzureSsoConfig {
pub enabled: bool,
pub tenant_id: String,
pub client_id: String,
pub redirect_uri: String,
pub scopes: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SmtpConfig {
pub enabled: bool,
pub host: String,
pub port: u16,
pub username: String,
pub from: String,
pub tls_mode: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct PollingConfig {
pub health_poll_interval_secs: u64,
pub patch_poll_interval_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest {
pub azure_sso: Option<AzureSsoConfigUpdate>,
pub smtp: Option<SmtpConfigUpdate>,
pub polling: Option<PollingConfigUpdate>,
pub ip_whitelist: Option<Vec<String>>,
pub web_tls_strategy: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AzureSsoConfigUpdate {
pub enabled: Option<bool>,
pub tenant_id: Option<String>,
pub client_id: Option<String>,
pub client_secret: Option<String>,
pub redirect_uri: Option<String>,
pub scopes: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SmtpConfigUpdate {
pub enabled: Option<bool>,
pub host: Option<String>,
pub port: Option<u16>,
pub username: Option<String>,
pub password: Option<String>,
pub from: Option<String>,
pub tls_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct PollingConfigUpdate {
pub health_poll_interval_secs: Option<u64>,
pub patch_poll_interval_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
pub struct IpWhitelistUpdate {
pub entries: Vec<String>,
}
// ============================================================
// Router
// ============================================================
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(get_settings).put(update_settings))
.route("/azure-sso/test", post(test_azure_sso))
.route("/smtp/test", post(test_smtp))
.route("/ip-whitelist", get(get_ip_whitelist).put(update_ip_whitelist))
}
// ============================================================
// Helpers
// ============================================================
const MASKED: &str = "********";
fn admin_only(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Admin access required" } })),
));
}
Ok(())
}
async fn load_system_config(
pool: &sqlx::PgPool,
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT key, value FROM system_config",
)
.fetch_all(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load system_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
Ok(rows.into_iter().collect())
}
fn build_settings_response(cfg: &HashMap<String, String>, azure: AzureSsoConfig) -> SettingsResponse {
let get = |key: &str| -> String { cfg.get(key).cloned().unwrap_or_default() };
SettingsResponse {
azure_sso: azure,
smtp: SmtpConfig {
enabled: get("smtp_enabled") == "true",
host: get("smtp_host"),
port: get("smtp_port").parse().unwrap_or(587),
username: get("smtp_username"),
from: get("smtp_from"),
tls_mode: get("smtp_tls_mode"),
},
polling: PollingConfig {
health_poll_interval_secs: get("health_poll_interval_secs").parse().unwrap_or(300),
patch_poll_interval_secs: get("patch_poll_interval_secs").parse().unwrap_or(1800),
},
ip_whitelist: serde_json::from_str(&get("ip_whitelist")).unwrap_or_default(),
web_tls_strategy: get("web_tls_strategy"),
}
}
async fn update_config_key(
pool: &sqlx::PgPool,
key: &str,
value: &str,
) -> Result<(), (StatusCode, Json<Value>)> {
sqlx::query("UPDATE system_config SET value = $1, updated_at = NOW() WHERE key = $2")
.bind(value)
.bind(key)
.execute(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, key, "Failed to update system_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
Ok(())
}
async fn fetch_azure_sso_config(
pool: &sqlx::PgPool,
) -> Result<AzureSsoConfig, (StatusCode, Json<Value>)> {
let row: Option<(bool, String, String, String, String)> = sqlx::query_as(
"SELECT enabled, tenant_id, client_id, redirect_uri, scopes FROM azure_sso_config WHERE id = 1",
)
.fetch_optional(pool)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
Ok(match row {
Some((enabled, tenant_id, client_id, redirect_uri, scopes)) => AzureSsoConfig {
enabled,
tenant_id,
client_id,
redirect_uri,
scopes,
},
None => AzureSsoConfig {
enabled: false,
tenant_id: String::new(),
client_id: String::new(),
redirect_uri: String::new(),
scopes: "openid email profile".to_string(),
},
})
}
// ============================================================
// GET /api/v1/settings
// ============================================================
async fn get_settings(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let cfg = load_system_config(&state.db).await?;
let azure = fetch_azure_sso_config(&state.db).await?;
Ok(Json(build_settings_response(&cfg, azure)))
}
// ============================================================
// PUT /api/v1/settings
// ============================================================
async fn update_settings(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<UpdateSettingsRequest>,
) -> Result<Json<SettingsResponse>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
// Update Azure SSO config
if let Some(azure) = &req.azure_sso {
// Build dynamic UPDATE query — only set fields that are Some
let mut sets = Vec::new();
let mut vals: Vec<String> = Vec::new();
let mut idx = 1;
if let Some(v) = azure.enabled {
sets.push(format!("enabled = ${}", idx));
vals.push(v.to_string());
idx += 1;
}
if let Some(ref v) = azure.tenant_id {
sets.push(format!("tenant_id = ${}", idx));
vals.push(v.clone());
idx += 1;
}
if let Some(ref v) = azure.client_id {
sets.push(format!("client_id = ${}", idx));
vals.push(v.clone());
idx += 1;
}
if let Some(ref v) = azure.client_secret {
if v != MASKED {
sets.push(format!("client_secret = ${}", idx));
vals.push(v.clone());
idx += 1;
}
}
if let Some(ref v) = azure.redirect_uri {
sets.push(format!("redirect_uri = ${}", idx));
vals.push(v.clone());
idx += 1;
}
if let Some(ref v) = azure.scopes {
sets.push(format!("scopes = ${}", idx));
vals.push(v.clone());
idx += 1;
}
if !sets.is_empty() {
let sql = format!(
"UPDATE azure_sso_config SET {}, updated_at = NOW() WHERE id = 1",
sets.join(", ")
);
let mut q = sqlx::query(&sql);
for val in &vals {
q = q.bind(val);
}
q.execute(&state.db).await.map_err(|e| {
tracing::error!(error = %e, "Failed to update azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
}
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("azure_sso"),
Some("1"),
json!({ "section": "azure_sso" }),
None,
None,
)
.await;
}
// Update SMTP config
if let Some(smtp) = &req.smtp {
if let Some(v) = smtp.enabled {
update_config_key(&state.db, "smtp_enabled", &v.to_string()).await?;
}
if let Some(ref v) = smtp.host {
update_config_key(&state.db, "smtp_host", v).await?;
}
if let Some(v) = smtp.port {
update_config_key(&state.db, "smtp_port", &v.to_string()).await?;
}
if let Some(ref v) = smtp.username {
update_config_key(&state.db, "smtp_username", v).await?;
}
if let Some(ref v) = smtp.password {
if v != MASKED {
update_config_key(&state.db, "smtp_password", v).await?;
}
}
if let Some(ref v) = smtp.from {
update_config_key(&state.db, "smtp_from", v).await?;
}
if let Some(ref v) = smtp.tls_mode {
update_config_key(&state.db, "smtp_tls_mode", v).await?;
}
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("smtp"),
Some("system_config"),
json!({ "section": "smtp" }),
None,
None,
)
.await;
}
// Update polling config
if let Some(polling) = &req.polling {
if let Some(v) = polling.health_poll_interval_secs {
update_config_key(&state.db, "health_poll_interval_secs", &v.to_string()).await?;
}
if let Some(v) = polling.patch_poll_interval_secs {
update_config_key(&state.db, "patch_poll_interval_secs", &v.to_string()).await?;
}
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("polling"),
Some("system_config"),
json!({ "section": "polling" }),
None,
None,
)
.await;
}
// Update IP whitelist
if let Some(ref entries) = req.ip_whitelist {
let json_str = serde_json::to_string(entries).unwrap_or_else(|_| "[]".to_string());
update_config_key(&state.db, "ip_whitelist", &json_str).await?;
// Update in-memory AuthConfig for immediate enforcement
state.auth_config.update_ip_whitelist(entries.clone());
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
Some("system_config"),
json!({ "entries": entries }),
None,
None,
)
.await;
}
// Update web TLS strategy
if let Some(ref v) = req.web_tls_strategy {
update_config_key(&state.db, "web_tls_strategy", v).await?;
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("web_tls_strategy"),
Some("system_config"),
json!({ "web_tls_strategy": v }),
None,
None,
)
.await;
}
// Return updated settings
let cfg = load_system_config(&state.db).await?;
let azure = fetch_azure_sso_config(&state.db).await?;
Ok(Json(build_settings_response(&cfg, azure)))
}
// ============================================================
// POST /api/v1/settings/azure-sso/test
// ============================================================
async fn test_azure_sso(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let row: Option<(String, String)> = sqlx::query_as(
"SELECT tenant_id, client_id FROM azure_sso_config WHERE id = 1",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load azure_sso_config");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let (tenant_id, _client_id) = match row {
Some(r) => r,
None => {
return Ok(Json(json!({
"success": false,
"message": "Azure SSO is not configured"
})));
}
};
if tenant_id.is_empty() {
return Ok(Json(json!({
"success": false,
"message": "Azure tenant ID is not set"
})));
}
let url = format!(
"https://login.microsoftonline.com/{}/v2.0/.well-known/openid-configuration",
tenant_id
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "HTTP client error" } })),
)
})?;
match client.get(&url).send().await {
Ok(resp) if resp.status().is_success() => {
let body: Value = resp.json().await.unwrap_or(json!({}));
let issuer = body.get("issuer").and_then(|v| v.as_str()).unwrap_or("");
if issuer.contains(&tenant_id) {
Ok(Json(json!({
"success": true,
"message": "Azure AD tenant verified successfully",
"issuer": issuer
})))
} else {
Ok(Json(json!({
"success": true,
"message": "Azure AD endpoint reached, but issuer does not match tenant_id",
"issuer": issuer
})))
}
}
Ok(resp) => Ok(Json(json!({
"success": false,
"message": format!("Failed to reach Azure AD: HTTP {}", resp.status())
}))),
Err(e) => Ok(Json(json!({
"success": false,
"message": format!("Failed to reach Azure AD: {}", e)
}))),
}
}
// ============================================================
// POST /api/v1/settings/smtp/test
// ============================================================
async fn test_smtp(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let cfg = load_system_config(&state.db).await?;
let smtp_enabled = cfg.get("smtp_enabled").map(|v| v.as_str()) == Some("true");
if !smtp_enabled {
return Ok(Json(json!({
"success": false,
"message": "SMTP is not enabled"
})));
}
let host = cfg.get("smtp_host").cloned().unwrap_or_default();
let port: u16 = cfg.get("smtp_port").and_then(|v| v.parse().ok()).unwrap_or(587);
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
let tls_mode = cfg.get("smtp_tls_mode").cloned().unwrap_or_else(|| "starttls".to_string());
if host.is_empty() || from_addr.is_empty() {
return Ok(Json(json!({
"success": false,
"message": "SMTP host or from address is not configured"
})));
}
let result = send_smtp_test(&host, port, &username, &password, &from_addr, &tls_mode).await;
match result {
Ok(()) => Ok(Json(json!({
"success": true,
"message": "Test email sent successfully"
}))),
Err(e) => Ok(Json(json!({
"success": false,
"message": format!("Failed to send test email: {}", e)
}))),
}
}
async fn send_smtp_test(
host: &str,
port: u16,
username: &str,
password: &str,
from_addr: &str,
tls_mode: &str,
) -> Result<(), String> {
let from_mailbox: Mailbox = from_addr.parse().map_err(|e| format!("Invalid from address: {}", e))?;
let email = Message::builder()
.from(from_mailbox.clone())
.to(from_mailbox)
.subject("Linux Patch Manager — SMTP Test")
.header(ContentType::TEXT_PLAIN)
.body("This is a test email from Linux Patch Manager.".to_string())
.map_err(|e| format!("Failed to build email: {}", e))?;
let result = match tls_mode {
"tls" => {
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::relay(host)
.map_err(|e| format!("TLS relay error: {}", e))?;
builder = builder.port(port);
if !username.is_empty() {
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
}
let transport = builder.build();
transport.send(email).await
}
"starttls" => {
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)
.map_err(|e| format!("STARTTLS relay error: {}", e))?;
builder = builder.port(port);
if !username.is_empty() {
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
}
let transport = builder.build();
transport.send(email).await
}
_ => {
// "none" — plaintext / no TLS
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(port);
if !username.is_empty() {
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
}
let transport = builder.build();
transport.send(email).await
}
};
result.map(|_| ()).map_err(|e| format!("SMTP send error: {}", e))
}
// ============================================================
// GET /api/v1/settings/ip-whitelist
// ============================================================
async fn get_ip_whitelist(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let value: Option<String> = sqlx::query_scalar(
"SELECT value FROM system_config WHERE key = 'ip_whitelist'",
)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to load ip_whitelist");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?;
let entries: Vec<String> = serde_json::from_str(&value.unwrap_or_default()).unwrap_or_default();
Ok(Json(json!({ "entries": entries })))
}
// ============================================================
// PUT /api/v1/settings/ip-whitelist
// ============================================================
async fn update_ip_whitelist(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<IpWhitelistUpdate>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
// Validate each entry
for entry in &req.entries {
if entry.parse::<ipnet::IpNet>().is_err()
&& entry.parse::<std::net::IpAddr>().is_err()
{
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "bad_request", "message": format!("Invalid CIDR or IP: {}", entry) } })),
));
}
}
let json_str = serde_json::to_string(&req.entries).unwrap_or_else(|_| "[]".to_string());
update_config_key(&state.db, "ip_whitelist", &json_str).await?;
// Update in-memory AuthConfig for immediate enforcement
state.auth_config.update_ip_whitelist(req.entries.clone());
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("ip_whitelist"),
Some("system_config"),
json!({ "entries": req.entries }),
None,
None,
)
.await;
Ok(Json(json!({ "entries": req.entries })))
}