From 84ab92f4f09377fecdcbae2d2dfd5753edda7fdf Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 23 Apr 2026 21:40:37 +0000 Subject: [PATCH] feat(M10): Settings page - Azure SSO, SMTP, polling, IP whitelist, TLS strategy --- Cargo.lock | 74 ++- crates/pm-auth/Cargo.toml | 3 +- crates/pm-auth/src/rbac.rs | 23 +- crates/pm-web/Cargo.toml | 7 + crates/pm-web/src/main.rs | 9 + crates/pm-web/src/routes/azure_sso.rs | 471 +++++++++++++++++ crates/pm-web/src/routes/mod.rs | 2 + crates/pm-web/src/routes/settings.rs | 694 ++++++++++++++++++++++++++ frontend/src/App.tsx | 3 +- frontend/src/api/client.ts | 48 ++ frontend/src/pages/SettingsPage.tsx | 292 +++++++++++ frontend/src/types/index.ts | 32 ++ tasks/todo.md | 18 +- 13 files changed, 1656 insertions(+), 20 deletions(-) create mode 100644 crates/pm-web/src/routes/azure_sso.rs create mode 100644 crates/pm-web/src/routes/settings.rs create mode 100644 frontend/src/pages/SettingsPage.tsx diff --git a/Cargo.lock b/Cargo.lock index ccb66b7..141a6ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,7 +65,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -604,7 +604,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -672,6 +672,22 @@ dependencies = [ "serde", ] +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1492,6 +1508,33 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lettre" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + [[package]] name = "libc" version = "0.2.185" @@ -1699,6 +1742,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2061,6 +2113,7 @@ dependencies = [ "hex", "ipnet", "jsonwebtoken", + "parking_lot", "pm-core", "rand 0.8.6", "serde", @@ -2147,15 +2200,20 @@ dependencies = [ "anyhow", "axum", "axum-extra", + "base64", "chrono", "dashmap", "ipnet", + "lettre", "pm-auth", "pm-ca", "pm-core", "pm-reports", + "rand 0.8.6", + "reqwest", "serde", "serde_json", + "sha2", "sqlx", "thiserror 2.0.18", "tokio", @@ -2164,6 +2222,8 @@ dependencies = [ "tracing", "tracing-subscriber", "ulid", + "url", + "urlencoding", "uuid", ] @@ -2351,6 +2411,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -2585,7 +2651,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4439,7 +4505,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "ring", "rusticata-macros", diff --git a/crates/pm-auth/Cargo.toml b/crates/pm-auth/Cargo.toml index a9620bf..274c5a7 100644 --- a/crates/pm-auth/Cargo.toml +++ b/crates/pm-auth/Cargo.toml @@ -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 } diff --git a/crates/pm-auth/src/rbac.rs b/crates/pm-auth/src/rbac.rs index 26ac3ca..4cd77b6 100644 --- a/crates/pm-auth/src/rbac.rs +++ b/crates/pm-auth/src/rbac.rs @@ -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, + /// IP whitelist (empty = allow all). RwLock for runtime updates. + pub ip_whitelist: Arc>>, } 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) { + let nets: Vec = 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"); } } diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index e5fffd7..42cba49 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -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 } diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 5a80782..7257e38 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -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, /// In-memory store for single-use WebSocket authentication tickets. pub ws_tickets: Arc>, + /// In-memory store for SSO PKCE sessions (state → code_verifier). + pub sso_sessions: Arc>, /// Internal certificate authority for mTLS client cert issuance. pub ca: Arc, } @@ -90,6 +93,7 @@ async fn main() -> anyhow::Result<()> { }); let ws_tickets: Arc> = Arc::new(DashMap::new()); + let sso_sessions: Arc> = 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 diff --git a/crates/pm-web/src/routes/azure_sso.rs b/crates/pm-web/src/routes/azure_sso.rs new file mode 100644 index 0000000..41330c0 --- /dev/null +++ b/crates/pm-web/src/routes/azure_sso.rs @@ -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, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + #[allow(dead_code)] + access_token: Option, + id_token: Option, + #[allow(dead_code)] + token_type: Option, + #[allow(dead_code)] + expires_in: Option, +} + +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + email: Option, + name: Option, + oid: Option, + preferred_username: Option, +} + +#[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 { + 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, +) -> Result)> { + // 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, + state: Option, + error: Option, + error_description: Option, +} + +async fn azure_callback( + State(state): State, + axum::extract::Query(params): axum::extract::Query, +) -> Result, (StatusCode, Json)> { + // 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 = 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 { + 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)) +} diff --git a/crates/pm-web/src/routes/mod.rs b/crates/pm-web/src/routes/mod.rs index 4c396f5..923e9de 100644 --- a/crates/pm-web/src/routes/mod.rs +++ b/crates/pm-web/src/routes/mod.rs @@ -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; diff --git a/crates/pm-web/src/routes/settings.rs b/crates/pm-web/src/routes/settings.rs new file mode 100644 index 0000000..b0853e7 --- /dev/null +++ b/crates/pm-web/src/routes/settings.rs @@ -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, + 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, + pub smtp: Option, + pub polling: Option, + pub ip_whitelist: Option>, + pub web_tls_strategy: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AzureSsoConfigUpdate { + pub enabled: Option, + pub tenant_id: Option, + pub client_id: Option, + pub client_secret: Option, + pub redirect_uri: Option, + pub scopes: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SmtpConfigUpdate { + pub enabled: Option, + pub host: Option, + pub port: Option, + pub username: Option, + pub password: Option, + pub from: Option, + pub tls_mode: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PollingConfigUpdate { + pub health_poll_interval_secs: Option, + pub patch_poll_interval_secs: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IpWhitelistUpdate { + pub entries: Vec, +} + +// ============================================================ +// Router +// ============================================================ + +pub fn router() -> Router { + 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)> { + 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, (StatusCode, Json)> { + 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, 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)> { + 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)> { + 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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + 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, + auth: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + 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 = 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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + 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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + 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::::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::::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::::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, + auth: AuthUser, +) -> Result, (StatusCode, Json)> { + admin_only(&auth)?; + + let value: Option = 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 = 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, + auth: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + admin_only(&auth)?; + + // Validate each entry + for entry in &req.entries { + if entry.parse::().is_err() + && entry.parse::().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 }))) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d1d519d..27b199b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import JobsPage from './pages/JobsPage' import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage' import CertificatesPage from './pages/CertificatesPage' import ReportsPage from './pages/ReportsPage' +import SettingsPage from './pages/SettingsPage' // Placeholder pages — implemented in later milestones const PlaceholderPage = ({ title }: { title: string }) => ( @@ -59,7 +60,7 @@ function App() { } /> {/* Protected — M8 */} } /> - } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a831458..385f3fd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -197,3 +197,51 @@ export const reportsApi = { timeout: 120_000, // reports can take a while }), } + +// ── Settings API (M10) ──────────────────────────────────────────────────── +export interface AzureSsoConfig { + enabled: boolean + tenant_id: string + client_id: string + redirect_uri: string + scopes: string +} + +export interface SmtpConfig { + enabled: boolean + host: string + port: number + username: string + from: string + tls_mode: string +} + +export interface PollingConfig { + health_poll_interval_secs: number + patch_poll_interval_secs: number +} + +export interface SettingsResponse { + azure_sso: AzureSsoConfig + smtp: SmtpConfig + polling: PollingConfig + ip_whitelist: string[] + web_tls_strategy: string +} + +export interface TestResult { + success: boolean + message: string +} + +export const settingsApi = { + get: () => apiClient.get('/settings'), + update: (data: Partial & { + azure_sso?: AzureSsoConfig & { client_secret?: string } + smtp?: SmtpConfig & { password?: string } + }) => apiClient.put('/settings', data), + testAzureSso: () => apiClient.post('/settings/azure-sso/test'), + testSmtp: () => apiClient.post('/settings/smtp/test'), + getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'), + updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }), +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..72ea363 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,292 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Accordion, AccordionDetails, AccordionSummary, Alert, Box, Button, + CircularProgress, Container, FormControl, FormControlLabel, Grid, + IconButton, InputLabel, MenuItem, Select, Snackbar, Switch, TextField, + Toolbar, Typography, +} from '@mui/material' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import SaveIcon from '@mui/icons-material/Save' +import DeleteIcon from '@mui/icons-material/Delete' +import AddIcon from '@mui/icons-material/Add' +import CloudIcon from '@mui/icons-material/Cloud' +import EmailIcon from '@mui/icons-material/Email' +import { settingsApi } from '../api/client' +import type { AzureSsoConfig, SmtpConfig, PollingConfig } from '../types' + +type AzureSsoForm = AzureSsoConfig & { client_secret?: string } +type SmtpForm = SmtpConfig & { password?: string } + +export default function SettingsPage() { + const [azureSso, setAzureSso] = useState({ + enabled: false, tenant_id: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid email profile', + }) + const [smtp, setSmtp] = useState({ + enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls', + }) + const [polling, setPolling] = useState({ + health_poll_interval_secs: 300, patch_poll_interval_secs: 1800, + }) + const [ipWhitelist, setIpWhitelist] = useState([]) + const [webTlsStrategy, setWebTlsStrategy] = useState('internal_ca') + + const [saving, setSaving] = useState(false) + const [testingAzure, setTestingAzure] = useState(false) + const [testingSmtp, setTestingSmtp] = useState(false) + const [azureSsoTestResult, setAzureSsoTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [loading, setLoading] = useState(true) + + const loadSettings = useCallback(async () => { + try { + setLoading(true) + const { data } = await settingsApi.get() + setAzureSso({ ...data.azure_sso, client_secret: '' }) + setSmtp({ ...data.smtp, password: '' }) + setPolling(data.polling) + setIpWhitelist(data.ip_whitelist) + setWebTlsStrategy(data.web_tls_strategy) + } catch { + setError('Failed to load settings') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { loadSettings() }, [loadSettings]) + + const handleSave = async () => { + setSaving(true) + setError(null) + setSuccess(null) + try { + await settingsApi.update({ + azure_sso: { ...azureSso }, + smtp: { ...smtp }, + polling, + ip_whitelist: ipWhitelist, + web_tls_strategy: webTlsStrategy, + }) + setSuccess('Settings saved successfully') + } catch { + setError('Failed to save settings') + } finally { + setSaving(false) + } + } + + const handleTestAzureSso = async () => { + setTestingAzure(true) + setAzureSsoTestResult(null) + try { + const { data } = await settingsApi.testAzureSso() + setAzureSsoTestResult(data) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Test failed' + setAzureSsoTestResult({ success: false, message: msg }) + } finally { + setTestingAzure(false) + } + } + + const handleTestSmtp = async () => { + setTestingSmtp(true) + setSmtpTestResult(null) + try { + const { data } = await settingsApi.testSmtp() + setSmtpTestResult(data) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Test failed' + setSmtpTestResult({ success: false, message: msg }) + } finally { + setTestingSmtp(false) + } + } + + const addWhitelistEntry = () => setIpWhitelist([...ipWhitelist, '']) + const removeWhitelistEntry = (idx: number) => setIpWhitelist(ipWhitelist.filter((_, i) => i !== idx)) + const updateWhitelistEntry = (idx: number, value: string) => { + const updated = [...ipWhitelist] + updated[idx] = value + setIpWhitelist(updated) + } + + if (loading) { + return ( + + + + ) + } + + return ( + + + Settings + + + + {error && setError(null)}>{error}} + + {/* Section 1: Azure SSO Configuration */} + + }> + Azure SSO Configuration + + + + + setAzureSso({ ...azureSso, enabled: e.target.checked })} />} + label="Enable Azure SSO" + /> + + + setAzureSso({ ...azureSso, tenant_id: e.target.value })} /> + + + setAzureSso({ ...azureSso, client_id: e.target.value })} /> + + + setAzureSso({ ...azureSso, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" /> + + + setAzureSso({ ...azureSso, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/azure/callback" /> + + + setAzureSso({ ...azureSso, scopes: e.target.value })} /> + + + + {azureSsoTestResult && ( + {azureSsoTestResult.message} + )} + + + + + + {/* Section 2: SMTP Configuration */} + + }> + SMTP Configuration + + + + + setSmtp({ ...smtp, enabled: e.target.checked })} />} + label="Enable Email Notifications" + /> + + + setSmtp({ ...smtp, host: e.target.value })} /> + + + setSmtp({ ...smtp, port: Number(e.target.value) })} /> + + + + TLS Mode + + + + + setSmtp({ ...smtp, username: e.target.value })} /> + + + setSmtp({ ...smtp, password: e.target.value })} placeholder="Enter new password or leave masked" /> + + + setSmtp({ ...smtp, from: e.target.value })} helperText="noreply@example.com" /> + + + + {smtpTestResult && ( + {smtpTestResult.message} + )} + + + + + + {/* Section 3: Polling Intervals */} + + }> + Polling Intervals + + + + + setPolling({ ...polling, health_poll_interval_secs: Number(e.target.value) })} helperText="How often to check agent health (default: 300)" /> + + + setPolling({ ...polling, patch_poll_interval_secs: Number(e.target.value) })} helperText="How often to check for patch updates (default: 1800)" /> + + + + + + {/* Section 4: IP Whitelist */} + + }> + IP Whitelist + + + + Restrict access to specific IP addresses or CIDR ranges. Leave empty to allow all. + + {ipWhitelist.map((entry, idx) => ( + + updateWhitelistEntry(idx, e.target.value)} placeholder="10.0.0.0/8 or 192.168.1.100" sx={{ flexGrow: 1 }} /> + removeWhitelistEntry(idx)}> + + ))} + + + + + {/* Section 5: Web UI TLS Certificate Strategy */} + + }> + Web UI TLS Certificate + + + + TLS Certificate Strategy + + + + {webTlsStrategy === 'internal_ca' + ? 'The internal CA will automatically generate and renew the web UI TLS certificate.' + : 'You must provide your own TLS certificate and key files at the configured paths.'} + + + + + setSuccess(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSuccess(null)}>{success} + + + ) +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d81e3e3..2ad85c2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -192,4 +192,36 @@ export interface IssuedCert { // ── Reports (M9) ───────────────────────────────────────────────────────────── export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit' + +// ── Settings (M10) ────────────────────────────────────────────────────────── + +export interface AzureSsoConfig { + enabled: boolean + tenant_id: string + client_id: string + redirect_uri: string + scopes: string +} + +export interface SmtpConfig { + enabled: boolean + host: string + port: number + username: string + from: string + tls_mode: string +} + +export interface PollingConfig { + health_poll_interval_secs: number + patch_poll_interval_secs: number +} + +export interface SettingsResponse { + azure_sso: AzureSsoConfig + smtp: SmtpConfig + polling: PollingConfig + ip_whitelist: string[] + web_tls_strategy: string +} export type ReportFormat = 'csv' | 'pdf' diff --git a/tasks/todo.md b/tasks/todo.md index 9a1e6d6..125fbd5 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -197,16 +197,16 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d ### M10: Settings Page (Azure SSO, SMTP, TLS, IP Whitelist) + Frontend Page **Goal:** All runtime configuration manageable from the UI. -- [ ] Implement `system_config` table CRUD API -- [ ] Implement Azure SSO configuration: tenant ID, client ID/secret, redirect URI, scopes -- [ ] Implement "Test Connection" action for Azure SSO (round-trip against Azure AD, report success/failure without enabling) -- [ ] Implement SMTP configuration: host, port, auth mode, username/password, TLS mode, from-address -- [ ] Implement "Send Test Email" action for SMTP -- [ ] Implement polling interval tuning (health, patch) in Settings +- [x] Implement `system_config` table CRUD API +- [x] Implement Azure SSO configuration: tenant ID, client ID/secret, redirect URI, scopes +- [x] Implement "Test Connection" action for Azure SSO (round-trip against Azure AD, report success/failure without enabling) +- [x] Implement SMTP configuration: host, port, auth mode, username/password, TLS mode, from-address +- [x] Implement "Send Test Email" action for SMTP +- [x] Implement polling interval tuning (health, patch) in Settings - [x] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied) -- [ ] Implement IP whitelist management in Settings -- [ ] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE -- [ ] Frontend: Settings page with all configuration sections and test actions +- [x] Implement IP whitelist management in Settings +- [x] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE +- [x] Frontend: Settings page with all configuration sections and test actions - [ ] Verify: Azure SSO test connection works, test email sends, TLS strategy switches, IP whitelist updates take effect ### M11: Email Notifications + Audit Logging Hardening