Private
Public Access
1
0

Compare commits

...

4 Commits

Author SHA1 Message Date
bc5bfdedd9 fix: separate authz gate 403 tests from DB-dependent tests 2026-06-06 10:59:23 -05:00
53eef4eec4 test: add authz gate integration tests (closes #15) 2026-06-06 10:05:49 -05:00
dd6961265d chore: bump version to 0.2.4
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 51s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 19s
CI Pipeline / Build .deb & Release (push) Successful in 3m41s
CI Pipeline / Security Audit (push) Successful in 5s
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-06 00:04:08 -05:00
40ba483d35 fix: add ca_chain and crl_pem to EnrollmentStatusResponse
Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 23:57:23 -05:00
9 changed files with 877 additions and 304 deletions

82
Cargo.lock generated
View File

@ -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.2"
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.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2565,7 +2609,7 @@ dependencies = [
[[package]]
name = "pm-auth"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"argon2",
@ -2593,7 +2637,7 @@ dependencies = [
[[package]]
name = "pm-ca"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2617,7 +2661,7 @@ dependencies = [
[[package]]
name = "pm-core"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"aes-gcm",
"anyhow",
@ -2641,7 +2685,7 @@ dependencies = [
[[package]]
name = "pm-reports"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -2661,7 +2705,7 @@ dependencies = [
[[package]]
name = "pm-web"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"axum",
@ -2672,14 +2716,17 @@ dependencies = [
"dashmap 6.1.0",
"governor 0.6.3",
"hex",
"http-body-util",
"ipnet",
"jsonwebtoken",
"lettre",
"mockito",
"pm-auth",
"pm-ca",
"pm-core",
"pm-reports",
"rand 0.8.6",
"rcgen",
"reqwest",
"rustls",
"serde",
@ -2688,6 +2735,7 @@ dependencies = [
"sqlx",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"tower",
"tower-http",
@ -2702,7 +2750,7 @@ dependencies = [
[[package]]
name = "pm-worker"
version = "0.2.2"
version = "0.2.4"
dependencies = [
"anyhow",
"chrono",
@ -3082,6 +3130,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 +3581,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"

View File

@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.2.3"
version = "0.2.4"
edition = "2021"
authors = ["Echo <echo@moon-dragon.us>"]
license = "MIT"

View File

@ -178,8 +178,10 @@ pub enum EnrollmentStatusResponse {
Pending,
Approved {
ca_crt: String,
ca_chain: String,
server_crt: String,
server_key: String,
crl_pem: String,
},
Denied,
NotFound,

View File

@ -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,9 @@ url = { workspace = true }
urlencoding = "2"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
mockito = "1"
tempfile = "3"
rcgen = { workspace = true }
time = { workspace = true }

248
crates/pm-web/src/lib.rs Normal file
View File

@ -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<String> = 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<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// 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<DashMap<String, ApprovedEntry>>,
}
/// 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<AppState>) -> Result<Json<Value>, 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)
}
}

View File

@ -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<String> = 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<AppConfig>,
pub signing_key_pem: String,
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// In-memory store for SSO PKCE sessions (state → code_verifier).
pub sso_sessions: Arc<DashMap<String, SsoSession>>,
/// In-memory store for SSO handoff codes (single-use, 60s TTL).
/// See `tasks/sso-token-handoff-spec.md` §4.1.
pub sso_handoffs: Arc<DashMap<String, SsoHandoff>>,
/// Cached OIDC discovery document and JWKS for SSO id_token verification.
pub oidc_cache: Arc<Mutex<OidcCache>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
/// 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<DashMap<String, ApprovedEntry>>,
}
#[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<AppState>) -> Result<Json<Value>, 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)
}
}

View File

@ -111,8 +111,10 @@ async fn enroll_status(
}
return Ok(Json(EnrollmentStatusResponse::Approved {
ca_crt: entry.pki.ca_crt.clone(),
ca_chain: entry.pki.ca_chain.clone(),
server_crt: entry.pki.server_crt.clone(),
server_key: entry.pki.server_key.clone(),
crl_pem: entry.pki.crl_pem.clone(),
}));
}

View File

@ -0,0 +1,530 @@
//! 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.
//!
//! ## Test organization
//!
//! The 403 (forbidden_role) tests verify that the authorization middleware
//! rejects non-admin roles BEFORE any handler or database logic runs. These
//! tests use a lazy PgPool (no live database required) and pre-generated CA
//! files, so they always pass in CI.
//!
//! The 200 (admin allowed) tests verify the full handler path including audit
//! logging. They require a live PostgreSQL database and are marked `#[ignore]`
//! so they only run when `DATABASE_URL` is set and `--ignored` is passed.
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.
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)
}
/// Generate CA key and cert files on disk so `CertAuthority::init` can load
/// them without needing a database connection.
fn generate_ca_files(ca_dir: &std::path::Path) {
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, PKCS_ECDSA_P256_SHA256,
};
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("generate CA key");
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params
.distinguished_name
.push(DnType::CommonName, "Test Root CA");
let cert = params.self_signed(&key).expect("self-sign CA cert");
std::fs::create_dir_all(ca_dir).expect("create CA dir");
std::fs::write(ca_dir.join("ca.key"), key.serialize_pem()).expect("write ca.key");
std::fs::write(ca_dir.join("ca.crt"), cert.pem()).expect("write ca.crt");
}
/// Build a minimal `AppState` suitable for 403 authz gate tests.
///
/// Uses a lazy PgPool (no live database connection required) and pre-generated
/// CA files. This works because the authorization middleware rejects non-admin
/// requests BEFORE any handler or database logic runs.
async fn setup_state_no_db() -> AppState {
let pool = sqlx::postgres::PgPoolOptions::new()
.connect_lazy("postgres://test:test@localhost:5432/test")
.expect("failed to create lazy pool");
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();
generate_ca_files(&ca_dir_path);
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()),
}
}
/// 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) {
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 full `AppState` with a live database connection.
async fn setup_state(pool: PgPool) -> AppState {
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.
async fn send_request(
state: AppState,
method: axum::http::Method,
uri: &str,
auth_header: Option<&str>,
body: Option<serde_json::Value>,
) -> (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)
}
// ═══════════════════════════════════════════════════════════════════════════
// 403 Forbidden Role tests — no database required
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify that the authorization middleware rejects non-admin roles
// BEFORE any handler or database logic runs. They use a lazy PgPool and
// pre-generated CA files, so they always pass in CI.
/// 1. PUT /api/v1/settings with operator role → 403 forbidden_role
#[tokio::test]
async fn update_settings_operator_denied() {
let state = setup_state_no_db().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");
}
/// 3. PUT /api/v1/settings/ip-whitelist with operator role → 403 forbidden_role
#[tokio::test]
async fn update_ip_whitelist_operator_denied() {
let state = setup_state_no_db().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");
}
/// 5. POST /api/v1/settings/sso/discover with operator role → 403 forbidden_role
#[tokio::test]
async fn discover_oidc_operator_denied() {
let state = setup_state_no_db().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");
}
/// 7. POST /api/v1/settings/sso/test with operator role → 403 forbidden_role
#[tokio::test]
async fn test_oidc_operator_denied() {
let state = setup_state_no_db().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");
}
// ═══════════════════════════════════════════════════════════════════════════
// 200 Admin Allowed tests — require live database
// ═══════════════════════════════════════════════════════════════════════════
//
// These tests verify the full handler path including audit logging.
// They require a live PostgreSQL database and are marked `#[ignore]` so they
// only run when DATABASE_URL is set and `--ignored` is passed.
/// 2. PUT /api/v1/settings with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
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");
}
/// 4. PUT /api/v1/settings/ip-whitelist with admin role → 200 + audit log
#[sqlx::test(migrations = "../../migrations")]
#[ignore]
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"
);
}
/// 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")]
#[ignore]
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"
);
}
/// 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")]
#[ignore]
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"
);
}

View File

@ -0,0 +1 @@
mod authz_gate;