Private
Public Access
1
0

feat(M3): Host Management, Groups, Users, CIDR Discovery

- pm-core::models: Host, HostSummary, Group, User, DiscoveryResult
  types + request payloads for all CRUD operations
- pm-core::audit: Tamper-evident hash-chained audit log writer
  (SHA-256 chain, non-fatal, covers all M3 events)
- pm-web/routes/hosts: Full host CRUD with RBAC scoping;
  FQDN DNS resolution on registration; host↔group membership;
  operator group-scoped access enforcement; audit on register/remove
- pm-web/routes/groups: Full group CRUD; host↔group and user↔group
  membership management; admin-only create/delete/update
- pm-web/routes/users: Full user CRUD (admin); current user profile;
  password hashing (Argon2id); role management; session revocation
- pm-web/routes/discovery: CIDR scan with bounded concurrency
  (128 workers), TCP probe with 2s timeout, reverse DNS lookup,
  scan results table, register-from-discovery flow with audit log
- Frontend: HostsPage (filterable table with health chips),
  HostDetailPage, GroupsPage (create/delete dialog),
  UsersPage (create/revoke sessions)
- App.tsx updated with all M3 routes wired to real pages
- cargo check --workspace: zero errors

Closes M3.
This commit is contained in:
2026-04-23 16:25:08 +00:00
parent 6811f84a7c
commit a6eb762962
17 changed files with 1887 additions and 51 deletions

151
crates/pm-core/src/audit.rs Normal file
View File

@ -0,0 +1,151 @@
//! Audit log helper functions.
//!
//! Writes tamper-evident, hash-chained audit events to the `audit_log` table.
//! The hash chain: each row's `row_hash` = SHA-256(prev_row_hash || action || target_id || created_at).
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::net::IpAddr;
use uuid::Uuid;
/// Audit event categories (must match the `audit_action` PostgreSQL ENUM).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditAction {
UserLogin,
UserLogout,
UserLoginFailed,
UserCreated,
UserDeleted,
UserUpdated,
HostRegistered,
HostRemoved,
GroupCreated,
GroupDeleted,
GroupMembershipChanged,
PatchJobCreated,
PatchJobCancelled,
PatchJobRollback,
MaintenanceWindowCreated,
MaintenanceWindowUpdated,
MaintenanceWindowDeleted,
CertificateIssued,
CertificateRenewed,
CertificateRevoked,
CertificateDownloaded,
ConfigChanged,
DiscoveryScanStarted,
}
impl AuditAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::UserLogin => "user_login",
Self::UserLogout => "user_logout",
Self::UserLoginFailed => "user_login_failed",
Self::UserCreated => "user_created",
Self::UserDeleted => "user_deleted",
Self::UserUpdated => "user_updated",
Self::HostRegistered => "host_registered",
Self::HostRemoved => "host_removed",
Self::GroupCreated => "group_created",
Self::GroupDeleted => "group_deleted",
Self::GroupMembershipChanged => "group_membership_changed",
Self::PatchJobCreated => "patch_job_created",
Self::PatchJobCancelled => "patch_job_cancelled",
Self::PatchJobRollback => "patch_job_rollback",
Self::MaintenanceWindowCreated => "maintenance_window_created",
Self::MaintenanceWindowUpdated => "maintenance_window_updated",
Self::MaintenanceWindowDeleted => "maintenance_window_deleted",
Self::CertificateIssued => "certificate_issued",
Self::CertificateRenewed => "certificate_renewed",
Self::CertificateRevoked => "certificate_revoked",
Self::CertificateDownloaded => "certificate_downloaded",
Self::ConfigChanged => "config_changed",
Self::DiscoveryScanStarted => "discovery_scan_started",
}
}
}
/// Write an audit event to the database.
///
/// Computes a hash chain entry using the previous row's hash.
/// Non-fatal: logs errors but does not propagate them to avoid
/// disrupting the primary operation.
pub async fn log_event(
pool: &PgPool,
action: AuditAction,
actor_user_id: Option<Uuid>,
actor_username: Option<&str>,
target_type: Option<&str>,
target_id: Option<&str>,
details: serde_json::Value,
ip_address: Option<IpAddr>,
request_id: Option<&str>,
) {
let result = write_audit_row(
pool, action, actor_user_id, actor_username,
target_type, target_id, details, ip_address, request_id,
)
.await;
if let Err(e) = result {
tracing::error!(error = %e, action = action.as_str(), "Failed to write audit log");
}
}
async fn write_audit_row(
pool: &PgPool,
action: AuditAction,
actor_user_id: Option<Uuid>,
actor_username: Option<&str>,
target_type: Option<&str>,
target_id: Option<&str>,
details: serde_json::Value,
ip_address: Option<IpAddr>,
request_id: Option<&str>,
) -> Result<(), sqlx::Error> {
// Fetch previous hash for chain
let prev_hash: Option<String> = sqlx::query_scalar(
"SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1",
)
.fetch_optional(pool)
.await?;
let prev = prev_hash.unwrap_or_default();
let now = chrono::Utc::now().to_rfc3339();
let action_str = action.as_str();
let tid = target_id.unwrap_or("");
// Hash: SHA-256(prev_hash + action + target_id + timestamp)
let mut hasher = Sha256::new();
hasher.update(prev.as_bytes());
hasher.update(action_str.as_bytes());
hasher.update(tid.as_bytes());
hasher.update(now.as_bytes());
let row_hash = hex::encode(hasher.finalize());
let ip_str = ip_address.map(|ip| ip.to_string());
sqlx::query(
r#"
INSERT INTO audit_log
(action, actor_user_id, actor_username, target_type, target_id,
details, ip_address, request_id, row_hash)
VALUES
($1::audit_action, $2, $3, $4, $5, $6, $7::inet, $8, $9)
"#,
)
.bind(action_str)
.bind(actor_user_id)
.bind(actor_username)
.bind(target_type)
.bind(target_id)
.bind(details)
.bind(ip_str)
.bind(request_id)
.bind(&row_hash)
.execute(pool)
.await?;
Ok(())
}

View File

@ -2,8 +2,16 @@ pub mod config;
pub mod db;
pub mod error;
pub mod logging;
pub mod models;
pub mod audit;
pub mod request_id;
// Re-export commonly used types
pub use error::{AppError, ErrorResponse};
pub use config::AppConfig;
pub use models::{
Host, HostSummary, HostHealthStatus, CreateHostRequest,
Group, CreateGroupRequest, UpdateGroupRequest,
User, UserRole as DbUserRole, AuthProvider, CreateUserRequest, UpdateUserRequest,
DiscoveryResult, DiscoveryCidrRequest, RegisterDiscoveredRequest,
};

View File

@ -0,0 +1,194 @@
//! Shared database model types used across pm-web and pm-worker.
//!
//! These match the database schema defined in migrations/.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
// ============================================================
// Enumerations (matching PostgreSQL ENUM types)
// ============================================================
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "host_health_status", rename_all = "lowercase")]
pub enum HostHealthStatus {
Pending,
Healthy,
Degraded,
Unreachable,
}
impl std::fmt::Display for HostHealthStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Healthy => write!(f, "healthy"),
Self::Degraded => write!(f, "degraded"),
Self::Unreachable => write!(f, "unreachable"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "user_role", rename_all = "lowercase")]
pub enum UserRole {
Admin,
Operator,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "auth_provider", rename_all = "snake_case")]
pub enum AuthProvider {
Local,
#[sqlx(rename = "azure_sso")]
AzureSso,
}
// ============================================================
// Host
// ============================================================
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Host {
pub id: Uuid,
pub fqdn: String,
pub ip_address: String, // stored as INET, returned as text
pub display_name: String,
pub os_family: Option<String>,
pub os_name: Option<String>,
pub arch: Option<String>,
pub agent_version: Option<String>,
pub health_status: HostHealthStatus,
pub last_health_at: Option<DateTime<Utc>>,
pub last_patch_at: Option<DateTime<Utc>>,
pub agent_port: i32,
pub notes: String,
pub registered_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Payload for registering a new host.
#[derive(Debug, Deserialize)]
pub struct CreateHostRequest {
/// FQDN or IP address of the managed host
pub fqdn: String,
pub display_name: Option<String>,
pub agent_port: Option<i32>,
pub notes: Option<String>,
pub group_ids: Option<Vec<Uuid>>,
}
/// Host list item (lighter projection for list views)
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct HostSummary {
pub id: Uuid,
pub fqdn: String,
pub ip_address: String,
pub display_name: String,
pub os_family: Option<String>,
pub os_name: Option<String>,
pub health_status: HostHealthStatus,
pub agent_version: Option<String>,
pub registered_at: DateTime<Utc>,
}
// ============================================================
// Group
// ============================================================
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Group {
pub id: Uuid,
pub name: String,
pub description: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateGroupRequest {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateGroupRequest {
pub name: Option<String>,
pub description: Option<String>,
}
// ============================================================
// User
// ============================================================
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: Uuid,
pub username: String,
pub display_name: String,
pub email: String,
pub role: UserRole,
pub auth_provider: AuthProvider,
pub mfa_enabled: bool,
pub is_active: bool,
pub force_password_reset: bool,
pub last_login_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// User create payload (admin-only)
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub display_name: Option<String>,
pub email: String,
pub role: String,
pub password: String,
}
/// User update payload
#[derive(Debug, Deserialize)]
pub struct UpdateUserRequest {
pub display_name: Option<String>,
pub email: Option<String>,
pub role: Option<String>,
pub is_active: Option<bool>,
}
// ============================================================
// Discovery
// ============================================================
/// Request body for CIDR auto-discovery scan.
#[derive(Debug, Deserialize)]
pub struct DiscoveryCidrRequest {
/// CIDR range to scan (e.g. "10.0.0.0/24")
pub cidr: String,
/// Agent port to probe (default 12443)
pub agent_port: Option<i32>,
}
/// A single discovered host result.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct DiscoveryResult {
pub id: Uuid,
pub scan_id: Uuid,
pub ip_address: String,
pub fqdn: Option<String>,
pub agent_version: Option<String>,
pub os_name: Option<String>,
pub agent_port: i32,
pub discovered_at: DateTime<Utc>,
pub registered: bool,
}
/// Payload for registering a host from a discovery result.
#[derive(Debug, Deserialize)]
pub struct RegisterDiscoveredRequest {
pub discovery_id: Uuid,
pub display_name: Option<String>,
pub group_ids: Option<Vec<Uuid>>,
}