feat(M10): Settings page - Azure SSO, SMTP, polling, IP whitelist, TLS strategy
This commit is contained in:
74
Cargo.lock
generated
74
Cargo.lock
generated
@ -65,7 +65,7 @@ dependencies = [
|
|||||||
"asn1-rs-derive",
|
"asn1-rs-derive",
|
||||||
"asn1-rs-impl",
|
"asn1-rs-impl",
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
@ -604,7 +604,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"asn1-rs",
|
"asn1-rs",
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"num-bigint",
|
"num-bigint",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
@ -672,6 +672,22 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@ -1492,6 +1508,33 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
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]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.185"
|
version = "0.2.185"
|
||||||
@ -1699,6 +1742,15 @@ dependencies = [
|
|||||||
"minimal-lexical",
|
"minimal-lexical",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@ -2061,6 +2113,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"parking_lot",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
@ -2147,15 +2200,20 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"axum-extra",
|
"axum-extra",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"ipnet",
|
"ipnet",
|
||||||
|
"lettre",
|
||||||
"pm-auth",
|
"pm-auth",
|
||||||
"pm-ca",
|
"pm-ca",
|
||||||
"pm-core",
|
"pm-core",
|
||||||
"pm-reports",
|
"pm-reports",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@ -2164,6 +2222,8 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"ulid",
|
"ulid",
|
||||||
|
"url",
|
||||||
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -2351,6 +2411,12 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quoted_printable"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "r-efi"
|
name = "r-efi"
|
||||||
version = "5.3.0"
|
version = "5.3.0"
|
||||||
@ -2585,7 +2651,7 @@ version = "4.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -4439,7 +4505,7 @@ dependencies = [
|
|||||||
"data-encoding",
|
"data-encoding",
|
||||||
"der-parser",
|
"der-parser",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"nom",
|
"nom 7.1.3",
|
||||||
"oid-registry",
|
"oid-registry",
|
||||||
"ring",
|
"ring",
|
||||||
"rusticata-macros",
|
"rusticata-macros",
|
||||||
|
|||||||
@ -24,5 +24,6 @@ rand = { workspace = true }
|
|||||||
totp-rs = { workspace = true }
|
totp-rs = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
|
||||||
ipnet = { workspace = true }
|
ipnet = { workspace = true }
|
||||||
|
parking_lot = "0.12"
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use axum::{
|
|||||||
response::{IntoResponse, Json, Response},
|
response::{IntoResponse, Json, Response},
|
||||||
};
|
};
|
||||||
use ipnet::IpNet;
|
use ipnet::IpNet;
|
||||||
|
use parking_lot::RwLock;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@ -64,8 +65,8 @@ impl UserRole {
|
|||||||
pub struct AuthConfig {
|
pub struct AuthConfig {
|
||||||
/// Ed25519 public key PEM for JWT verification.
|
/// Ed25519 public key PEM for JWT verification.
|
||||||
pub verify_key_pem: String,
|
pub verify_key_pem: String,
|
||||||
/// IP whitelist (empty = allow all).
|
/// IP whitelist (empty = allow all). RwLock for runtime updates.
|
||||||
pub ip_whitelist: Vec<IpNet>,
|
pub ip_whitelist: Arc<RwLock<Vec<IpNet>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthConfig {
|
impl AuthConfig {
|
||||||
@ -77,17 +78,29 @@ impl AuthConfig {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
verify_key_pem,
|
verify_key_pem,
|
||||||
ip_whitelist,
|
ip_whitelist: Arc::new(RwLock::new(ip_whitelist)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an IP address is allowed by the whitelist.
|
/// Check if an IP address is allowed by the whitelist.
|
||||||
/// If the whitelist is empty, all IPs are allowed.
|
/// If the whitelist is empty, all IPs are allowed.
|
||||||
pub fn is_ip_allowed(&self, ip: &IpAddr) -> bool {
|
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;
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,3 +31,10 @@ ulid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
ipnet = { workspace = true }
|
ipnet = { workspace = true }
|
||||||
dashmap = { version = "6" }
|
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 }
|
||||||
|
|||||||
@ -22,6 +22,7 @@ use pm_auth::{
|
|||||||
rbac::{AuthConfig, require_auth},
|
rbac::{AuthConfig, require_auth},
|
||||||
};
|
};
|
||||||
use routes::ws::WsTicket;
|
use routes::ws::WsTicket;
|
||||||
|
use routes::azure_sso::SsoSession;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::{
|
use std::{
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
@ -42,6 +43,8 @@ pub struct AppState {
|
|||||||
pub auth_config: Arc<AuthConfig>,
|
pub auth_config: Arc<AuthConfig>,
|
||||||
/// In-memory store for single-use WebSocket authentication tickets.
|
/// In-memory store for single-use WebSocket authentication tickets.
|
||||||
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
|
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.
|
/// Internal certificate authority for mTLS client cert issuance.
|
||||||
pub ca: Arc<pm_ca::CertAuthority>,
|
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 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.
|
// Background task: purge expired WS tickets every 30 seconds.
|
||||||
{
|
{
|
||||||
@ -115,6 +119,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
signing_key_pem,
|
signing_key_pem,
|
||||||
auth_config,
|
auth_config,
|
||||||
ws_tickets,
|
ws_tickets,
|
||||||
|
sso_sessions,
|
||||||
ca: Arc::new(ca),
|
ca: Arc::new(ca),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -163,6 +168,8 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(routes::ws::ticket_router())
|
.merge(routes::ws::ticket_router())
|
||||||
// Reports
|
// Reports
|
||||||
.nest("/reports", routes::reports::router())
|
.nest("/reports", routes::reports::router())
|
||||||
|
// Settings (admin-only)
|
||||||
|
.nest("/settings", routes::settings::router())
|
||||||
// Apply auth middleware to all the above
|
// Apply auth middleware to all the above
|
||||||
.route_layer(middleware::from_fn(move |req, next| {
|
.route_layer(middleware::from_fn(move |req, next| {
|
||||||
let auth_config = auth_config.clone();
|
let auth_config = auth_config.clone();
|
||||||
@ -173,6 +180,8 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/status/health", get(health_handler))
|
.route("/status/health", get(health_handler))
|
||||||
// Public auth routes (no JWT needed)
|
// Public auth routes (no JWT needed)
|
||||||
.nest("/api/v1/auth", routes::auth::public_router())
|
.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)
|
// Protected API routes (JWT required)
|
||||||
.nest("/api/v1", protected_api)
|
.nest("/api/v1", protected_api)
|
||||||
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
||||||
|
|||||||
471
crates/pm-web/src/routes/azure_sso.rs
Normal file
471
crates/pm-web/src/routes/azure_sso.rs
Normal 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))
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ pub mod maintenance_windows;
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod azure_sso;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
|
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
|
|||||||
694
crates/pm-web/src/routes/settings.rs
Normal file
694
crates/pm-web/src/routes/settings.rs
Normal 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 })))
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import JobsPage from './pages/JobsPage'
|
|||||||
import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
|
import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
|
||||||
import CertificatesPage from './pages/CertificatesPage'
|
import CertificatesPage from './pages/CertificatesPage'
|
||||||
import ReportsPage from './pages/ReportsPage'
|
import ReportsPage from './pages/ReportsPage'
|
||||||
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
|
||||||
// Placeholder pages — implemented in later milestones
|
// Placeholder pages — implemented in later milestones
|
||||||
const PlaceholderPage = ({ title }: { title: string }) => (
|
const PlaceholderPage = ({ title }: { title: string }) => (
|
||||||
@ -59,7 +60,7 @@ function App() {
|
|||||||
<Route path="/reports" element={<RequireAuth><ReportsPage /></RequireAuth>} />
|
<Route path="/reports" element={<RequireAuth><ReportsPage /></RequireAuth>} />
|
||||||
{/* Protected — M8 */}
|
{/* Protected — M8 */}
|
||||||
<Route path="/certificates" element={<RequireAuth><CertificatesPage /></RequireAuth>} />
|
<Route path="/certificates" element={<RequireAuth><CertificatesPage /></RequireAuth>} />
|
||||||
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
|
<Route path="/settings" element={<RequireAuth><SettingsPage /></RequireAuth>} />
|
||||||
|
|
||||||
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
|
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@ -197,3 +197,51 @@ export const reportsApi = {
|
|||||||
timeout: 120_000, // reports can take a while
|
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<SettingsResponse>('/settings'),
|
||||||
|
update: (data: Partial<SettingsResponse> & {
|
||||||
|
azure_sso?: AzureSsoConfig & { client_secret?: string }
|
||||||
|
smtp?: SmtpConfig & { password?: string }
|
||||||
|
}) => apiClient.put<SettingsResponse>('/settings', data),
|
||||||
|
testAzureSso: () => apiClient.post<TestResult>('/settings/azure-sso/test'),
|
||||||
|
testSmtp: () => apiClient.post<TestResult>('/settings/smtp/test'),
|
||||||
|
getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'),
|
||||||
|
updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }),
|
||||||
|
}
|
||||||
|
|||||||
292
frontend/src/pages/SettingsPage.tsx
Normal file
292
frontend/src/pages/SettingsPage.tsx
Normal file
@ -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<AzureSsoForm>({
|
||||||
|
enabled: false, tenant_id: '', client_id: '', client_secret: '', redirect_uri: '', scopes: 'openid email profile',
|
||||||
|
})
|
||||||
|
const [smtp, setSmtp] = useState<SmtpForm>({
|
||||||
|
enabled: false, host: '', port: 587, username: '', password: '', from: '', tls_mode: 'starttls',
|
||||||
|
})
|
||||||
|
const [polling, setPolling] = useState<PollingConfig>({
|
||||||
|
health_poll_interval_secs: 300, patch_poll_interval_secs: 1800,
|
||||||
|
})
|
||||||
|
const [ipWhitelist, setIpWhitelist] = useState<string[]>([])
|
||||||
|
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<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(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 (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 3, textAlign: 'center' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
|
<Toolbar disableGutters sx={{ mb: 3, justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h5" fontWeight={700}>Settings</Typography>
|
||||||
|
<Button variant="contained" onClick={handleSave} disabled={saving} startIcon={saving ? <CircularProgress size={20} /> : <SaveIcon />}>
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
|
||||||
|
{/* Section 1: Azure SSO Configuration */}
|
||||||
|
<Accordion defaultExpanded>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Azure SSO Configuration</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={12}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={azureSso.enabled} onChange={(e) => setAzureSso({ ...azureSso, enabled: e.target.checked })} />}
|
||||||
|
label="Enable Azure SSO"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Tenant ID" value={azureSso.tenant_id} onChange={(e) => setAzureSso({ ...azureSso, tenant_id: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Client ID" value={azureSso.client_id} onChange={(e) => setAzureSso({ ...azureSso, client_id: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Client Secret" type="password" value={azureSso.client_secret ?? ''} onChange={(e) => setAzureSso({ ...azureSso, client_secret: e.target.value })} placeholder="Enter new secret or leave masked" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Redirect URI" value={azureSso.redirect_uri} onChange={(e) => setAzureSso({ ...azureSso, redirect_uri: e.target.value })} helperText="e.g. https://patch-manager.example.com/api/v1/auth/azure/callback" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Scopes" value={azureSso.scopes} onChange={(e) => setAzureSso({ ...azureSso, scopes: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<Button variant="outlined" onClick={handleTestAzureSso} disabled={testingAzure || !azureSso.tenant_id} startIcon={testingAzure ? <CircularProgress size={20} /> : <CloudIcon />}>
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
{azureSsoTestResult && (
|
||||||
|
<Alert severity={azureSsoTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{azureSsoTestResult.message}</Alert>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Section 2: SMTP Configuration */}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>SMTP Configuration</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={12}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={smtp.enabled} onChange={(e) => setSmtp({ ...smtp, enabled: e.target.checked })} />}
|
||||||
|
label="Enable Email Notifications"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="SMTP Host" value={smtp.host} onChange={(e) => setSmtp({ ...smtp, host: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={3}>
|
||||||
|
<TextField fullWidth label="Port" type="number" value={smtp.port} onChange={(e) => setSmtp({ ...smtp, port: Number(e.target.value) })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={3}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>TLS Mode</InputLabel>
|
||||||
|
<Select value={smtp.tls_mode} label="TLS Mode" onChange={(e) => setSmtp({ ...smtp, tls_mode: e.target.value })}>
|
||||||
|
<MenuItem value="none">None</MenuItem>
|
||||||
|
<MenuItem value="starttls">STARTTLS</MenuItem>
|
||||||
|
<MenuItem value="tls">TLS (Implicit)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Username" value={smtp.username} onChange={(e) => setSmtp({ ...smtp, username: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Password" type="password" value={smtp.password ?? ''} onChange={(e) => setSmtp({ ...smtp, password: e.target.value })} placeholder="Enter new password or leave masked" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="From Address" value={smtp.from} onChange={(e) => setSmtp({ ...smtp, from: e.target.value })} helperText="noreply@example.com" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<Button variant="outlined" onClick={handleTestSmtp} disabled={testingSmtp || !smtp.enabled} startIcon={testingSmtp ? <CircularProgress size={20} /> : <EmailIcon />}>
|
||||||
|
Send Test Email
|
||||||
|
</Button>
|
||||||
|
{smtpTestResult && (
|
||||||
|
<Alert severity={smtpTestResult.success ? 'success' : 'error'} sx={{ mt: 1 }}>{smtpTestResult.message}</Alert>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Section 3: Polling Intervals */}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Polling Intervals</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Health Poll Interval (seconds)" type="number" value={polling.health_poll_interval_secs} onChange={(e) => setPolling({ ...polling, health_poll_interval_secs: Number(e.target.value) })} helperText="How often to check agent health (default: 300)" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField fullWidth label="Patch Data Poll Interval (seconds)" type="number" value={polling.patch_poll_interval_secs} onChange={(e) => setPolling({ ...polling, patch_poll_interval_secs: Number(e.target.value) })} helperText="How often to check for patch updates (default: 1800)" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Section 4: IP Whitelist */}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>IP Whitelist</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Restrict access to specific IP addresses or CIDR ranges. Leave empty to allow all.
|
||||||
|
</Typography>
|
||||||
|
{ipWhitelist.map((entry, idx) => (
|
||||||
|
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||||
|
<TextField size="small" value={entry} onChange={(e) => updateWhitelistEntry(idx, e.target.value)} placeholder="10.0.0.0/8 or 192.168.1.100" sx={{ flexGrow: 1 }} />
|
||||||
|
<IconButton onClick={() => removeWhitelistEntry(idx)}><DeleteIcon /></IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={addWhitelistEntry}>Add Entry</Button>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Section 5: Web UI TLS Certificate Strategy */}
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography fontWeight={600}>Web UI TLS Certificate</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>TLS Certificate Strategy</InputLabel>
|
||||||
|
<Select value={webTlsStrategy} label="TLS Certificate Strategy" onChange={(e) => setWebTlsStrategy(e.target.value)}>
|
||||||
|
<MenuItem value="internal_ca">Internal CA (auto-generated)</MenuItem>
|
||||||
|
<MenuItem value="operator_supplied">Operator-Supplied Certificate</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{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.'}
|
||||||
|
</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={!!success}
|
||||||
|
autoHideDuration={4000}
|
||||||
|
onClose={() => setSuccess(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert severity="success" onClose={() => setSuccess(null)}>{success}</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -192,4 +192,36 @@ export interface IssuedCert {
|
|||||||
|
|
||||||
// ── Reports (M9) ─────────────────────────────────────────────────────────────
|
// ── Reports (M9) ─────────────────────────────────────────────────────────────
|
||||||
export type ReportType = 'compliance' | 'patch-history' | 'vulnerability' | 'audit'
|
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'
|
export type ReportFormat = 'csv' | 'pdf'
|
||||||
|
|||||||
@ -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
|
### M10: Settings Page (Azure SSO, SMTP, TLS, IP Whitelist) + Frontend Page
|
||||||
**Goal:** All runtime configuration manageable from the UI.
|
**Goal:** All runtime configuration manageable from the UI.
|
||||||
|
|
||||||
- [ ] Implement `system_config` table CRUD API
|
- [x] Implement `system_config` table CRUD API
|
||||||
- [ ] Implement Azure SSO configuration: tenant ID, client ID/secret, redirect URI, scopes
|
- [x] 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)
|
- [x] 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
|
- [x] Implement SMTP configuration: host, port, auth mode, username/password, TLS mode, from-address
|
||||||
- [ ] Implement "Send Test Email" action for SMTP
|
- [x] Implement "Send Test Email" action for SMTP
|
||||||
- [ ] Implement polling interval tuning (health, patch) in Settings
|
- [x] Implement polling interval tuning (health, patch) in Settings
|
||||||
- [x] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied)
|
- [x] Implement Web UI TLS certificate strategy selection (internal CA vs. operator-supplied)
|
||||||
- [ ] Implement IP whitelist management in Settings
|
- [x] Implement IP whitelist management in Settings
|
||||||
- [ ] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE
|
- [x] Implement Azure SSO OAuth2/OIDC Authorization Code flow with PKCE
|
||||||
- [ ] Frontend: Settings page with all configuration sections and test actions
|
- [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
|
- [ ] Verify: Azure SSO test connection works, test email sends, TLS strategy switches, IP whitelist updates take effect
|
||||||
|
|
||||||
### M11: Email Notifications + Audit Logging Hardening
|
### M11: Email Notifications + Audit Logging Hardening
|
||||||
|
|||||||
Reference in New Issue
Block a user