Private
Public Access
1
0

fix: eslint-disable for useEffect deps in UsersPage
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 5s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m2s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Has been skipped

This commit is contained in:
2026-05-07 19:14:21 +00:00
parent cc1214a963
commit 08add28b80
17 changed files with 359 additions and 76 deletions

View File

@ -19,7 +19,7 @@ pub mod session;
// Commonly re-exported types
pub use jwt::{AccessClaims, JwtError};
pub use password::{hash_password, verify_password, PasswordError};
pub use password::validate_password_strength;
pub use password::{hash_password, verify_password, PasswordError};
pub use rbac::{AuthConfig, AuthUser, UserRole};
pub use session::{LoginRequest, LoginResponse, SessionError, SessionUser};

View File

@ -90,7 +90,10 @@ pub fn validate_password_strength(password: &str) -> Result<(), String> {
}
let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?";
if !password.chars().any(|c| special_chars.contains(c)) {
return Err("Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)".to_string());
return Err(
"Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)"
.to_string(),
);
}
Ok(())
}

View File

@ -141,10 +141,12 @@ pub async fn login(
return Err(SessionError::AccountLocked);
}
// Lockout period has expired — reset counters
sqlx::query("UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1")
.bind(user.id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE id = $1",
)
.bind(user.id)
.execute(pool)
.await?;
}
// 2. Verify password
@ -156,12 +158,14 @@ pub async fn login(
let new_attempts = user.failed_login_attempts + 1;
if new_attempts >= 5 {
let lock_until = Utc::now() + chrono::Duration::minutes(30);
sqlx::query("UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3")
.bind(new_attempts)
.bind(lock_until)
.bind(user.id)
.execute(pool)
.await?;
sqlx::query(
"UPDATE users SET failed_login_attempts = $1, locked_until = $2 WHERE id = $3",
)
.bind(new_attempts)
.bind(lock_until)
.bind(user.id)
.execute(pool)
.await?;
tracing::warn!(username = %req.username, "Account locked after {} failed attempts", new_attempts);
} else {
sqlx::query("UPDATE users SET failed_login_attempts = $1 WHERE id = $2")

View File

@ -12,12 +12,11 @@ pub use config::AppConfig;
pub use crypto::{decrypt, encrypt, load_or_create_key, CryptoError, KEY_PATH};
pub use error::{AppError, ErrorResponse};
pub use models::{
AuthProvider, CreateGroupRequest, CreateHealthCheckRequest, CreateHostRequest,
ChangePasswordRequest, AdminResetPasswordRequest, CreateUserRequest,
DiscoveryCidrRequest, DiscoveryResult, Group, HealthCheck,
HealthCheckResult, HealthCheckWithResult, Host, HostHealthStatus, HostSummary,
RegisterDiscoveredRequest, UpdateGroupRequest, UpdateHealthCheckRequest, UpdateUserRequest,
User, UserRole as DbUserRole,
AdminResetPasswordRequest, AuthProvider, ChangePasswordRequest, CreateGroupRequest,
CreateHealthCheckRequest, CreateHostRequest, CreateUserRequest, DiscoveryCidrRequest,
DiscoveryResult, Group, HealthCheck, HealthCheckResult, HealthCheckWithResult, Host,
HostHealthStatus, HostSummary, RegisterDiscoveredRequest, UpdateGroupRequest,
UpdateHealthCheckRequest, UpdateUserRequest, User, UserRole as DbUserRole,
};
// Re-export audit integrity types

View File

@ -457,6 +457,7 @@ pub struct MaintenanceWindow {
/// Day-of-week (0=Sun, weekly) or day-of-month (1-31, monthly); NULL for once/daily.
pub recurrence_day: Option<i32>,
pub enabled: bool,
pub auto_apply: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -474,6 +475,8 @@ pub struct CreateMaintenanceWindowRequest {
pub recurrence_day: Option<i32>,
/// Whether the window is active (default true).
pub enabled: Option<bool>,
/// Whether to auto-create a patch_apply job when this window opens and patches are pending (default true).
pub auto_apply: Option<bool>,
}
/// Payload for `PUT /api/v1/hosts/{id}/maintenance-windows/{window_id}`.
@ -485,4 +488,5 @@ pub struct UpdateMaintenanceWindowRequest {
pub duration_minutes: Option<i32>,
pub recurrence_day: Option<i32>,
pub enabled: Option<bool>,
pub auto_apply: Option<bool>,
}

View File

@ -14,7 +14,10 @@ use routes::azure_sso::SsoSession;
use routes::ws::WsTicket;
use serde_json::{json, Value};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use tower_http::{services::{ServeDir, ServeFile}, trace::TraceLayer};
use tower_http::{
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
/// Shared application state threaded through Axum.
#[derive(Clone)]

View File

@ -13,15 +13,15 @@ use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::Json,
routing::{get, post},
routing::delete,
routing::{get, post},
Router,
};
use pm_auth::{
mfa_totp,
hash_password, mfa_totp,
rbac::AuthUser,
session::{self, LoginRequest, LoginResponse},
verify_password, hash_password, validate_password_strength,
validate_password_strength, verify_password,
};
use serde::Deserialize;
use serde_json::{json, Value};
@ -39,7 +39,10 @@ pub fn public_router() -> Router<AppState> {
.route("/login", post(login_handler))
.route("/refresh", post(refresh_handler))
.route("/logout", post(logout_handler))
.route("/force-change-password", post(force_change_password_handler))
.route(
"/force-change-password",
post(force_change_password_handler),
)
}
// ============================================================
@ -265,9 +268,11 @@ async fn force_change_password_handler(
None => {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
Json(
json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }),
),
));
}
},
};
// Verify current password
@ -277,7 +282,9 @@ async fn force_change_password_handler(
if !valid {
return Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } })),
Json(
json!({ "error": { "code": "invalid_credentials", "message": "Invalid username or password" } }),
),
));
}
@ -385,20 +392,18 @@ async fn disable_mfa(
Json(req): Json<DisableMfaRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Verify current password to confirm identity
let hash: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE id = $1",
)
.bind(auth_user.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch password hash");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?
.flatten();
let hash: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
.bind(auth_user.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch password hash");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?
.flatten();
let hash_str = hash.unwrap_or_default();
let valid = verify_password(&req.password, &hash_str).unwrap_or(false);
@ -406,7 +411,9 @@ async fn disable_mfa(
if !valid {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })),
Json(
json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }),
),
));
}

View File

@ -74,7 +74,7 @@ async fn list_windows(
let windows: Vec<MaintenanceWindow> = sqlx::query_as(
r#"
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
recurrence_day, enabled, created_at, updated_at
recurrence_day, enabled, auto_apply, created_at, updated_at
FROM maintenance_windows
WHERE host_id = $1
ORDER BY created_at ASC
@ -151,15 +151,16 @@ async fn create_window(
let duration = req.duration_minutes.unwrap_or(60);
let enabled = req.enabled.unwrap_or(true);
let auto_apply = req.auto_apply.unwrap_or(true);
let window: MaintenanceWindow = sqlx::query_as(
r#"
INSERT INTO maintenance_windows
(host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled)
(host_id, label, recurrence, start_at, duration_minutes, recurrence_day, enabled, auto_apply)
VALUES
($1, $2, $3, $4, $5, $6, $7)
($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
recurrence_day, enabled, created_at, updated_at
recurrence_day, enabled, auto_apply, created_at, updated_at
"#,
)
.bind(host_id)
@ -169,6 +170,7 @@ async fn create_window(
.bind(duration)
.bind(req.recurrence_day)
.bind(enabled)
.bind(auto_apply)
.fetch_one(&state.db)
.await
.map_err(|e| {
@ -220,7 +222,7 @@ async fn update_window(
let existing: Option<MaintenanceWindow> = sqlx::query_as(
r#"
SELECT id, host_id, label, recurrence, start_at, duration_minutes,
recurrence_day, enabled, created_at, updated_at
recurrence_day, enabled, auto_apply, created_at, updated_at
FROM maintenance_windows
WHERE id = $1 AND host_id = $2
"#,
@ -253,6 +255,7 @@ async fn update_window(
let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes);
let new_rec_day = req.recurrence_day.or(existing.recurrence_day);
let new_enabled = req.enabled.unwrap_or(existing.enabled);
let new_auto_apply = req.auto_apply.unwrap_or(existing.auto_apply);
// Validate recurrence_day for the final recurrence type.
if new_recurrence == pm_core::models::WindowRecurrence::Weekly {
@ -289,10 +292,11 @@ async fn update_window(
duration_minutes = $6,
recurrence_day = $7,
enabled = $8,
auto_apply = $9,
updated_at = NOW()
WHERE id = $1 AND host_id = $2
RETURNING id, host_id, label, recurrence, start_at, duration_minutes,
recurrence_day, enabled, created_at, updated_at
recurrence_day, enabled, auto_apply, created_at, updated_at
"#,
)
.bind(win_id)
@ -303,6 +307,7 @@ async fn update_window(
.bind(new_duration)
.bind(new_rec_day)
.bind(new_enabled)
.bind(new_auto_apply)
.fetch_one(&state.db)
.await
.map_err(|e| {

View File

@ -15,11 +15,14 @@ use axum::{
routing::{delete, get, post, put},
Router,
};
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
use pm_auth::validate_password_strength;
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout, verify_password};
use pm_core::{
audit::{log_event, AuditAction},
models::{AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest, User},
models::{
AdminResetPasswordRequest, ChangePasswordRequest, CreateUserRequest, UpdateUserRequest,
User,
},
};
use serde_json::{json, Value};
use uuid::Uuid;
@ -203,7 +206,9 @@ async fn update_user(
));
}
// Only admins can change role or active status
if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some()) && !auth.role.is_admin() {
if (req.role.is_some() || req.is_active.is_some() || req.force_password_reset.is_some())
&& !auth.role.is_admin()
{
return Err((
StatusCode::FORBIDDEN,
Json(
@ -355,20 +360,18 @@ async fn change_own_password(
Json(req): Json<ChangePasswordRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// Fetch current password hash
let hash: Option<String> = sqlx::query_scalar(
"SELECT password_hash FROM users WHERE id = $1",
)
.bind(auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch password hash");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?
.flatten();
let hash: Option<String> = sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to fetch password hash");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
})?
.flatten();
let hash_str = hash.unwrap_or_default();
let valid = verify_password(&req.current_password, &hash_str).unwrap_or(false);
@ -376,7 +379,9 @@ async fn change_own_password(
if !valid {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } })),
Json(
json!({ "error": { "code": "invalid_password", "message": "Current password is incorrect" } }),
),
));
}

View File

@ -1,8 +1,13 @@
//! Maintenance window scheduler.
//!
//! Polls every 60 seconds and, for each enabled maintenance window that is
//! currently open, dispatches any queued non-immediate patch jobs associated
//! with the window's host.
//! Polls every 60 seconds and performs two tasks:
//!
//! 1. **Auto-apply**: For each enabled maintenance window with `auto_apply = true`
//! that is currently open, if the host has pending patches and no existing
//! patch_apply job queued/running for that window, automatically creates one.
//!
//! 2. **Dispatch**: For each open window, dispatch any queued non-immediate
//! patch jobs associated with the window's host.
//!
//! A window is considered "open" when:
//! - `once` — `start_at <= NOW() < start_at + duration_minutes * '1 minute'`
@ -33,6 +38,23 @@ struct QueuedJobId {
job_id: Uuid,
}
#[derive(Debug, FromRow)]
struct AutoApplyWindow {
window_id: Uuid,
host_id: Uuid,
}
#[derive(Debug, FromRow)]
struct PendingPatchHost {
host_id: Uuid,
patch_count: i32,
}
#[derive(Debug, FromRow)]
struct InsertedJobId {
job_id: Uuid,
}
// ─────────────────────────────────────────────────────────────────────────────
// Public entry point
// ─────────────────────────────────────────────────────────────────────────────
@ -49,12 +71,206 @@ pub async fn run_maintenance_scheduler(pool: PgPool, config: Arc<AppConfig>) {
loop {
ticker.tick().await;
tracing::debug!("Maintenance scheduler: checking open windows");
// Step 1: Auto-create patch_apply jobs for windows with auto_apply=true
auto_create_patch_jobs(pool.clone(), config.clone()).await;
// Step 2: Dispatch any queued non-immediate jobs for open windows
dispatch_open_window_jobs(pool.clone(), config.clone()).await;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Core dispatch logic
// Step 1: Auto-create patch_apply jobs
// ─────────────────────────────────────────────────────────────────────────────
/// For each enabled maintenance window that is currently open AND has
/// `auto_apply = true`, check if the host has pending patches and no
/// existing patch_apply job for this window cycle. If so, create one.
async fn auto_create_patch_jobs(pool: PgPool, _config: Arc<AppConfig>) {
// Find all open windows with auto_apply=true
let auto_windows: Vec<AutoApplyWindow> = match sqlx::query_as(
r#"
SELECT mw.id AS window_id, mw.host_id
FROM maintenance_windows mw
WHERE mw.enabled = TRUE
AND mw.auto_apply = TRUE
AND (
( mw.recurrence = 'once'
AND mw.start_at <= NOW()
AND NOW() < mw.start_at + (mw.duration_minutes * INTERVAL '1 minute')
)
OR
( mw.recurrence = 'daily'
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
+ (mw.duration_minutes * INTERVAL '1 minute'))
)
OR
( mw.recurrence = 'weekly'
AND EXTRACT(DOW FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
+ (mw.duration_minutes * INTERVAL '1 minute'))
)
OR
( mw.recurrence = 'monthly'
AND EXTRACT(DAY FROM NOW() AT TIME ZONE 'UTC') = mw.recurrence_day
AND (NOW() AT TIME ZONE 'UTC')::time >= (mw.start_at AT TIME ZONE 'UTC')::time
AND (NOW() AT TIME ZONE 'UTC')::time < ((mw.start_at AT TIME ZONE 'UTC')::time
+ (mw.duration_minutes * INTERVAL '1 minute'))
)
)
"#,
)
.fetch_all(&pool)
.await
{
Ok(w) => w,
Err(e) => {
tracing::error!(error = %e, "auto_create_patch_jobs: open-windows query failed");
return;
}
};
if auto_windows.is_empty() {
tracing::debug!("auto_create: no open auto-apply windows this cycle");
return;
}
tracing::info!(
auto_window_count = auto_windows.len(),
"auto_create: found open auto-apply windows"
);
for win in &auto_windows {
// Check if host has pending patches
let pending: Option<PendingPatchHost> = match sqlx::query_as(
r#"
SELECT host_id, patch_count
FROM host_patch_data
WHERE host_id = $1 AND patch_count > 0
"#,
)
.bind(win.host_id)
.fetch_optional(&pool)
.await
{
Ok(p) => p,
Err(e) => {
tracing::error!(
error = %e,
host_id = %win.host_id,
"auto_create: patch data query failed"
);
continue;
},
};
let Some(pending) = pending else {
tracing::debug!(
host_id = %win.host_id,
"auto_create: no pending patches, skipping"
);
continue;
};
// Check if there's already a queued/running patch_apply job for this host
// that was created during this window cycle (within the window's time range).
// We use a simpler check: any non-completed patch_apply job for this host
// that references this maintenance window, OR any non-immediate job without
// a window that was created since the window opened.
let existing_job: bool = match sqlx::query_scalar(
r#"
SELECT EXISTS(
SELECT 1 FROM patch_jobs pj
JOIN patch_job_hosts pjh ON pj.id = pjh.job_id
WHERE pjh.host_id = $1
AND pj.status IN ('queued', 'running', 'pending')
AND pj.kind = 'patch_apply'
AND (
pj.maintenance_window_id = $2
OR
(pj.immediate = FALSE AND pj.created_at >=
(SELECT start_at - INTERVAL '5 minutes' FROM maintenance_windows WHERE id = $2)
)
)
)
"#,
)
.bind(win.host_id)
.bind(win.window_id)
.fetch_one(&pool)
.await
{
Ok(b) => b,
Err(e) => {
tracing::error!(
error = %e,
host_id = %win.host_id,
"auto_create: existing job check failed"
);
continue;
}
};
if existing_job {
tracing::debug!(
host_id = %win.host_id,
window_id = %win.window_id,
"auto_create: existing job already queued/running, skipping"
);
continue;
}
// Create a new patch_apply job for this host, linked to the window.
let job: Option<InsertedJobId> = match sqlx::query_as(
r#"
WITH new_job AS (
INSERT INTO patch_jobs
(kind, status, maintenance_window_id, immediate, patch_selection, notes)
VALUES
('patch_apply', 'queued', $1, FALSE, '[]'::jsonb,
'Auto-created by maintenance window scheduler')
RETURNING id AS job_id
)
INSERT INTO patch_job_hosts (job_id, host_id, status)
SELECT new_job.job_id, $2, 'queued'
FROM new_job
RETURNING job_id
"#,
)
.bind(win.window_id)
.bind(win.host_id)
.fetch_optional(&pool)
.await
{
Ok(j) => j,
Err(e) => {
tracing::error!(
error = %e,
host_id = %win.host_id,
window_id = %win.window_id,
"auto_create: job insert failed"
);
continue;
},
};
if let Some(job) = job {
tracing::info!(
job_id = %job.job_id,
host_id = %win.host_id,
window_id = %win.window_id,
patch_count = pending.patch_count,
"auto_create: created patch_apply job for host in maintenance window"
);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Dispatch queued non-immediate jobs
// ─────────────────────────────────────────────────────────────────────────────
/// Find all hosts with a currently-open maintenance window, then for each,