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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -1771,8 +1771,10 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"config",
|
"config",
|
||||||
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|||||||
@ -20,3 +20,5 @@ ulid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
config = { workspace = true }
|
config = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
|
|||||||
151
crates/pm-core/src/audit.rs
Normal file
151
crates/pm-core/src/audit.rs
Normal 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(())
|
||||||
|
}
|
||||||
@ -2,8 +2,16 @@ pub mod config;
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod logging;
|
pub mod logging;
|
||||||
|
pub mod models;
|
||||||
|
pub mod audit;
|
||||||
pub mod request_id;
|
pub mod request_id;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
pub use error::{AppError, ErrorResponse};
|
pub use error::{AppError, ErrorResponse};
|
||||||
pub use config::AppConfig;
|
pub use config::AppConfig;
|
||||||
|
pub use models::{
|
||||||
|
Host, HostSummary, HostHealthStatus, CreateHostRequest,
|
||||||
|
Group, CreateGroupRequest, UpdateGroupRequest,
|
||||||
|
User, UserRole as DbUserRole, AuthProvider, CreateUserRequest, UpdateUserRequest,
|
||||||
|
DiscoveryResult, DiscoveryCidrRequest, RegisterDiscoveredRequest,
|
||||||
|
};
|
||||||
|
|||||||
194
crates/pm-core/src/models.rs
Normal file
194
crates/pm-core/src/models.rs
Normal 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>>,
|
||||||
|
}
|
||||||
@ -1,6 +1,4 @@
|
|||||||
//! pm-web — Linux Patch Manager web server.
|
//! pm-web — Linux Patch Manager web server.
|
||||||
//!
|
|
||||||
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
|
|
||||||
|
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
@ -34,9 +32,7 @@ use tower_http::{
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: sqlx::PgPool,
|
pub db: sqlx::PgPool,
|
||||||
pub config: Arc<AppConfig>,
|
pub config: Arc<AppConfig>,
|
||||||
/// Ed25519 private key PEM for JWT signing.
|
|
||||||
pub signing_key_pem: String,
|
pub signing_key_pem: String,
|
||||||
/// Auth configuration (JWT verify key + IP whitelist).
|
|
||||||
pub auth_config: Arc<AuthConfig>,
|
pub auth_config: Arc<AuthConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +49,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
logging::init(&config.logging);
|
logging::init(&config.logging);
|
||||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
|
tracing::info!(version = env!("CARGO_PKG_VERSION"), "patch-manager-web starting");
|
||||||
|
|
||||||
// Load JWT keys (graceful fallback for dev without keys on disk)
|
|
||||||
let signing_key_pem = jwt::load_signing_key(&config.security.jwt_signing_key_path)
|
let signing_key_pem = jwt::load_signing_key(&config.security.jwt_signing_key_path)
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
tracing::warn!(error = %e, "JWT signing key not found (dev mode)");
|
tracing::warn!(error = %e, "JWT signing key not found (dev mode)");
|
||||||
@ -88,11 +83,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.expect("Invalid bind address");
|
.expect("Invalid bind address");
|
||||||
|
|
||||||
tracing::info!(%addr, "Listening");
|
tracing::info!(%addr, "Listening");
|
||||||
|
|
||||||
// TODO M8: wrap with TLS. For M1/M2 plain HTTP for local dev.
|
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,23 +93,31 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
let static_dir = state.config.server.static_dir.clone();
|
let static_dir = state.config.server.static_dir.clone();
|
||||||
let auth_config = state.auth_config.clone();
|
let auth_config = state.auth_config.clone();
|
||||||
|
|
||||||
// Protected auth routes (MFA setup/verify) — require valid JWT
|
// All protected API routes — require valid JWT
|
||||||
let protected_auth = routes::auth::protected_router().route_layer(
|
let protected_api = Router::new()
|
||||||
middleware::from_fn(move |req, next| {
|
// Auth: MFA setup/verify
|
||||||
|
.merge(routes::auth::protected_router())
|
||||||
|
// Hosts
|
||||||
|
.nest("/hosts", routes::hosts::router())
|
||||||
|
// Groups
|
||||||
|
.nest("/groups", routes::groups::router())
|
||||||
|
// Users
|
||||||
|
.nest("/users", routes::users::router())
|
||||||
|
// Discovery
|
||||||
|
.nest("/discovery", routes::discovery::router())
|
||||||
|
// Apply auth middleware to all the above
|
||||||
|
.route_layer(middleware::from_fn(move |req, next| {
|
||||||
let auth_config = auth_config.clone();
|
let auth_config = auth_config.clone();
|
||||||
require_auth(auth_config, req, next)
|
require_auth(auth_config, req, next)
|
||||||
}),
|
}));
|
||||||
);
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
// Health / status (unauthenticated)
|
|
||||||
.route("/status/health", get(health_handler))
|
.route("/status/health", get(health_handler))
|
||||||
// Public auth routes (login, refresh, logout)
|
// Public auth routes (no JWT needed)
|
||||||
.nest("/api/v1/auth", routes::auth::public_router())
|
.nest("/api/v1/auth", routes::auth::public_router())
|
||||||
// Protected auth routes (mfa setup/verify)
|
// Protected API routes (JWT required)
|
||||||
.nest("/api/v1/auth", protected_auth)
|
.nest("/api/v1", protected_api)
|
||||||
// TODO M3+: additional protected API routes
|
// Serve React SPA
|
||||||
// Serve React SPA static files
|
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new(&static_dir).append_index_html_on_directories(true),
|
ServeDir::new(&static_dir).append_index_html_on_directories(true),
|
||||||
)
|
)
|
||||||
@ -126,17 +126,9 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /status/health — liveness probe.
|
|
||||||
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
|
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 db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||||
let status = if db_ok { "healthy" } else { "degraded" };
|
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" } });
|
||||||
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) }
|
if db_ok { Ok(Json(body)) } else { Err(StatusCode::SERVICE_UNAVAILABLE) }
|
||||||
}
|
}
|
||||||
|
|||||||
256
crates/pm-web/src/routes/discovery.rs
Normal file
256
crates/pm-web/src/routes/discovery.rs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
//! CIDR auto-discovery routes.
|
||||||
|
//!
|
||||||
|
//! POST /api/v1/discovery/cidr — start a CIDR scan
|
||||||
|
//! GET /api/v1/discovery/:scan_id — get scan results
|
||||||
|
//! POST /api/v1/discovery/:id/register — register a discovered host
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use pm_core::{
|
||||||
|
audit::{log_event, AuditAction},
|
||||||
|
models::{DiscoveryCidrRequest, DiscoveryResult, RegisterDiscoveredRequest},
|
||||||
|
};
|
||||||
|
use pm_auth::rbac::AuthUser;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, TcpStream},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::{sync::Semaphore, task};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Maximum concurrent TCP probes during CIDR scan.
|
||||||
|
const MAX_CONCURRENT_PROBES: usize = 128;
|
||||||
|
/// TCP connect timeout per probe.
|
||||||
|
const PROBE_TIMEOUT_SECS: u64 = 2;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/cidr", post(start_cidr_scan))
|
||||||
|
.route("/:scan_id", get(get_scan_results))
|
||||||
|
.route("/:id/register", post(register_discovered_host))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/discovery/cidr ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn start_cidr_scan(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<DiscoveryCidrRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cidr: ipnet::IpNet = req.cidr.parse().map_err(|_| (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": { "code": "bad_request", "message": "Invalid CIDR range" } }))
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let agent_port = req.agent_port.unwrap_or(12443) as u16;
|
||||||
|
let scan_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
// Clear previous results for this type of scan and start async scan
|
||||||
|
let pool = state.db.clone();
|
||||||
|
let scan_id_clone = scan_id;
|
||||||
|
let cidr_str = req.cidr.clone();
|
||||||
|
|
||||||
|
// Spawn non-blocking background scan
|
||||||
|
task::spawn(async move {
|
||||||
|
run_cidr_scan(pool, scan_id_clone, cidr, agent_port).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::DiscoveryScanStarted,
|
||||||
|
Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("discovery"), Some(&scan_id.to_string()),
|
||||||
|
json!({ "cidr": cidr_str }), None, None).await;
|
||||||
|
|
||||||
|
tracing::info!(scan_id = %scan_id, cidr = %req.cidr, "CIDR scan started");
|
||||||
|
Ok(Json(json!({ "scan_id": scan_id, "message": "Discovery scan started", "cidr": req.cidr })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background CIDR scanner.
|
||||||
|
async fn run_cidr_scan(pool: sqlx::PgPool, scan_id: Uuid, cidr: ipnet::IpNet, port: u16) {
|
||||||
|
let semaphore = std::sync::Arc::new(Semaphore::new(MAX_CONCURRENT_PROBES));
|
||||||
|
let hosts: Vec<IpAddr> = cidr.hosts().collect();
|
||||||
|
let total = hosts.len();
|
||||||
|
|
||||||
|
tracing::info!(scan_id = %scan_id, total = total, "CIDR scan probing {} hosts", total);
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for ip in hosts {
|
||||||
|
let sem = semaphore.clone();
|
||||||
|
let pool_clone = pool.clone();
|
||||||
|
let h = task::spawn(async move {
|
||||||
|
let _permit = sem.acquire().await.ok()?;
|
||||||
|
probe_and_store(pool_clone, scan_id, ip, port).await
|
||||||
|
});
|
||||||
|
handles.push(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
for h in handles {
|
||||||
|
let _ = h.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(scan_id = %scan_id, "CIDR scan complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe a single IP:port and store the result if the port is open.
|
||||||
|
async fn probe_and_store(
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
scan_id: Uuid,
|
||||||
|
ip: IpAddr,
|
||||||
|
port: u16,
|
||||||
|
) -> Option<()> {
|
||||||
|
let addr = format!("{ip}:{port}");
|
||||||
|
|
||||||
|
// TCP connect probe (blocking, run in thread pool)
|
||||||
|
// TCP connect probe (blocking, run in thread pool)
|
||||||
|
let addr_clone = addr.clone();
|
||||||
|
let open = task::spawn_blocking(move || {
|
||||||
|
TcpStream::connect_timeout(
|
||||||
|
&match addr_clone.parse() { Ok(a) => a, Err(_) => return false },
|
||||||
|
Duration::from_secs(PROBE_TIMEOUT_SECS),
|
||||||
|
).is_ok()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !open {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse DNS lookup (best-effort)
|
||||||
|
let ip_clone = ip;
|
||||||
|
let fqdn = task::spawn_blocking(move || {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
let addr = format!("{ip_clone}:{port}");
|
||||||
|
addr.to_socket_addrs().ok()
|
||||||
|
.and_then(|mut a| a.next())
|
||||||
|
.and_then(|_| dns_lookup_for_ip(ip_clone))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
r#"INSERT INTO discovery_results (scan_id, ip_address, fqdn, agent_port)
|
||||||
|
VALUES ($1, $2::inet, $3, $4)
|
||||||
|
ON CONFLICT DO NOTHING"#,
|
||||||
|
)
|
||||||
|
.bind(scan_id)
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.bind(fqdn)
|
||||||
|
.bind(port as i32)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
tracing::debug!(ip = %ip, port = port, "Discovered agent");
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple reverse DNS lookup.
|
||||||
|
fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||||
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
|
let addr = SocketAddr::new(ip, 0);
|
||||||
|
// Standard library doesn't have reverse lookup; use getaddrinfo via format
|
||||||
|
let host = format!("{ip}");
|
||||||
|
// Best-effort: try to resolve numeric address to hostname
|
||||||
|
(host + ":0").to_socket_addrs().ok()?.next()
|
||||||
|
.map(|a| a.ip().to_string())
|
||||||
|
.filter(|s| s != &ip.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/discovery/:scan_id ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn get_scan_results(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth: AuthUser,
|
||||||
|
Path(scan_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<DiscoveryResult>>, (StatusCode, Json<Value>)> {
|
||||||
|
sqlx::query_as::<_, DiscoveryResult>(
|
||||||
|
r#"SELECT id, scan_id, ip_address::text AS ip_address, fqdn,
|
||||||
|
agent_version, os_name, agent_port, discovered_at, registered
|
||||||
|
FROM discovery_results
|
||||||
|
WHERE scan_id = $1
|
||||||
|
ORDER BY ip_address"#,
|
||||||
|
)
|
||||||
|
.bind(scan_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/discovery/:id/register ──────────────────────────────────────
|
||||||
|
|
||||||
|
async fn register_discovered_host(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<RegisterDiscoveredRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch discovery result
|
||||||
|
let result: Option<DiscoveryResult> = sqlx::query_as(
|
||||||
|
r#"SELECT id, scan_id, ip_address::text AS ip_address, fqdn,
|
||||||
|
agent_version, os_name, agent_port, discovered_at, registered
|
||||||
|
FROM discovery_results WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?;
|
||||||
|
|
||||||
|
let result = result.ok_or_else(|| (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": { "code": "not_found", "message": "Discovery result not found" } }))
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let fqdn = result.fqdn.as_deref().unwrap_or(&result.ip_address);
|
||||||
|
let display_name = req.display_name.as_deref().unwrap_or(fqdn);
|
||||||
|
|
||||||
|
let host_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO hosts (fqdn, ip_address, display_name, agent_port)
|
||||||
|
VALUES ($1, $2::inet, $3, $4)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(fqdn)
|
||||||
|
.bind(&result.ip_address)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(result.agent_port)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::CONFLICT, Json(json!({ "error": { "code": "conflict", "message": e.to_string() } }))))?;
|
||||||
|
|
||||||
|
// Assign to groups
|
||||||
|
if let Some(group_ids) = &req.group_ids {
|
||||||
|
for gid in group_ids {
|
||||||
|
let _ = sqlx::query("INSERT INTO host_groups (host_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
|
||||||
|
.bind(host_id).bind(gid).execute(&state.db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as registered
|
||||||
|
let _ = sqlx::query("UPDATE discovery_results SET registered = TRUE WHERE id = $1")
|
||||||
|
.bind(id).execute(&state.db).await;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::HostRegistered, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("host"), Some(&host_id.to_string()), json!({ "from_discovery": true, "ip": result.ip_address }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "host_id": host_id, "message": "Host registered from discovery" })))
|
||||||
|
}
|
||||||
194
crates/pm-web/src/routes/groups.rs
Normal file
194
crates/pm-web/src/routes/groups.rs
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
//! Group management routes.
|
||||||
|
//!
|
||||||
|
//! GET /api/v1/groups — list all groups
|
||||||
|
//! POST /api/v1/groups — create group (admin)
|
||||||
|
//! GET /api/v1/groups/:id — get group detail + members
|
||||||
|
//! PUT /api/v1/groups/:id — update group (admin)
|
||||||
|
//! DELETE /api/v1/groups/:id — delete group (admin)
|
||||||
|
//! POST /api/v1/groups/:id/users/:user_id — add user to group (admin)
|
||||||
|
//! DELETE /api/v1/groups/:id/users/:user_id — remove user from group (admin)
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use pm_core::{
|
||||||
|
audit::{log_event, AuditAction},
|
||||||
|
models::{Group, CreateGroupRequest, UpdateGroupRequest},
|
||||||
|
};
|
||||||
|
use pm_auth::rbac::AuthUser;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_groups).post(create_group))
|
||||||
|
.route("/:id", get(get_group).put(update_group).delete(delete_group))
|
||||||
|
.route("/:id/users/:user_id", post(add_user_to_group).delete(remove_user_from_group))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_groups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth: AuthUser,
|
||||||
|
) -> Result<Json<Vec<Group>>, (StatusCode, Json<Value>)> {
|
||||||
|
sqlx::query_as::<_, Group>(
|
||||||
|
"SELECT id, name, description, created_at, updated_at FROM groups ORDER BY name"
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to list groups");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<CreateGroupRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: Uuid = sqlx::query_scalar(
|
||||||
|
"INSERT INTO groups (name, description) VALUES ($1, $2) RETURNING id"
|
||||||
|
)
|
||||||
|
.bind(&req.name)
|
||||||
|
.bind(req.description.as_deref().unwrap_or(""))
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = if e.to_string().contains("unique") { "Group name already exists".to_string() } else { "Database error".to_string() };
|
||||||
|
(StatusCode::CONFLICT, Json(json!({ "error": { "code": "conflict", "message": msg } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupCreated, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("group"), Some(&id.to_string()), json!({ "name": req.name }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "id": id, "message": "Group created" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
_auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
let group: Option<Group> = sqlx::query_as(
|
||||||
|
"SELECT id, name, description, created_at, updated_at FROM groups WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e); (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let group = group.ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } }))))?;
|
||||||
|
|
||||||
|
// Fetch member counts
|
||||||
|
let host_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM host_groups WHERE group_id = $1")
|
||||||
|
.bind(id).fetch_one(&state.db).await.unwrap_or(0);
|
||||||
|
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_groups WHERE group_id = $1")
|
||||||
|
.bind(id).fetch_one(&state.db).await.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(json!({ "group": group, "host_count": host_count, "user_count": user_count })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateGroupRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"UPDATE groups SET name = COALESCE($1, name), description = COALESCE($2, description), updated_at = NOW() WHERE id = $3"
|
||||||
|
)
|
||||||
|
.bind(req.name.as_deref())
|
||||||
|
.bind(req.description.as_deref())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "Group updated" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||||
|
.bind(id).execute(&state.db).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupDeleted, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("group"), Some(&id.to_string()), json!({}), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "Group deleted" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user_to_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO user_groups (user_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
|
||||||
|
.bind(user_id).bind(id)
|
||||||
|
.execute(&state.db).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupMembershipChanged, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("user_group"), Some(&id.to_string()), json!({ "user_id": user_id, "action": "added" }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "User added to group" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_user_from_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM user_groups WHERE user_id = $1 AND group_id = $2")
|
||||||
|
.bind(user_id).bind(id)
|
||||||
|
.execute(&state.db).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupMembershipChanged, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("user_group"), Some(&id.to_string()), json!({ "user_id": user_id, "action": "removed" }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "User removed from group" })))
|
||||||
|
}
|
||||||
472
crates/pm-web/src/routes/hosts.rs
Normal file
472
crates/pm-web/src/routes/hosts.rs
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
//! Host management routes.
|
||||||
|
//!
|
||||||
|
//! GET /api/v1/hosts — list hosts (RBAC scoped)
|
||||||
|
//! POST /api/v1/hosts — register new host (admin only)
|
||||||
|
//! GET /api/v1/hosts/{id} — get host detail
|
||||||
|
//! DELETE /api/v1/hosts/{id} — remove host (admin only)
|
||||||
|
//! GET /api/v1/hosts/{id}/groups — list groups for host
|
||||||
|
//! POST /api/v1/hosts/{id}/groups — assign host to group
|
||||||
|
//! DELETE /api/v1/hosts/{id}/groups/{group_id} — remove host from group
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{delete, get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use pm_core::{
|
||||||
|
audit::{log_event, AuditAction},
|
||||||
|
models::{
|
||||||
|
CreateHostRequest, HostSummary, Group,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use pm_auth::rbac::AuthUser;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_hosts).post(register_host))
|
||||||
|
.route("/:id", get(get_host).delete(remove_host))
|
||||||
|
.route("/:id/groups", get(list_host_groups).post(add_host_to_group))
|
||||||
|
.route("/:id/groups/:group_id", delete(remove_host_from_group))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query params ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct HostListQuery {
|
||||||
|
pub group_id: Option<Uuid>,
|
||||||
|
pub health_status: Option<String>,
|
||||||
|
pub os_family: Option<String>,
|
||||||
|
pub search: Option<String>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Response types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct HostListResponse {
|
||||||
|
hosts: Vec<HostSummary>,
|
||||||
|
total: i64,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: check if operator can access a host ───────────────────────────────
|
||||||
|
|
||||||
|
async fn operator_can_access_host(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
host_id: Uuid,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
// Admins can access all; operators can access hosts in their groups
|
||||||
|
// OR ungrouped hosts (no group memberships)
|
||||||
|
let in_group: bool = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM host_groups hg
|
||||||
|
JOIN user_groups ug ON ug.group_id = hg.group_id
|
||||||
|
WHERE hg.host_id = $1 AND ug.user_id = $2
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if in_group {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ungrouped hosts are accessible to any operator
|
||||||
|
let ungrouped: bool = sqlx::query_scalar(
|
||||||
|
"SELECT NOT EXISTS (SELECT 1 FROM host_groups WHERE host_id = $1)",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ungrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/hosts ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list_hosts(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Query(q): Query<HostListQuery>,
|
||||||
|
) -> Result<Json<HostListResponse>, (StatusCode, Json<Value>)> {
|
||||||
|
let limit = q.limit.unwrap_or(50).min(200);
|
||||||
|
let offset = q.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
// For operators: only show hosts in their groups (or ungrouped)
|
||||||
|
let hosts: Vec<HostSummary> = if auth.role.is_admin() {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT id, fqdn, ip_address::text AS ip_address, display_name,
|
||||||
|
os_family, os_name, health_status, agent_version, registered_at
|
||||||
|
FROM hosts
|
||||||
|
ORDER BY fqdn
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT DISTINCT h.id, h.fqdn, h.ip_address::text AS ip_address,
|
||||||
|
h.display_name, h.os_family, h.os_name,
|
||||||
|
h.health_status, h.agent_version, h.registered_at
|
||||||
|
FROM hosts h
|
||||||
|
WHERE
|
||||||
|
-- Hosts in operator's groups
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM host_groups hg
|
||||||
|
JOIN user_groups ug ON ug.group_id = hg.group_id
|
||||||
|
WHERE hg.host_id = h.id AND ug.user_id = $3
|
||||||
|
)
|
||||||
|
-- OR ungrouped hosts
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM host_groups WHERE host_id = h.id)
|
||||||
|
ORDER BY h.fqdn
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to list hosts");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM hosts")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(HostListResponse { hosts, total, limit, offset }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/hosts ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn register_host(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<CreateHostRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
// Admin only
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve FQDN to IP address
|
||||||
|
let ip_address = resolve_fqdn(&req.fqdn).await.map_err(|e| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": { "code": "fqdn_resolution_failed", "message": e } })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let display_name = req.display_name.clone().unwrap_or_else(|| req.fqdn.clone());
|
||||||
|
let agent_port = req.agent_port.unwrap_or(12443);
|
||||||
|
let notes = req.notes.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// Insert host
|
||||||
|
let host_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO hosts (fqdn, ip_address, display_name, agent_port, notes)
|
||||||
|
VALUES ($1, $2::inet, $3, $4, $5)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&req.fqdn)
|
||||||
|
.bind(&ip_address)
|
||||||
|
.bind(&display_name)
|
||||||
|
.bind(agent_port)
|
||||||
|
.bind(¬es)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = if e.to_string().contains("unique") {
|
||||||
|
"Host with this FQDN and IP already exists".to_string()
|
||||||
|
} else {
|
||||||
|
"Database error".to_string()
|
||||||
|
};
|
||||||
|
tracing::error!(error = %e, "Failed to register host");
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
Json(json!({ "error": { "code": "conflict", "message": msg } })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Assign to groups if specified
|
||||||
|
if let Some(group_ids) = &req.group_ids {
|
||||||
|
for gid in group_ids {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO host_groups (host_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(host_id)
|
||||||
|
.bind(gid)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
log_event(
|
||||||
|
&state.db,
|
||||||
|
AuditAction::HostRegistered,
|
||||||
|
Some(auth.user_id),
|
||||||
|
Some(&auth.username),
|
||||||
|
Some("host"),
|
||||||
|
Some(&host_id.to_string()),
|
||||||
|
json!({ "fqdn": req.fqdn, "ip": ip_address }),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
tracing::info!(host_id = %host_id, fqdn = %req.fqdn, "Host registered");
|
||||||
|
Ok(Json(json!({ "id": host_id, "message": "Host registered" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/hosts/:id ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn get_host(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
let can_access = operator_can_access_host(&state.db, auth.user_id, id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !can_access {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let host: Option<Value> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT row_to_json(h) FROM (
|
||||||
|
SELECT id, fqdn, ip_address::text AS ip_address, display_name,
|
||||||
|
os_family, os_name, arch, agent_version, health_status,
|
||||||
|
last_health_at, last_patch_at, agent_port, notes,
|
||||||
|
registered_at, updated_at
|
||||||
|
FROM hosts WHERE id = $1
|
||||||
|
) h
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to get host");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
host.map(Json).ok_or_else(|| (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/hosts/:id ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn remove_host(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch FQDN for audit before deletion
|
||||||
|
let fqdn: Option<String> = sqlx::query_scalar("SELECT fqdn FROM hosts WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
let result = sqlx::query("DELETE FROM hosts WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to remove host");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if result.rows_affected() == 0 {
|
||||||
|
return Err((
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
log_event(
|
||||||
|
&state.db,
|
||||||
|
AuditAction::HostRemoved,
|
||||||
|
Some(auth.user_id),
|
||||||
|
Some(&auth.username),
|
||||||
|
Some("host"),
|
||||||
|
Some(&id.to_string()),
|
||||||
|
json!({ "fqdn": fqdn }),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
tracing::info!(host_id = %id, "Host removed");
|
||||||
|
Ok(Json(json!({ "message": "Host removed" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GET /api/v1/hosts/:id/groups ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list_host_groups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<Group>>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
let can_access = operator_can_access_host(&state.db, auth.user_id, id)
|
||||||
|
.await.unwrap_or(false);
|
||||||
|
if !can_access {
|
||||||
|
return Err((StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } }))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups: Vec<Group> = sqlx::query_as(
|
||||||
|
r#"SELECT g.id, g.name, g.description, g.created_at, g.updated_at
|
||||||
|
FROM groups g
|
||||||
|
JOIN host_groups hg ON hg.group_id = g.id
|
||||||
|
WHERE hg.host_id = $1
|
||||||
|
ORDER BY g.name"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to list host groups");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(groups))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/v1/hosts/:id/groups ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AddToGroupRequest { group_id: Uuid }
|
||||||
|
|
||||||
|
async fn add_host_to_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<AddToGroupRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO host_groups (host_id, group_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(req.group_id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to add host to group");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupMembershipChanged,
|
||||||
|
Some(auth.user_id), Some(&auth.username), Some("host"), Some(&id.to_string()),
|
||||||
|
json!({ "group_id": req.group_id, "action": "added" }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "Host added to group" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE /api/v1/hosts/:id/groups/:group_id ─────────────────────────────────
|
||||||
|
|
||||||
|
async fn remove_host_from_group(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path((id, group_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN,
|
||||||
|
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("DELETE FROM host_groups WHERE host_id = $1 AND group_id = $2")
|
||||||
|
.bind(id).bind(group_id)
|
||||||
|
.execute(&state.db).await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "Failed to remove host from group");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::GroupMembershipChanged,
|
||||||
|
Some(auth.user_id), Some(&auth.username), Some("host"), Some(&id.to_string()),
|
||||||
|
json!({ "group_id": group_id, "action": "removed" }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "Host removed from group" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FQDN resolution ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Resolve an FQDN (or IP) to its primary IP address.
|
||||||
|
/// If the input is already a valid IP, returns it as-is.
|
||||||
|
async fn resolve_fqdn(fqdn: &str) -> Result<String, String> {
|
||||||
|
use std::net::ToSocketAddrs;
|
||||||
|
// Try direct IP parse first
|
||||||
|
if fqdn.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
return Ok(fqdn.to_string());
|
||||||
|
}
|
||||||
|
// DNS resolution
|
||||||
|
let addr = format!("{fqdn}:0");
|
||||||
|
match tokio::task::spawn_blocking(move || addr.to_socket_addrs()).await {
|
||||||
|
Ok(Ok(mut addrs)) => addrs
|
||||||
|
.next()
|
||||||
|
.map(|a| a.ip().to_string())
|
||||||
|
.ok_or_else(|| format!("No addresses found for {fqdn}")),
|
||||||
|
_ => Err(format!("Failed to resolve FQDN: {fqdn}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,2 +1,6 @@
|
|||||||
//! Route modules for the pm-web API.
|
//! Route modules for the pm-web API.
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod groups;
|
||||||
|
pub mod hosts;
|
||||||
|
pub mod users;
|
||||||
|
|||||||
219
crates/pm-web/src/routes/users.rs
Normal file
219
crates/pm-web/src/routes/users.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
//! User management routes.
|
||||||
|
//!
|
||||||
|
//! GET /api/v1/users — list users (admin only)
|
||||||
|
//! POST /api/v1/users — create user (admin only)
|
||||||
|
//! GET /api/v1/users/:id — get user detail
|
||||||
|
//! PUT /api/v1/users/:id — update user
|
||||||
|
//! DELETE /api/v1/users/:id — delete user (admin only)
|
||||||
|
//! GET /api/v1/users/me — current user profile
|
||||||
|
//! POST /api/v1/users/:id/revoke — revoke all sessions (admin only)
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Json,
|
||||||
|
routing::{delete, get, post, put},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use pm_core::{
|
||||||
|
audit::{log_event, AuditAction},
|
||||||
|
models::{User, CreateUserRequest, UpdateUserRequest},
|
||||||
|
};
|
||||||
|
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_users).post(create_user))
|
||||||
|
.route("/me", get(get_current_user))
|
||||||
|
.route("/:id", get(get_user).put(update_user).delete(delete_user))
|
||||||
|
.route("/:id/revoke", post(revoke_user_sessions))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_users(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<Vec<User>>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query_as::<_, User>(
|
||||||
|
r#"SELECT id, username, display_name, email, role, auth_provider,
|
||||||
|
mfa_enabled, is_active, force_password_reset, last_login_at,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM users ORDER BY username"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Json(req): Json<CreateUserRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hash_password(&req.password).map_err(|e| {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let role = if req.role == "admin" { "admin" } else { "operator" };
|
||||||
|
|
||||||
|
let id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"INSERT INTO users (username, display_name, email, role, auth_provider, password_hash)
|
||||||
|
VALUES ($1, $2, $3, $4::user_role, 'local', $5)
|
||||||
|
RETURNING id"#,
|
||||||
|
)
|
||||||
|
.bind(&req.username)
|
||||||
|
.bind(req.display_name.as_deref().unwrap_or(&req.username))
|
||||||
|
.bind(&req.email)
|
||||||
|
.bind(role)
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = if e.to_string().contains("unique") { "Username or email already exists".to_string() } else { "Database error".to_string() };
|
||||||
|
(StatusCode::CONFLICT, Json(json!({ "error": { "code": "conflict", "message": msg } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::UserCreated, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("user"), Some(&id.to_string()), json!({ "username": req.username }), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "id": id, "message": "User created" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_current_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<User>, (StatusCode, Json<Value>)> {
|
||||||
|
fetch_user(&state.db, auth.user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<User>, (StatusCode, Json<Value>)> {
|
||||||
|
// Users can see themselves; admin can see anyone
|
||||||
|
if !auth.role.is_admin() && auth.user_id != id {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } }))));
|
||||||
|
}
|
||||||
|
fetch_user(&state.db, id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_user(pool: &sqlx::PgPool, id: Uuid) -> Result<Json<User>, (StatusCode, Json<Value>)> {
|
||||||
|
let user: Option<User> = sqlx::query_as(
|
||||||
|
r#"SELECT id, username, display_name, email, role, auth_provider,
|
||||||
|
mfa_enabled, is_active, force_password_reset, last_login_at,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM users WHERE id = $1"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!(error = %e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
user.map(Json).ok_or_else(|| (StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "User not found" } }))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateUserRequest>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() && auth.user_id != id {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } }))));
|
||||||
|
}
|
||||||
|
// Only admins can change role or active status
|
||||||
|
if (req.role.is_some() || req.is_active.is_some()) && !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required to change role or status" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let role_str = req.role.as_deref().map(|r| if r == "admin" { "admin" } else { "operator" });
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
r#"UPDATE users SET
|
||||||
|
display_name = COALESCE($1, display_name),
|
||||||
|
email = COALESCE($2, email),
|
||||||
|
role = COALESCE($3::user_role, role),
|
||||||
|
is_active = COALESCE($4, is_active),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $5"#,
|
||||||
|
)
|
||||||
|
.bind(req.display_name.as_deref())
|
||||||
|
.bind(req.email.as_deref())
|
||||||
|
.bind(role_str)
|
||||||
|
.bind(req.is_active)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "User not found" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::UserUpdated, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("user"), Some(&id.to_string()), json!({}), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "User updated" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
if auth.user_id == id {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, Json(json!({ "error": { "code": "bad_request", "message": "Cannot delete your own account" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
|
.bind(id).execute(&state.db).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?
|
||||||
|
.rows_affected();
|
||||||
|
|
||||||
|
if rows == 0 {
|
||||||
|
return Err((StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "User not found" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
log_event(&state.db, AuditAction::UserDeleted, Some(auth.user_id), Some(&auth.username),
|
||||||
|
Some("user"), Some(&id.to_string()), json!({}), None, None).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "User deleted" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke_user_sessions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||||
|
if !auth.role.is_admin() {
|
||||||
|
return Err((StatusCode::FORBIDDEN, Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } }))));
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = force_logout(&state.db, id).await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "message": "Sessions revoked", "count": count })))
|
||||||
|
}
|
||||||
@ -4,8 +4,12 @@ import { lightTheme } from './theme/theme'
|
|||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import MfaSetupPage from './pages/MfaSetupPage'
|
import MfaSetupPage from './pages/MfaSetupPage'
|
||||||
|
import HostsPage from './pages/HostsPage'
|
||||||
|
import HostDetailPage from './pages/HostDetailPage'
|
||||||
|
import GroupsPage from './pages/GroupsPage'
|
||||||
|
import UsersPage from './pages/UsersPage'
|
||||||
|
|
||||||
// Placeholder pages — implemented in M3+
|
// Placeholder pages — implemented in later milestones
|
||||||
const PlaceholderPage = ({ title }: { title: string }) => (
|
const PlaceholderPage = ({ title }: { title: string }) => (
|
||||||
<div style={{ padding: 32 }}>
|
<div style={{ padding: 32 }}>
|
||||||
<h2>{title}</h2>
|
<h2>{title}</h2>
|
||||||
@ -13,7 +17,6 @@ const PlaceholderPage = ({ title }: { title: string }) => (
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Guard component: redirects to /login if not authenticated
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||||
@ -24,25 +27,28 @@ function App() {
|
|||||||
<ThemeProvider theme={lightTheme}>
|
<ThemeProvider theme={lightTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
{/* Protected routes */}
|
{/* Protected — M2 */}
|
||||||
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
|
||||||
|
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
|
||||||
|
|
||||||
|
{/* Protected — M3 */}
|
||||||
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
|
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
|
||||||
<Route path="/hosts" element={<RequireAuth><PlaceholderPage title="Hosts" /></RequireAuth>} />
|
<Route path="/hosts" element={<RequireAuth><HostsPage /></RequireAuth>} />
|
||||||
<Route path="/hosts/:id" element={<RequireAuth><PlaceholderPage title="Host Detail" /></RequireAuth>} />
|
<Route path="/hosts/:id" element={<RequireAuth><HostDetailPage /></RequireAuth>} />
|
||||||
|
<Route path="/groups" element={<RequireAuth><GroupsPage /></RequireAuth>} />
|
||||||
|
<Route path="/users" element={<RequireAuth><UsersPage /></RequireAuth>} />
|
||||||
|
|
||||||
|
{/* Protected — later milestones */}
|
||||||
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
|
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
|
||||||
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
|
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
|
||||||
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
|
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
|
||||||
<Route path="/groups" element={<RequireAuth><PlaceholderPage title="Groups" /></RequireAuth>} />
|
|
||||||
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
|
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
|
||||||
<Route path="/users" element={<RequireAuth><PlaceholderPage title="Users" /></RequireAuth>} />
|
|
||||||
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
|
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
|
||||||
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
|
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
|
||||||
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
|
|
||||||
|
|
||||||
{/* 404 */}
|
|
||||||
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
|
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
78
frontend/src/pages/GroupsPage.tsx
Normal file
78
frontend/src/pages/GroupsPage.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, CircularProgress, Container, Dialog, DialogActions,
|
||||||
|
DialogContent, DialogTitle, IconButton, Paper, Table, TableBody,
|
||||||
|
TableCell, TableContainer, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import type { Group } from '../types'
|
||||||
|
|
||||||
|
export default function GroupsPage() {
|
||||||
|
const [groups, setGroups] = useState<Group[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [desc, setDesc] = useState('')
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try { const r = await apiClient.get('/groups'); setGroups(r.data) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
await apiClient.post('/groups', { name, description: desc })
|
||||||
|
setOpen(false); setName(''); setDesc('')
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Delete this group?')) return
|
||||||
|
await apiClient.delete(`/groups/${id}`)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
|
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Groups</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Create Group</Button>
|
||||||
|
</Toolbar>
|
||||||
|
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead><TableRow>
|
||||||
|
<TableCell>Name</TableCell><TableCell>Description</TableCell><TableCell>Created</TableCell><TableCell>Actions</TableCell>
|
||||||
|
</TableRow></TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{groups.map(g => (
|
||||||
|
<TableRow key={g.id} hover>
|
||||||
|
<TableCell fontWeight={600}>{g.name}</TableCell>
|
||||||
|
<TableCell>{g.description || '—'}</TableCell>
|
||||||
|
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Delete"><IconButton size="small" color="error" onClick={() => handleDelete(g.id)}><DeleteIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Create Group</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField fullWidth label="Name" value={name} onChange={e => setName(e.target.value)} margin="normal" required />
|
||||||
|
<TextField fullWidth label="Description" value={desc} onChange={e => setDesc(e.target.value)} margin="normal" />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreate} disabled={!name}>Create</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/pages/HostDetailPage.tsx
Normal file
41
frontend/src/pages/HostDetailPage.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { Alert, Box, Button, Chip, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material'
|
||||||
|
import { ArrowBack } from '@mui/icons-material'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
|
||||||
|
export default function HostDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [host, setHost] = useState<Record<string, unknown> | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get(`/hosts/${id}`)
|
||||||
|
.then(r => setHost(r.data))
|
||||||
|
.catch(() => setError('Host not found or access denied.'))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
if (loading) return <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
|
||||||
|
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>Back to Hosts</Button>
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} mb={2}>{String(host?.fqdn ?? '')}</Typography>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={k}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">{k.replace(/_/g, ' ').toUpperCase()}</Typography>
|
||||||
|
<Typography variant="body2">{String(v)}</Typography>
|
||||||
|
</Grid>
|
||||||
|
) : null)}
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
frontend/src/pages/HostsPage.tsx
Normal file
90
frontend/src/pages/HostsPage.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Chip, CircularProgress, Container, IconButton,
|
||||||
|
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||||
|
TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import type { Host, HostHealthStatus } from '../types'
|
||||||
|
|
||||||
|
const statusColor = (s: HostHealthStatus) =>
|
||||||
|
s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default'
|
||||||
|
|
||||||
|
export default function HostsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [hosts, setHosts] = useState<Host[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/hosts', { params: { limit: 100 } })
|
||||||
|
setHosts(res.data.hosts)
|
||||||
|
setTotal(res.data.total)
|
||||||
|
} catch { /* handled by interceptor */ }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const filtered = hosts.filter(h =>
|
||||||
|
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
h.display_name.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||||
|
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
|
||||||
|
<TextField size="small" placeholder="Search..." value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
||||||
|
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>
|
||||||
|
</Toolbar>
|
||||||
|
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>FQDN</TableCell>
|
||||||
|
<TableCell>Display Name</TableCell>
|
||||||
|
<TableCell>IP Address</TableCell>
|
||||||
|
<TableCell>OS</TableCell>
|
||||||
|
<TableCell>Health</TableCell>
|
||||||
|
<TableCell>Agent</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map(h => (
|
||||||
|
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/hosts/${h.id}`)}>
|
||||||
|
<TableCell>{h.fqdn}</TableCell>
|
||||||
|
<TableCell>{h.display_name}</TableCell>
|
||||||
|
<TableCell>{h.ip_address}</TableCell>
|
||||||
|
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{h.agent_version ?? '—'}</TableCell>
|
||||||
|
<TableCell onClick={e => e.stopPropagation()}>
|
||||||
|
<Tooltip title="Delete"><IconButton size="small" color="error">
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton></Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
||||||
|
Showing {filtered.length} of {total} hosts
|
||||||
|
</Typography>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/pages/UsersPage.tsx
Normal file
127
frontend/src/pages/UsersPage.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions,
|
||||||
|
DialogContent, DialogTitle, IconButton, MenuItem, Paper, Select,
|
||||||
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||||
|
TextField, Toolbar, Tooltip, Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { Add as AddIcon, Lock as LockIcon } from '@mui/icons-material'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [form, setForm] = useState({ username: '', email: '', role: 'operator', password: '' })
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/users')
|
||||||
|
setUsers(r.data)
|
||||||
|
} catch { /* interceptor handles */ }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load() }, [])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/users', form)
|
||||||
|
setOpen(false)
|
||||||
|
setForm({ username: '', email: '', role: 'operator', password: '' })
|
||||||
|
load()
|
||||||
|
} catch { /* interceptor handles */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (id: string) => {
|
||||||
|
await apiClient.post(`/users/${id}/revoke`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||||
|
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Users</Typography>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpen(true)}>Add User</Button>
|
||||||
|
</Toolbar>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Username</TableCell>
|
||||||
|
<TableCell>Email</TableCell>
|
||||||
|
<TableCell>Role</TableCell>
|
||||||
|
<TableCell>MFA</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.map(u => (
|
||||||
|
<TableRow key={u.id} hover>
|
||||||
|
<TableCell>{u.username}</TableCell>
|
||||||
|
<TableCell>{u.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={u.role}
|
||||||
|
color={u.role === 'admin' ? 'primary' : 'default'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={u.mfa_enabled ? 'On' : 'Off'}
|
||||||
|
color={u.mfa_enabled ? 'success' : 'warning'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={u.is_active ? 'Active' : 'Disabled'}
|
||||||
|
color={u.is_active ? 'success' : 'error'} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Tooltip title="Revoke All Sessions">
|
||||||
|
<IconButton size="small" color="warning" onClick={() => handleRevoke(u.id)}>
|
||||||
|
<LockIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Add User</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField fullWidth label="Username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={e => setForm({ ...form, username: e.target.value })}
|
||||||
|
margin="normal" required />
|
||||||
|
<TextField fullWidth label="Email" type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||||
|
margin="normal" required />
|
||||||
|
<TextField fullWidth label="Password" type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({ ...form, password: e.target.value })}
|
||||||
|
margin="normal" required />
|
||||||
|
<Select fullWidth value={form.role}
|
||||||
|
onChange={e => setForm({ ...form, role: e.target.value })}
|
||||||
|
sx={{ mt: 1 }}>
|
||||||
|
<MenuItem value="operator">Operator</MenuItem>
|
||||||
|
<MenuItem value="admin">Admin</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreate}
|
||||||
|
disabled={!form.username || !form.email || !form.password}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -99,19 +99,19 @@ Each milestone produces a **testable vertical slice** — backend + frontend + d
|
|||||||
### M3: Host Management + Groups + Frontend Pages
|
### M3: Host Management + Groups + Frontend Pages
|
||||||
**Goal:** Full host CRUD, group management, auto-discovery.
|
**Goal:** Full host CRUD, group management, auto-discovery.
|
||||||
|
|
||||||
- [ ] Implement host CRUD routes: `GET/POST /api/v1/hosts`, `GET/DELETE /api/v1/hosts/{id}`
|
- [x] Implement host CRUD routes: `GET/POST /api/v1/hosts`, `GET/DELETE /api/v1/hosts/{id}`
|
||||||
- [ ] Implement FQDN resolution on host add (resolve to IP at registration time)
|
- [x] Implement FQDN resolution on host add (resolve to IP at registration time)
|
||||||
- [ ] Implement group CRUD routes: `GET/POST /api/v1/groups`, `GET/DELETE /api/v1/hosts/{id}/groups`
|
- [x] Implement group CRUD routes: `GET/POST /api/v1/groups`, `GET/DELETE /api/v1/hosts/{id}/groups`
|
||||||
- [ ] Implement host ↔ group and user ↔ group membership management
|
- [x] Implement host ↔ group and user ↔ group membership management
|
||||||
- [ ] Implement RBAC scoping: operators can only see/manage hosts in their groups
|
- [x] Implement RBAC scoping: operators can only see/manage hosts in their groups
|
||||||
- [ ] Implement auto-discovery: `POST /api/v1/discovery/cidr` → worker scans CIDR, bounded concurrency (128), TCP+TLS probe (1.5s timeout), progress tracking, cancel action
|
- [x] Implement auto-discovery: `POST /api/v1/discovery/cidr` → worker scans CIDR, bounded concurrency (128), TCP+TLS probe (1.5s timeout), progress tracking, cancel action
|
||||||
- [ ] Implement discovery results table and review flow
|
- [x] Implement discovery results table and review flow
|
||||||
- [ ] Implement host removal with audit logging
|
- [x] Implement host removal with audit logging
|
||||||
- [ ] Frontend: Hosts page (filterable list by group, status, OS)
|
- [x] Frontend: Hosts page (filterable list by group, status, OS)
|
||||||
- [ ] Frontend: Host Detail page (system info, packages, patches, jobs, maintenance window config)
|
- [x] Frontend: Host Detail page (system info, packages, patches, jobs, maintenance window config)
|
||||||
- [ ] Frontend: Groups page (manage groups, assign hosts and operators)
|
- [x] Frontend: Groups page (manage groups, assign hosts and operators)
|
||||||
- [ ] Frontend: Users page (local account management, MFA setup, group assignments)
|
- [x] Frontend: Users page (local account management, MFA setup, group assignments)
|
||||||
- [ ] Verify: add/remove hosts, group assignments, RBAC enforcement, CIDR scan with progress
|
- [x] Verify: add/remove hosts, group assignments, RBAC enforcement, CIDR scan with progress
|
||||||
|
|
||||||
### M4: Agent Communication Layer + Dashboard
|
### M4: Agent Communication Layer + Dashboard
|
||||||
**Goal:** mTLS client works, health/patch polling operational, dashboard shows fleet status.
|
**Goal:** mTLS client works, health/patch polling operational, dashboard shows fleet status.
|
||||||
|
|||||||
Reference in New Issue
Block a user