Private
Public Access
1
0

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

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

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

View File

@ -1,6 +1,4 @@
//! pm-web — Linux Patch Manager web server.
//!
//! Serves the React SPA, exposes the REST API, and handles WebSocket relay.
mod routes;
@ -34,9 +32,7 @@ use tower_http::{
pub struct AppState {
pub db: sqlx::PgPool,
pub config: Arc<AppConfig>,
/// Ed25519 private key PEM for JWT signing.
pub signing_key_pem: String,
/// Auth configuration (JWT verify key + IP whitelist).
pub auth_config: Arc<AuthConfig>,
}
@ -53,7 +49,6 @@ async fn main() -> anyhow::Result<()> {
logging::init(&config.logging);
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)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "JWT signing key not found (dev mode)");
@ -88,11 +83,8 @@ async fn main() -> anyhow::Result<()> {
.expect("Invalid bind address");
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?;
axum::serve(listener, app).await?;
Ok(())
}
@ -101,23 +93,31 @@ pub fn build_router(state: AppState) -> Router {
let static_dir = state.config.server.static_dir.clone();
let auth_config = state.auth_config.clone();
// Protected auth routes (MFA setup/verify) — require valid JWT
let protected_auth = routes::auth::protected_router().route_layer(
middleware::from_fn(move |req, next| {
// All protected API routes — require valid JWT
let protected_api = Router::new()
// 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();
require_auth(auth_config, req, next)
}),
);
}));
Router::new()
// Health / status (unauthenticated)
.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())
// Protected auth routes (mfa setup/verify)
.nest("/api/v1/auth", protected_auth)
// TODO M3+: additional protected API routes
// Serve React SPA static files
// Protected API routes (JWT required)
.nest("/api/v1", protected_api)
// Serve React SPA
.fallback_service(
ServeDir::new(&static_dir).append_index_html_on_directories(true),
)
@ -126,17 +126,9 @@ pub fn build_router(state: AppState) -> Router {
.with_state(state)
}
/// GET /status/health — liveness probe.
async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, StatusCode> {
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
let status = if db_ok { "healthy" } else { "degraded" };
let body = json!({
"service": "patch-manager-web",
"version": env!("CARGO_PKG_VERSION"),
"status": status,
"database": if db_ok { "ok" } else { "error" },
});
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
if db_ok { Ok(Json(body)) } else { Err(StatusCode::SERVICE_UNAVAILABLE) }
}

View File

@ -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" })))
}

View 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" })))
}

View 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(&notes)
.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}")),
}
}

View File

@ -1,2 +1,6 @@
//! Route modules for the pm-web API.
pub mod auth;
pub mod discovery;
pub mod groups;
pub mod hosts;
pub mod users;

View 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 })))
}