From 53eef4eec4ecf01d95951ee93b39886c04e334d1 Mon Sep 17 00:00:00 2001 From: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:05:49 -0500 Subject: [PATCH] test: add authz gate integration tests (closes #15) --- Cargo.lock | 80 +++- crates/pm-web/Cargo.toml | 7 + crates/pm-web/src/lib.rs | 248 ++++++++++ crates/pm-web/src/main.rs | 305 +----------- crates/pm-web/tests/integration/authz_gate.rs | 450 ++++++++++++++++++ crates/pm-web/tests/integration/main.rs | 1 + 6 files changed, 788 insertions(+), 303 deletions(-) create mode 100644 crates/pm-web/src/lib.rs create mode 100644 crates/pm-web/tests/integration/authz_gate.rs create mode 100644 crates/pm-web/tests/integration/main.rs diff --git a/Cargo.lock b/Cargo.lock index e99e8f2..d0372f8 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,16 @@ dependencies = [ "syn", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -475,6 +485,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2043,7 +2062,7 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "migrate-secrets" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "hex", @@ -2096,6 +2115,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2548,7 +2592,7 @@ dependencies = [ [[package]] name = "pm-agent-client" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2609,7 @@ dependencies = [ [[package]] name = "pm-auth" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "argon2", @@ -2593,7 +2637,7 @@ dependencies = [ [[package]] name = "pm-ca" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -2617,7 +2661,7 @@ dependencies = [ [[package]] name = "pm-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "aes-gcm", "anyhow", @@ -2641,7 +2685,7 @@ dependencies = [ [[package]] name = "pm-reports" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -2661,7 +2705,7 @@ dependencies = [ [[package]] name = "pm-web" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "axum", @@ -2672,9 +2716,11 @@ dependencies = [ "dashmap 6.1.0", "governor 0.6.3", "hex", + "http-body-util", "ipnet", "jsonwebtoken", "lettre", + "mockito", "pm-auth", "pm-ca", "pm-core", @@ -2702,7 +2748,7 @@ dependencies = [ [[package]] name = "pm-worker" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -3082,6 +3128,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -3521,6 +3579,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.4" diff --git a/crates/pm-web/Cargo.toml b/crates/pm-web/Cargo.toml index 1c28006..ff8f528 100644 --- a/crates/pm-web/Cargo.toml +++ b/crates/pm-web/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true authors.workspace = true license.workspace = true +[lib] +name = "pm_web" +path = "src/lib.rs" + [[bin]] name = "pm-web" path = "src/main.rs" @@ -46,4 +50,7 @@ url = { workspace = true } urlencoding = "2" [dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +mockito = "1" tempfile = "3" diff --git a/crates/pm-web/src/lib.rs b/crates/pm-web/src/lib.rs new file mode 100644 index 0000000..7d8b79f --- /dev/null +++ b/crates/pm-web/src/lib.rs @@ -0,0 +1,248 @@ +//! pm-web — Linux Patch Manager web server (library crate). +//! +//! Re-exports [`AppState`], [`build_router`], and [`health_handler`] so that +//! integration tests can construct a test application without depending on +//! the binary entry-point. + +pub mod routes; +pub mod secret_key; + +use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router}; +use dashmap::DashMap; +use pm_auth::{ + password::hash_password, + rbac::{require_auth, AuthConfig}, +}; +use pm_core::{config::AppConfig, models::ApprovedEntry, request_id::request_id_middleware}; +use rand::Rng; +use routes::sso::{OidcCache, SsoHandoff, SsoSession}; +use routes::ws::WsTicket; +use serde_json::{json, Value}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tower_governor::{ + governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer, +}; +use tower_http::{ + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; + +/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8). +/// Detecting this prefix means the admin password has not been bootstrapped yet. +const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA"; + +/// Bootstrap the default admin account with a random password. +/// +/// On first startup after a fresh install, the `users` table contains the seed +/// admin row with a clearly-invalid placeholder hash (cannot validate any password). +/// This function detects that placeholder, generates a cryptographically random +/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row. +/// +/// The plaintext password is printed **once** to stderr (visible in `systemctl status` +/// or `journalctl`) and is never stored on disk. +/// +/// If the admin row already has a real hash, this function is a no-op. +pub async fn bootstrap_admin_password(pool: &sqlx::PgPool) { + let result: Option = sqlx::query_scalar( + "SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'", + ) + .fetch_optional(pool) + .await + .unwrap_or(None); + + let current_hash = match result { + Some(h) => h, + None => return, + }; + + if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) { + return; + } + + let password: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(24) + .map(char::from) + .collect(); + + let new_hash = match hash_password(&password) { + Ok(h) => h, + Err(e) => { + tracing::error!(error = %e, "Failed to hash bootstrap admin password"); + return; + }, + }; + + let rows = sqlx::query( + r#"UPDATE users + SET password_hash = $1 + WHERE username = 'admin' + AND auth_provider = 'local' + AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#, + ) + .bind(&new_hash) + .execute(pool) + .await; + + match rows { + Ok(result) if result.rows_affected() == 1 => { + eprintln!(); + eprintln!("========================================"); + eprintln!(" INITIAL ADMIN PASSWORD (shown once)"); + eprintln!(" Username: admin"); + eprintln!(" Password: {}", password); + eprintln!(); + eprintln!(" You will be forced to change this on first login."); + eprintln!(" If lost, restart the service to generate a new one."); + eprintln!("========================================"); + eprintln!(); + tracing::info!("Bootstrap admin password generated and set"); + }, + Ok(_) => { + tracing::info!("Admin password already bootstrapped (concurrent or prior)"); + }, + Err(e) => { + tracing::error!(error = %e, "Failed to update admin password hash"); + }, + } +} + +/// Shared application state threaded through Axum. +#[derive(Clone)] +pub struct AppState { + pub db: sqlx::PgPool, + pub config: Arc, + pub signing_key_pem: String, + 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>, + /// In-memory store for SSO handoff codes (single-use, 60s TTL). + /// See `tasks/sso-token-handoff-spec.md` §4.1. + pub sso_handoffs: Arc>, + /// Cached OIDC discovery document and JWKS for SSO id_token verification. + pub oidc_cache: Arc>, + /// Internal certificate authority for mTLS client cert issuance. + pub ca: Arc, + /// Short-lived cache for approved enrollment PKI bundles. + /// + /// Entries are single-use (removed on retrieval) and expire after + /// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS). + pub approved_enrollments: Arc>, +} + +/// Construct the full Axum router. +pub fn build_router(state: AppState) -> Router { + let static_dir = state.config.server.static_dir.clone(); + let auth_config = state.auth_config.clone(); + let rl = &state.config.rate_limit; + + // Enrollment rate limiting: strict (5 req/min per IP, burst 3) + let enrollment_governor = Arc::new( + GovernorConfigBuilder::default() + .key_extractor(SmartIpKeyExtractor) + .per_millisecond(12_000) + .burst_size(rl.enrollment_burst) + .finish() + .expect("Invalid enrollment governor config"), + ); + + // Auth rate limiting: moderate (20 req/min per IP, burst 10) + let auth_governor = Arc::new( + GovernorConfigBuilder::default() + .key_extractor(SmartIpKeyExtractor) + .per_millisecond(3_000) + .burst_size(rl.auth_burst) + .finish() + .expect("Invalid auth governor config"), + ); + + // API rate limiting: normal (120 req/min per IP, burst 30) + let api_governor = Arc::new( + GovernorConfigBuilder::default() + .key_extractor(SmartIpKeyExtractor) + .per_millisecond(500) + .burst_size(rl.api_burst) + .finish() + .expect("Invalid API governor config"), + ); + + // Enrollment routes with strict per-IP rate limiting + let enrollment_router = + routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor)); + + // Public auth routes with moderate per-IP rate limiting + let auth_public_router = + routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor))); + + // SSO routes with moderate per-IP rate limiting + let sso_public_router = + routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor))); + let sso_azure_router = + routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor)); + + // All protected API routes — require valid JWT, with normal per-IP rate limiting + let protected_api = Router::new() + .nest("/auth", routes::auth::protected_router()) + .nest("/hosts", routes::hosts::router()) + .nest("/hosts", routes::ca::host_cert_router()) + .nest("/groups", routes::groups::router()) + .nest("/users", routes::users::router()) + .nest("/discovery", routes::discovery::router()) + .nest("/status", routes::status::router()) + .nest("/jobs", routes::jobs::router()) + .nest( + "/hosts/{host_id}/maintenance-windows", + routes::maintenance_windows::router(), + ) + .nest( + "/maintenance-windows", + routes::maintenance_windows::all_windows_router(), + ) + .nest("/ca", routes::ca::ca_router()) + .nest("/certificates", routes::ca::certs_router()) + .merge(routes::ws::ticket_router()) + .nest("/reports", routes::reports::router()) + .nest( + "/hosts/{host_id}/health-checks", + routes::health_checks::router(), + ) + .nest("/settings", routes::settings::router()) + .nest("/admin", routes::enrollment::admin_router()) + .layer(GovernorLayer::new(api_governor)) + .route_layer(middleware::from_fn(move |req, next| { + let auth_config = auth_config.clone(); + require_auth(auth_config, req, next) + })); + + Router::new() + .route("/status/health", get(health_handler)) + .nest("/api/v1/auth", auth_public_router) + .nest("/api/v1", enrollment_router) + .nest("/api/v1", routes::pki::router()) + .nest("/api/v1/auth/sso", sso_public_router) + .nest("/api/v1/auth/azure", sso_azure_router) + .nest("/api/v1", protected_api) + .merge(routes::ws::ws_router()) + .fallback_service( + ServeDir::new(&static_dir) + .append_index_html_on_directories(true) + .fallback(ServeFile::new(format!("{}/index.html", static_dir))), + ) + .layer(middleware::from_fn(request_id_middleware)) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +pub async fn health_handler(State(state): State) -> Result, StatusCode> { + let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok(); + let status = if db_ok { "healthy" } else { "degraded" }; + let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } }); + if db_ok { + Ok(Json(body)) + } else { + Err(StatusCode::SERVICE_UNAVAILABLE) + } +} diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 52916d3..a1cca0a 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -1,145 +1,13 @@ -//! pm-web — Linux Patch Manager web server. +//! pm-web — Linux Patch Manager web server (binary entry-point). -mod routes; - -mod secret_key; - -use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router}; -use axum_server::tls_rustls::RustlsConfig; use dashmap::DashMap; -use pm_auth::{ - jwt, - password::hash_password, - rbac::{require_auth, AuthConfig}, -}; -use pm_core::{ - config::AppConfig, db, logging, models::ApprovedEntry, request_id::request_id_middleware, -}; -use rand::Rng; -use routes::sso::{OidcCache, SsoHandoff, SsoSession}; -use routes::ws::WsTicket; -use serde_json::{json, Value}; +use pm_auth::{jwt, rbac::AuthConfig}; +use pm_core::{config::AppConfig, db, models::ApprovedEntry}; +use pm_web::routes::sso::{OidcCache, SsoHandoff, SsoSession}; +use pm_web::routes::ws::WsTicket; +use pm_web::{bootstrap_admin_password, build_router, AppState}; use std::{net::SocketAddr, sync::Arc, time::Duration}; use tokio::sync::Mutex; -use tower_governor::{ - governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer, -}; -use tower_http::{ - services::{ServeDir, ServeFile}, - trace::TraceLayer, -}; - -/// Placeholder Argon2id hash prefix used in the seed admin migration (issue #8). -/// Detecting this prefix means the admin password has not been bootstrapped yet. -const ADMIN_PLACEHOLDER_HASH_PREFIX: &str = "$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA"; - -/// Bootstrap the default admin account with a random password. -/// -/// On first startup after a fresh install, the `users` table contains the seed -/// admin row with a clearly-invalid placeholder hash (cannot validate any password). -/// This function detects that placeholder, generates a cryptographically random -/// 24-character password, hashes it with Argon2id, and UPDATEs the admin row. -/// -/// The plaintext password is printed **once** to stderr (visible in `systemctl status` -/// or `journalctl`) and is never stored on disk. -/// -/// If the admin row already has a real hash, this function is a no-op. -async fn bootstrap_admin_password(pool: &sqlx::PgPool) { - // Check if the admin account still has the placeholder hash. - let result: Option = sqlx::query_scalar( - "SELECT password_hash FROM users WHERE username = 'admin' AND auth_provider = 'local'", - ) - .fetch_optional(pool) - .await - .unwrap_or(None); - - let current_hash = match result { - Some(h) => h, - None => return, // No admin row — nothing to bootstrap. - }; - - if !current_hash.starts_with(ADMIN_PLACEHOLDER_HASH_PREFIX) { - // Admin already has a real password — nothing to do. - return; - } - - // Generate a 24-character random alphanumeric password. - let password: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(24) - .map(char::from) - .collect(); - - // Hash it with the application's Argon2id parameters. - let new_hash = match hash_password(&password) { - Ok(h) => h, - Err(e) => { - tracing::error!(error = %e, "Failed to hash bootstrap admin password"); - return; - }, - }; - - // Replace the placeholder hash with the real one. - // The WHERE clause matches the placeholder prefix to ensure idempotency. - let rows = sqlx::query( - r#"UPDATE users - SET password_hash = $1 - WHERE username = 'admin' - AND auth_provider = 'local' - AND password_hash LIKE '$argon2id$v=19$m=65536,t=3,p=1$AAAAAAAAAAAAAAAA%'"#, - ) - .bind(&new_hash) - .execute(pool) - .await; - - match rows { - Ok(result) if result.rows_affected() == 1 => { - eprintln!(); - eprintln!("========================================"); - eprintln!(" INITIAL ADMIN PASSWORD (shown once)"); - eprintln!(" Username: admin"); - eprintln!(" Password: {}", password); - eprintln!(); - eprintln!(" You will be forced to change this on first login."); - eprintln!(" If lost, restart the service to generate a new one."); - eprintln!("========================================"); - eprintln!(); - tracing::info!("Bootstrap admin password generated and set"); - }, - Ok(_) => { - // Rows affected != 1 — concurrent bootstrap or already replaced. - tracing::info!("Admin password already bootstrapped (concurrent or prior)"); - }, - Err(e) => { - tracing::error!(error = %e, "Failed to update admin password hash"); - }, - } -} - -/// Shared application state threaded through Axum. -#[derive(Clone)] -pub struct AppState { - pub db: sqlx::PgPool, - pub config: Arc, - pub signing_key_pem: String, - 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>, - /// In-memory store for SSO handoff codes (single-use, 60s TTL). - /// See `tasks/sso-token-handoff-spec.md` §4.1. - pub sso_handoffs: Arc>, - /// Cached OIDC discovery document and JWKS for SSO id_token verification. - pub oidc_cache: Arc>, - /// Internal certificate authority for mTLS client cert issuance. - pub ca: Arc, - /// Short-lived cache for approved enrollment PKI bundles. - /// - /// Entries are single-use (removed on retrieval) and expire after - /// [`ENROLLMENT_BUNDLE_TTL_SECS`](pm_core::models::ENROLLMENT_BUNDLE_TTL_SECS). - pub approved_enrollments: Arc>, -} #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -156,7 +24,7 @@ async fn main() -> anyhow::Result<()> { AppConfig::default() }); - logging::init(&config.logging); + pm_core::logging::init(&config.logging); tracing::info!( version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting" @@ -183,12 +51,10 @@ async fn main() -> anyhow::Result<()> { let pool = db::init_pool(&config.database).await?; db::run_migrations(&pool).await?; - // Bootstrap admin password if the seed admin still has the placeholder hash (issue #8). + // Bootstrap admin password if the seed admin still has the placeholder hash. bootstrap_admin_password(&pool).await; // Initialise the internal CA using the configured certificate paths. - // The CA certificate and key must exist at the configured locations and be - // unencrypted PEM. If absent, a new CA is generated in that directory. let ca_base = std::path::Path::new(&config.security.ca_cert_path) .parent() .expect("CA certificate path must have a parent directory"); @@ -223,7 +89,7 @@ async fn main() -> anyhow::Result<()> { }); } - // Background task: purge expired SSO sessions every 60 seconds (sessions older than 10 minutes). + // Background task: purge expired SSO sessions every 60 seconds. { let sessions = sso_sessions.clone(); tokio::spawn(async move { @@ -243,8 +109,6 @@ async fn main() -> anyhow::Result<()> { } // Background task: purge expired approved enrollment PKI bundles. - // Entries are also removed on first retrieval (single-use), so this - // task only cleans up bundles that were never picked up by the agent. { let approved = approved_enrollments.clone(); tokio::spawn(async move { @@ -262,9 +126,6 @@ async fn main() -> anyhow::Result<()> { } // Background task: purge expired SSO handoff codes every 60 seconds. - // See `tasks/sso-token-handoff-spec.md` §4.3. Handoffs are also - // atomically removed on exchange (single-use), so this task only - // cleans up codes that the SPA never POSTed back for. { let handoffs = sso_handoffs.clone(); tokio::spawn(async move { @@ -306,7 +167,7 @@ async fn main() -> anyhow::Result<()> { let tls_key = std::path::Path::new(&config.security.web_tls_key_path); if tls_cert.exists() && tls_key.exists() { - let tls_config = RustlsConfig::from_pem_file( + let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( &config.security.web_tls_cert_path, &config.security.web_tls_key_path, ) @@ -338,149 +199,3 @@ async fn main() -> anyhow::Result<()> { Ok(()) } - -/// Construct the full Axum router. -pub fn build_router(state: AppState) -> Router { - let static_dir = state.config.server.static_dir.clone(); - let auth_config = state.auth_config.clone(); - let rl = &state.config.rate_limit; - - // Enrollment rate limiting: strict (5 req/min per IP, burst 3) - // Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy. - // governor quota: 1 request per 12_000ms = ~5/min sustained - let enrollment_governor = Arc::new( - GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) - .per_millisecond(12_000) - .burst_size(rl.enrollment_burst) - .finish() - .expect("Invalid enrollment governor config"), - ); - - // Auth rate limiting: moderate (20 req/min per IP, burst 10) - // Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy. - // governor quota: 1 request per 3_000ms = ~20/min sustained - let auth_governor = Arc::new( - GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) - .per_millisecond(3_000) - .burst_size(rl.auth_burst) - .finish() - .expect("Invalid auth governor config"), - ); - - // API rate limiting: normal (120 req/min per IP, burst 30) - // Uses SmartIpKeyExtractor to respect X-Forwarded-For behind reverse proxy. - // governor quota: 1 request per 500ms = ~120/min sustained - let api_governor = Arc::new( - GovernorConfigBuilder::default() - .key_extractor(SmartIpKeyExtractor) - .per_millisecond(500) - .burst_size(rl.api_burst) - .finish() - .expect("Invalid API governor config"), - ); - - // Enrollment routes with strict per-IP rate limiting - let enrollment_router = - routes::enrollment::router().layer(GovernorLayer::new(enrollment_governor)); - - // Public auth routes with moderate per-IP rate limiting - let auth_public_router = - routes::auth::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor))); - - // SSO routes with moderate per-IP rate limiting - let sso_public_router = - routes::sso::public_router().layer(GovernorLayer::new(Arc::clone(&auth_governor))); - let sso_azure_router = - routes::sso::azure_compat_router().layer(GovernorLayer::new(auth_governor)); - - // All protected API routes — require valid JWT, with normal per-IP rate limiting - let protected_api = Router::new() - // Auth: MFA setup/verify - // Auth: MFA setup/verify/disable (nested under /auth so paths are /api/v1/auth/mfa/*) - .nest("/auth", routes::auth::protected_router()) - // Hosts - .nest("/hosts", routes::hosts::router()) - // Host-scoped certificate endpoints (merged separately to avoid conflict) - .nest("/hosts", routes::ca::host_cert_router()) - // Groups - .nest("/groups", routes::groups::router()) - // Users - .nest("/users", routes::users::router()) - // Discovery - .nest("/discovery", routes::discovery::router()) - // Fleet status - .nest("/status", routes::status::router()) - // Patch jobs - .nest("/jobs", routes::jobs::router()) - // Maintenance windows (nested under hosts path param) - .nest( - "/hosts/{host_id}/maintenance-windows", - routes::maintenance_windows::router(), - ) - // Maintenance windows — bulk list-all endpoint - .nest( - "/maintenance-windows", - routes::maintenance_windows::all_windows_router(), - ) - // CA root certificate download - .nest("/ca", routes::ca::ca_router()) - // Certificate list / renew / revoke - .nest("/certificates", routes::ca::certs_router()) - // WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade) - .merge(routes::ws::ticket_router()) - // Reports - .nest("/reports", routes::reports::router()) - .nest( - "/hosts/{host_id}/health-checks", - routes::health_checks::router(), - ) - // Settings (admin-only) - .nest("/settings", routes::settings::router()) - // Admin enrollment routes (JWT protected, Admin role enforced) - .nest("/admin", routes::enrollment::admin_router()) - // Apply rate limiting then auth middleware - .layer(GovernorLayer::new(api_governor)) - .route_layer(middleware::from_fn(move |req, next| { - let auth_config = auth_config.clone(); - require_auth(auth_config, req, next) - })); - - Router::new() - .route("/status/health", get(health_handler)) - // Public auth routes (rate-limited, no JWT) - .nest("/api/v1/auth", auth_public_router) - // Public enrollment endpoints (rate-limited, no JWT) - .nest("/api/v1", enrollment_router) - // Public PKI endpoints (CRL distribution, no JWT — CRLs are self-authenticating) - .nest("/api/v1", routes::pki::router()) - // Public SSO routes (rate-limited, no JWT) - .nest("/api/v1/auth/sso", sso_public_router) - // Public Azure SSO routes (rate-limited, no JWT) - .nest("/api/v1/auth/azure", sso_azure_router) - // Protected API routes (JWT required, rate-limited) - .nest("/api/v1", protected_api) - // WebSocket browser endpoint — ticket-authenticated, outside JWT middleware - .merge(routes::ws::ws_router()) - // Serve React SPA - .fallback_service( - ServeDir::new(&static_dir) - .append_index_html_on_directories(true) - .fallback(ServeFile::new(format!("{}/index.html", static_dir))), - ) - .layer(middleware::from_fn(request_id_middleware)) - .layer(TraceLayer::new_for_http()) - .with_state(state) -} - -async fn health_handler(State(state): State) -> Result, StatusCode> { - let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok(); - let status = if db_ok { "healthy" } else { "degraded" }; - let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } }); - if db_ok { - Ok(Json(body)) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } -} diff --git a/crates/pm-web/tests/integration/authz_gate.rs b/crates/pm-web/tests/integration/authz_gate.rs new file mode 100644 index 0000000..7b8f4ff --- /dev/null +++ b/crates/pm-web/tests/integration/authz_gate.rs @@ -0,0 +1,450 @@ +//! Integration tests for the authz gate that restricts auth config mutations +//! (OIDC, SMTP, IP whitelist) to the Admin role only. +//! +//! See Issue #15 for the full specification. + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{Request, StatusCode}; +use dashmap::DashMap; +use http_body_util::BodyExt; +use pm_auth::jwt; +use pm_auth::rbac::AuthConfig; +use pm_core::config::AppConfig; +use pm_web::routes::sso::OidcCache; +use pm_web::{build_router, AppState}; +use serde_json::json; +use sqlx::PgPool; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::Mutex; +use tower::ServiceExt; +use uuid::Uuid; + +// ── Ed25519 test key pair ──────────────────────────────────────────────────── +const TEST_SIGNING_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIBrWiMMcgpPXwtGDSSBl01fcQyb5Vh4CMzEmxcSXvcrJ +-----END PRIVATE KEY----- +"; + +const TEST_VERIFY_KEY: &str = "-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEACgE6fMDCcG11NOpPKSO/ASpPUSntB7XsF5sBFBYDjFo= +-----END PUBLIC KEY----- +"; + +// ── Fixed test user IDs (so we can seed matching rows in the DB) ───────────── +const ADMIN_USER_ID: &str = "00000000-0000-4000-8000-000000000001"; +const OPERATOR_USER_ID: &str = "00000000-0000-4000-8000-000000000002"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Generate a valid JWT authorization header for the given role. +/// Uses fixed user IDs so that matching rows exist in the `users` table +/// (required for the audit_log foreign-key constraint on `actor_user_id`). +fn auth_header(role: &str) -> String { + let user_id = match role { + "admin" => Uuid::parse_str(ADMIN_USER_ID).unwrap(), + _ => Uuid::parse_str(OPERATOR_USER_ID).unwrap(), + }; + let username = format!("test-{}", role); + let token = jwt::issue_access_token(user_id, &username, role, 900, TEST_SIGNING_KEY) + .expect("failed to issue test JWT"); + format!("Bearer {}", token) +} + +/// Seed test users into the database so that audit_log foreign-key +/// constraints on `actor_user_id` are satisfied. +async fn seed_test_users(pool: &PgPool) { + // Use Argon2id placeholder hash — the actual hash doesn't need to be valid + // for these tests; we just need the rows to exist for FK constraints. + let placeholder_hash = "$argon2id$v=19$m=65536,t=3,p=1$placeholder$placeholder"; + for (user_id, username, role) in [ + (ADMIN_USER_ID, "test-admin", "admin"), + (OPERATOR_USER_ID, "test-operator", "operator"), + ] { + sqlx::query( + r#"INSERT INTO users (id, username, display_name, email, role, auth_provider, password_hash) + VALUES ($1, $2, $3, $4, $5::user_role, 'local', $6) + ON CONFLICT (id) DO NOTHING"#, + ) + .bind(Uuid::parse_str(user_id).unwrap()) + .bind(username) + .bind(username) + .bind(format!("{}@test.example.com", username)) + .bind(role) + .bind(placeholder_hash) + .execute(pool) + .await + .expect("failed to seed test user"); + } +} + +/// Build a minimal `AppState` suitable for integration tests. +async fn setup_state(pool: PgPool) -> AppState { + // Seed test users so audit_log FK constraints are satisfied. + seed_test_users(&pool).await; + + let mut config = AppConfig::default(); + config.server.static_dir = "/tmp".to_string(); + + let auth_config = Arc::new(AuthConfig::new(TEST_VERIFY_KEY.to_string(), &[], &[])); + + let ca_dir = tempfile::tempdir().expect("failed to create temp dir for CA"); + let ca_dir_path = ca_dir.path().to_path_buf(); + std::mem::forget(ca_dir); + + let ca = pm_ca::CertAuthority::init(&ca_dir_path, &pool) + .await + .expect("CA init failed"); + + AppState { + db: pool, + config: Arc::new(config), + signing_key_pem: TEST_SIGNING_KEY.to_string(), + auth_config, + ws_tickets: Arc::new(DashMap::new()), + sso_sessions: Arc::new(DashMap::new()), + sso_handoffs: Arc::new(DashMap::new()), + oidc_cache: Arc::new(Mutex::new(OidcCache::default())), + ca: Arc::new(ca), + approved_enrollments: Arc::new(DashMap::new()), + } +} + +/// Send a request through the full Axum router and return the response. +/// +/// Inserts `ConnectInfo` so the rate limiter and IP-allowlist +/// middleware can resolve the client IP. +async fn send_request( + state: AppState, + method: axum::http::Method, + uri: &str, + auth_header: Option<&str>, + body: Option, +) -> (StatusCode, serde_json::Value) { + let router = build_router(state); + let mut builder = Request::builder().method(method).uri(uri); + if let Some(auth) = auth_header { + builder = builder.header("authorization", auth); + } + builder = builder.header("content-type", "application/json"); + + let req = if let Some(b) = body { + builder.body(Body::from(b.to_string())).unwrap() + } else { + builder.body(Body::empty()).unwrap() + }; + + // Insert ConnectInfo so tower_governor's SmartIpKeyExtractor can resolve the client IP. + let (mut parts, body) = req.into_parts(); + parts + .extensions + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + let req = Request::from_parts(parts, body); + + let resp = router.oneshot(req).await.unwrap(); + let status = resp.status(); + let body_bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body_json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap_or_else(|_| { + let raw = String::from_utf8_lossy(&body_bytes); + json!({ "_raw": raw.to_string() }) + }); + (status, body_json) +} + +// ── Integration tests ─────────────────────────────────────────────────────── + +/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role +#[sqlx::test(migrations = "../../migrations")] +async fn update_settings_operator_denied(pool: PgPool) { + let state = setup_state(pool).await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::PUT, + "/api/v1/settings", + Some(&auth), + Some(json!({ "polling": { "health_poll_interval_secs": 300 } })), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 2. PUT /api/v1/settings with admin role → 200 + audit log +#[sqlx::test(migrations = "../../migrations")] +async fn update_settings_admin_allowed(pool: PgPool) { + let state = setup_state(pool).await; + let pool = state.db.clone(); + let auth = auth_header("admin"); + + let (status, body) = send_request( + state, + axum::http::Method::PUT, + "/api/v1/settings", + Some(&auth), + Some(json!({ "polling": { "health_poll_interval_secs": 300 } })), + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "expected 200, got {}: {:?}", + status, + body + ); + + let row: Option<(String,)> = sqlx::query_as( + "SELECT action::text FROM audit_log WHERE action::text = 'config_changed' ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&pool) + .await + .expect("audit log query failed"); + assert!(row.is_some(), "expected audit log entry for config_changed"); +} + +/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role +#[sqlx::test(migrations = "../../migrations")] +async fn update_ip_whitelist_operator_denied(pool: PgPool) { + let state = setup_state(pool).await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::PUT, + "/api/v1/settings/ip-whitelist", + Some(&auth), + Some(json!({ "entries": ["10.0.0.0/8"] })), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log +#[sqlx::test(migrations = "../../migrations")] +async fn update_ip_whitelist_admin_allowed(pool: PgPool) { + let state = setup_state(pool).await; + let pool = state.db.clone(); + let auth = auth_header("admin"); + + let (status, body) = send_request( + state, + axum::http::Method::PUT, + "/api/v1/settings/ip-whitelist", + Some(&auth), + Some(json!({ "entries": ["10.0.0.0/8"] })), + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "expected 200, got {}: {:?}", + status, + body + ); + + let row: Option<(String,)> = sqlx::query_as( + "SELECT action::text FROM audit_log WHERE action::text = 'ip_whitelist_updated' ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&pool) + .await + .expect("audit log query failed"); + assert!( + row.is_some(), + "expected audit log entry for ip_whitelist_updated" + ); +} + +/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role +#[sqlx::test(migrations = "../../migrations")] +async fn discover_oidc_operator_denied(pool: PgPool) { + let state = setup_state(pool).await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/discover", + Some(&auth), + Some(json!({ "discovery_url": "https://example.com/.well-known/openid-configuration" })), + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 6. POST /api/v1/settings/sso/discover with admin role → 200 + audit log +/// Uses mockito to simulate an OIDC discovery endpoint. +#[sqlx::test(migrations = "../../migrations")] +async fn discover_oidc_admin_allowed(pool: PgPool) { + let state = setup_state(pool).await; + let pool = state.db.clone(); + let auth = auth_header("admin"); + + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "issuer": "https://mock-oidc.example.com", + "authorization_endpoint": "https://mock-oidc.example.com/auth", + "token_endpoint": "https://mock-oidc.example.com/token", + "jwks_uri": "https://mock-oidc.example.com/jwks", + "userinfo_endpoint": "https://mock-oidc.example.com/userinfo" + }) + .to_string(), + ) + .create_async() + .await; + + let discovery_url = format!("{}/.well-known/openid-configuration", server.url()); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/discover", + Some(&auth), + Some(json!({ "discovery_url": discovery_url })), + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "expected 200, got {}: {:?}", + status, + body + ); + assert_eq!(body["success"], true); + + mock.assert_async().await; + + let row: Option<(String,)> = sqlx::query_as( + "SELECT action::text FROM audit_log WHERE action::text = 'oidc_discover_performed' ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&pool) + .await + .expect("audit log query failed"); + assert!( + row.is_some(), + "expected audit log entry for oidc_discover_performed" + ); +} + +/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role +#[sqlx::test(migrations = "../../migrations")] +async fn test_oidc_operator_denied(pool: PgPool) { + let state = setup_state(pool).await; + let auth = auth_header("operator"); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/test", + Some(&auth), + None, + ) + .await; + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "expected 403, got {}: {:?}", + status, + body + ); + assert_eq!(body["error"]["code"], "forbidden_role"); +} + +/// 8. POST /api/v1/settings/sso/test with admin role → 200 + audit log +/// Uses mockito to simulate an OIDC discovery endpoint. +#[sqlx::test(migrations = "../../migrations")] +async fn test_oidc_admin_allowed(pool: PgPool) { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/.well-known/openid-configuration") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!({ + "issuer": "https://mock-oidc.example.com", + "authorization_endpoint": "https://mock-oidc.example.com/auth", + "token_endpoint": "https://mock-oidc.example.com/token", + "jwks_uri": "https://mock-oidc.example.com/jwks" + }) + .to_string(), + ) + .create_async() + .await; + + let discovery_url = format!("{}/.well-known/openid-configuration", server.url()); + + // Seed the oidc_config table with an enabled provider pointing to mockito. + sqlx::query("UPDATE oidc_config SET enabled = true, discovery_url = $1 WHERE id = 1") + .bind(&discovery_url) + .execute(&pool) + .await + .expect("failed to seed oidc_config"); + + let state = setup_state(pool).await; + let pool = state.db.clone(); + let auth = auth_header("admin"); + + let (status, body) = send_request( + state, + axum::http::Method::POST, + "/api/v1/settings/sso/test", + Some(&auth), + None, + ) + .await; + + assert_eq!( + status, + StatusCode::OK, + "expected 200, got {}: {:?}", + status, + body + ); + assert_eq!(body["success"], true); + + mock.assert_async().await; + + let row: Option<(String,)> = sqlx::query_as( + "SELECT action::text FROM audit_log WHERE action::text = 'oidc_test_performed' ORDER BY created_at DESC LIMIT 1", + ) + .fetch_optional(&pool) + .await + .expect("audit log query failed"); + assert!( + row.is_some(), + "expected audit log entry for oidc_test_performed" + ); +} diff --git a/crates/pm-web/tests/integration/main.rs b/crates/pm-web/tests/integration/main.rs new file mode 100644 index 0000000..ae431f9 --- /dev/null +++ b/crates/pm-web/tests/integration/main.rs @@ -0,0 +1 @@ +mod authz_gate;