style: Apply rustfmt with stable-only config
Some checks failed
CI Pipeline / Clippy Lints (push) Failing after 0s
CI Pipeline / Rust Unit Tests (push) Failing after 0s
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 0s
CI Pipeline / Security Audit (push) Failing after 3s
CI Pipeline / Build .deb & Release (push) Has been skipped
Some checks failed
CI Pipeline / Clippy Lints (push) Failing after 0s
CI Pipeline / Rust Unit Tests (push) Failing after 0s
CI Pipeline / Rust Format Check (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 0s
CI Pipeline / Security Audit (push) Failing after 3s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Fixed rustfmt.toml to only use stable options (removed nightly-only) - Applied cargo fmt --all to fix formatting violations - Stable options: edition=2021, max_width=100, reorder_imports/modules, match_block_trailing_comma
This commit is contained in:
@ -2,37 +2,18 @@
|
||||
|
||||
mod routes;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
middleware,
|
||||
response::Json,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, middleware, response::Json, routing::get, Router};
|
||||
use dashmap::DashMap;
|
||||
use pm_core::{
|
||||
config::AppConfig,
|
||||
db,
|
||||
logging,
|
||||
request_id::request_id_middleware,
|
||||
};
|
||||
use pm_auth::{
|
||||
jwt,
|
||||
rbac::{AuthConfig, require_auth},
|
||||
rbac::{require_auth, AuthConfig},
|
||||
};
|
||||
use routes::ws::WsTicket;
|
||||
use pm_core::{config::AppConfig, db, logging, request_id::request_id_middleware};
|
||||
use routes::azure_sso::SsoSession;
|
||||
use routes::ws::WsTicket;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tower_http::{
|
||||
services::ServeDir,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
|
||||
/// Shared application state threaded through Axum.
|
||||
#[derive(Clone)]
|
||||
@ -60,7 +41,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
|
||||
let signing_key_pem = jwt::load_signing_key(&config.security.jwt_signing_key_path)
|
||||
.unwrap_or_else(|e| {
|
||||
@ -68,8 +52,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
String::new()
|
||||
});
|
||||
|
||||
let verify_key_pem = jwt::load_verify_key(&config.security.jwt_verify_key_path)
|
||||
.unwrap_or_else(|e| {
|
||||
let verify_key_pem =
|
||||
jwt::load_verify_key(&config.security.jwt_verify_key_path).unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "JWT verify key not found (dev mode)");
|
||||
String::new()
|
||||
});
|
||||
@ -159,7 +143,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// Patch jobs
|
||||
.nest("/jobs", routes::jobs::router())
|
||||
// Maintenance windows (nested under hosts path param)
|
||||
.nest("/hosts/:host_id/maintenance-windows", routes::maintenance_windows::router())
|
||||
.nest(
|
||||
"/hosts/:host_id/maintenance-windows",
|
||||
routes::maintenance_windows::router(),
|
||||
)
|
||||
// CA root certificate download
|
||||
.nest("/ca", routes::ca::ca_router())
|
||||
// Certificate list / renew / revoke
|
||||
@ -187,9 +174,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
// WebSocket browser endpoint — ticket-authenticated, outside JWT middleware
|
||||
.merge(routes::ws::ws_router())
|
||||
// Serve React SPA
|
||||
.fallback_service(
|
||||
ServeDir::new(&static_dir).append_index_html_on_directories(true),
|
||||
)
|
||||
.fallback_service(ServeDir::new(&static_dir).append_index_html_on_directories(true))
|
||||
.layer(middleware::from_fn(request_id_middleware))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
@ -199,5 +184,9 @@ async fn health_handler(State(state): State<AppState>) -> Result<Json<Value>, St
|
||||
let db_ok = sqlx::query("SELECT 1").execute(&state.db).await.is_ok();
|
||||
let status = if db_ok { "healthy" } else { "degraded" };
|
||||
let body = json!({ "service": "patch-manager-web", "version": env!("CARGO_PKG_VERSION"), "status": status, "database": if db_ok { "ok" } else { "error" } });
|
||||
if db_ok { Ok(Json(body)) } else { Err(StatusCode::SERVICE_UNAVAILABLE) }
|
||||
if db_ok {
|
||||
Ok(Json(body))
|
||||
} else {
|
||||
Err(StatusCode::SERVICE_UNAVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,8 +18,8 @@ use axum::{
|
||||
};
|
||||
use pm_auth::{
|
||||
mfa_totp,
|
||||
session::{self, LoginRequest, LoginResponse},
|
||||
rbac::AuthUser,
|
||||
session::{self, LoginRequest, LoginResponse},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
@ -107,10 +107,17 @@ async fn login_handler(
|
||||
),
|
||||
_ => {
|
||||
tracing::error!(error = %e, "Login error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "An error occurred")
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"An error occurred",
|
||||
)
|
||||
},
|
||||
};
|
||||
(status, Json(json!({ "error": { "code": code, "message": message } })))
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": message } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -156,10 +163,17 @@ async fn refresh_handler(
|
||||
),
|
||||
_ => {
|
||||
tracing::error!(error = %e, "Refresh error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "An error occurred")
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"An error occurred",
|
||||
)
|
||||
},
|
||||
};
|
||||
(status, Json(json!({ "error": { "code": code, "message": msg } })))
|
||||
(
|
||||
status,
|
||||
Json(json!({ "error": { "code": code, "message": msg } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -221,11 +235,13 @@ async fn mfa_verify_handler(
|
||||
auth_user: AuthUser,
|
||||
Json(req): Json<MfaVerifyRequest>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let valid = mfa_totp::verify_code(&auth_user.username, &req.secret_base32, &req.code)
|
||||
.map_err(|e| (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
))?;
|
||||
let valid =
|
||||
mfa_totp::verify_code(&auth_user.username, &req.secret_base32, &req.code).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !valid {
|
||||
return Err((
|
||||
|
||||
@ -97,15 +97,19 @@ async fn azure_login(
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Azure SSO is not configured" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "forbidden", "message": "Azure SSO is not configured" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if !enabled {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Azure SSO is not enabled" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "forbidden", "message": "Azure SSO is not enabled" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@ -162,7 +166,9 @@ async fn azure_callback(
|
||||
let desc = params.error_description.unwrap_or_default();
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": { "code": "sso_error", "message": format!("Azure AD error: {} - {}", error, desc) } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "sso_error", "message": format!("Azure AD error: {} - {}", error, desc) } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@ -176,7 +182,9 @@ async fn azure_callback(
|
||||
let state_token = params.state.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": { "code": "bad_request", "message": "Missing state parameter" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "bad_request", "message": "Missing state parameter" } }),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
@ -211,9 +219,11 @@ async fn azure_callback(
|
||||
None => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Azure SSO not configured" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "internal_error", "message": "Azure SSO not configured" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Exchange code for tokens
|
||||
@ -263,7 +273,9 @@ async fn azure_callback(
|
||||
tracing::error!(status = %status, body = %body, "Token exchange failed");
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: HTTP {}", status) } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "sso_error", "message": format!("Token exchange failed: HTTP {}", status) } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@ -302,7 +314,9 @@ async fn azure_callback(
|
||||
if email.is_empty() || oid.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({ "error": { "code": "sso_error", "message": "Missing email or oid in id_token" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "sso_error", "message": "Missing email or oid in id_token" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@ -326,9 +340,11 @@ async fn azure_callback(
|
||||
Some(u) if !u.is_active => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "account_disabled", "message": "Account is disabled" } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "account_disabled", "message": "Account is disabled" } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
},
|
||||
Some(u) => u,
|
||||
None => {
|
||||
// Auto-create user with role=operator, auth_provider=azure_sso
|
||||
@ -372,22 +388,24 @@ async fn azure_callback(
|
||||
is_active: true,
|
||||
mfa_enabled: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Update last_login_at and azure_oid
|
||||
sqlx::query("UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2")
|
||||
.bind(&oid)
|
||||
.bind(user.id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to update last_login_at");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
sqlx::query(
|
||||
"UPDATE users SET last_login_at = NOW(), azure_oid = COALESCE(azure_oid, $1) WHERE id = $2",
|
||||
)
|
||||
.bind(&oid)
|
||||
.bind(user.id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to update last_login_at");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Issue JWT access token + refresh token
|
||||
let access_ttl = state.config.security.jwt_access_ttl_secs as i64;
|
||||
@ -466,6 +484,5 @@ fn decode_jwt_payload(token: &str) -> Result<IdTokenClaims, String> {
|
||||
.decode(&payload_b64_padded)
|
||||
.map_err(|e| format!("Base64 decode error: {}", e))?;
|
||||
|
||||
serde_json::from_slice(&payload_bytes)
|
||||
.map_err(|e| format!("JSON parse error: {}", e))
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON parse error: {}", e))
|
||||
}
|
||||
|
||||
@ -33,8 +33,7 @@ use crate::AppState;
|
||||
|
||||
/// Handles routes mounted at /api/v1/ca
|
||||
pub fn ca_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/root.crt", get(download_root_ca))
|
||||
Router::new().route("/root.crt", get(download_root_ca))
|
||||
}
|
||||
|
||||
/// Handles routes mounted at /api/v1/certificates
|
||||
@ -84,10 +83,7 @@ struct IssueCertRequest {
|
||||
|
||||
// ── Helper: build PEM download response ──────────────────────────────────────
|
||||
|
||||
fn pem_response(
|
||||
pem: String,
|
||||
filename: &str,
|
||||
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||
fn pem_response(pem: String, filename: &str) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@ -174,7 +170,7 @@ async fn list_certificates(
|
||||
.bind(st)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
},
|
||||
(Some(hid), None) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
@ -187,7 +183,7 @@ async fn list_certificates(
|
||||
.bind(hid)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
},
|
||||
(None, Some(st)) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
@ -200,7 +196,7 @@ async fn list_certificates(
|
||||
.bind(st)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
sqlx::query_as::<_, CertRow>(
|
||||
r#"SELECT id, host_id, serial_number, common_name,
|
||||
@ -211,7 +207,7 @@ async fn list_certificates(
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
}
|
||||
},
|
||||
}
|
||||
.map_err(db_error)?;
|
||||
|
||||
@ -259,7 +255,7 @@ async fn download_client_cert(
|
||||
)
|
||||
.await;
|
||||
pem_response(pem, "client.crt")
|
||||
}
|
||||
},
|
||||
None => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
@ -328,25 +324,23 @@ async fn renew_cert(
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
require_admin(&auth)?;
|
||||
|
||||
let issued = state
|
||||
.ca
|
||||
.renew_cert(cert_id, &state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(error = %e, %cert_id, "Failed to renew cert");
|
||||
if msg.contains("not found") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
let issued = state.ca.renew_cert(cert_id, &state.db).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
tracing::error!(error = %e, %cert_id, "Failed to renew cert");
|
||||
if msg.contains("not found") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(
|
||||
json!({ "error": { "code": "not_found", "message": "Certificate not found" } }),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
|
||||
log_event(
|
||||
&state.db,
|
||||
|
||||
@ -11,11 +11,11 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
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},
|
||||
@ -46,13 +46,18 @@ async fn start_cidr_scan(
|
||||
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" } }))));
|
||||
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 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();
|
||||
@ -67,13 +72,23 @@ async fn start_cidr_scan(
|
||||
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;
|
||||
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 })))
|
||||
Ok(Json(
|
||||
json!({ "scan_id": scan_id, "message": "Discovery scan started", "cidr": req.cidr }),
|
||||
))
|
||||
}
|
||||
|
||||
/// Background CIDR scanner.
|
||||
@ -103,12 +118,7 @@ async fn run_cidr_scan(pool: sqlx::PgPool, scan_id: Uuid, cidr: ipnet::IpNet, po
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
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)
|
||||
@ -116,9 +126,13 @@ async fn probe_and_store(
|
||||
let addr_clone = addr.clone();
|
||||
let open = task::spawn_blocking(move || {
|
||||
TcpStream::connect_timeout(
|
||||
&match addr_clone.parse() { Ok(a) => a, Err(_) => return false },
|
||||
&match addr_clone.parse() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return false,
|
||||
},
|
||||
Duration::from_secs(PROBE_TIMEOUT_SECS),
|
||||
).is_ok()
|
||||
)
|
||||
.is_ok()
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@ -132,7 +146,8 @@ async fn probe_and_store(
|
||||
let fqdn = task::spawn_blocking(move || {
|
||||
use std::net::ToSocketAddrs;
|
||||
let addr = format!("{ip_clone}:{port}");
|
||||
addr.to_socket_addrs().ok()
|
||||
addr.to_socket_addrs()
|
||||
.ok()
|
||||
.and_then(|mut a| a.next())
|
||||
.and_then(|_| dns_lookup_for_ip(ip_clone))
|
||||
})
|
||||
@ -163,7 +178,10 @@ fn dns_lookup_for_ip(ip: IpAddr) -> Option<String> {
|
||||
// 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()
|
||||
(host + ":0")
|
||||
.to_socket_addrs()
|
||||
.ok()?
|
||||
.next()
|
||||
.map(|a| a.ip().to_string())
|
||||
.filter(|s| s != &ip.to_string())
|
||||
}
|
||||
@ -188,7 +206,10 @@ async fn get_scan_results(
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -201,7 +222,10 @@ async fn register_discovered_host(
|
||||
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" } }))));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch discovery result
|
||||
@ -213,7 +237,12 @@ async fn register_discovered_host(
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?;
|
||||
.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,
|
||||
@ -235,7 +264,12 @@ async fn register_discovered_host(
|
||||
.bind(result.agent_port)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::CONFLICT, Json(json!({ "error": { "code": "conflict", "message": e.to_string() } }))))?;
|
||||
.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 {
|
||||
@ -247,10 +281,24 @@ async fn register_discovered_host(
|
||||
|
||||
// Mark as registered
|
||||
let _ = sqlx::query("UPDATE discovery_results SET registered = TRUE WHERE id = $1")
|
||||
.bind(id).execute(&state.db).await;
|
||||
.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;
|
||||
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" })))
|
||||
Ok(Json(
|
||||
json!({ "host_id": host_id, "message": "Host registered from discovery" }),
|
||||
))
|
||||
}
|
||||
|
||||
@ -15,11 +15,11 @@ use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{Group, CreateGroupRequest, UpdateGroupRequest},
|
||||
models::{CreateGroupRequest, Group, UpdateGroupRequest},
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -28,8 +28,14 @@ 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))
|
||||
.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(
|
||||
@ -37,14 +43,17 @@ async fn list_groups(
|
||||
_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"
|
||||
"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" } })))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -54,23 +63,42 @@ async fn create_group(
|
||||
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" } }))));
|
||||
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 } })))
|
||||
})?;
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -81,24 +109,43 @@ async fn get_group(
|
||||
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"
|
||||
"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" } })))
|
||||
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" } }))))?;
|
||||
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);
|
||||
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 })))
|
||||
Ok(Json(
|
||||
json!({ "group": group, "host_count": host_count, "user_count": user_count }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_group(
|
||||
@ -108,7 +155,10 @@ async fn update_group(
|
||||
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" } }))));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
let rows = sqlx::query(
|
||||
@ -123,7 +173,10 @@ async fn update_group(
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err((StatusCode::NOT_FOUND, Json(json!({ "error": { "code": "not_found", "message": "Group not found" } }))));
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Group not found" } })),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "message": "Group updated" })))
|
||||
@ -135,20 +188,43 @@ async fn delete_group(
|
||||
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" } }))));
|
||||
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() } }))))?
|
||||
.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" } }))));
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -159,16 +235,38 @@ async fn add_user_to_group(
|
||||
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" } }))));
|
||||
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() } }))))?;
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -179,16 +277,36 @@ async fn remove_user_from_group(
|
||||
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" } }))));
|
||||
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() } }))))?;
|
||||
.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;
|
||||
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" })))
|
||||
}
|
||||
|
||||
@ -16,13 +16,11 @@ use axum::{
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{
|
||||
CreateHostRequest, HostSummary, Group,
|
||||
},
|
||||
models::{CreateHostRequest, Group, HostSummary},
|
||||
};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
@ -88,12 +86,11 @@ async fn operator_can_access_host(
|
||||
}
|
||||
|
||||
// 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?;
|
||||
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)
|
||||
}
|
||||
@ -162,7 +159,12 @@ async fn list_hosts(
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Json(HostListResponse { hosts, total, limit, offset }))
|
||||
Ok(Json(HostListResponse {
|
||||
hosts,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── POST /api/v1/hosts ────────────────────────────────────────────────────────
|
||||
@ -244,7 +246,8 @@ async fn register_host(
|
||||
json!({ "fqdn": req.fqdn, "ip": ip_address }),
|
||||
None,
|
||||
None,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(host_id = %host_id, fqdn = %req.fqdn, "Host registered");
|
||||
Ok(Json(json!({ "id": host_id, "message": "Host registered" })))
|
||||
@ -291,10 +294,12 @@ async fn get_host(
|
||||
)
|
||||
})?;
|
||||
|
||||
host.map(Json).ok_or_else(|| (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
))
|
||||
host.map(Json).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── DELETE /api/v1/hosts/:id ──────────────────────────────────────────────────
|
||||
@ -347,7 +352,8 @@ async fn remove_host(
|
||||
json!({ "fqdn": fqdn }),
|
||||
None,
|
||||
None,
|
||||
).await;
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(host_id = %id, "Host removed");
|
||||
Ok(Json(json!({ "message": "Host removed" })))
|
||||
@ -362,10 +368,13 @@ async fn list_host_groups(
|
||||
) -> 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);
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !can_access {
|
||||
return Err((StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } }))));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Access denied" } })),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -381,8 +390,10 @@ async fn list_host_groups(
|
||||
.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" } })))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(groups))
|
||||
@ -391,7 +402,9 @@ async fn list_host_groups(
|
||||
// ── POST /api/v1/hosts/:id/groups ─────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddToGroupRequest { group_id: Uuid }
|
||||
struct AddToGroupRequest {
|
||||
group_id: Uuid,
|
||||
}
|
||||
|
||||
async fn add_host_to_group(
|
||||
State(state): State<AppState>,
|
||||
@ -400,8 +413,10 @@ async fn add_host_to_group(
|
||||
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" } }))));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
@ -413,13 +428,24 @@ async fn add_host_to_group(
|
||||
.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" } })))
|
||||
(
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -432,22 +458,37 @@ async fn remove_host_from_group(
|
||||
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" } }))));
|
||||
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
|
||||
.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" } })))
|
||||
(
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
|
||||
@ -149,7 +149,11 @@ async fn create_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "create_job: insert patch_jobs failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Insert one patch_job_hosts row per requested host.
|
||||
@ -170,7 +174,11 @@ async fn create_job(
|
||||
error = %e, %job_id, %host_id,
|
||||
"create_job: insert patch_job_hosts failed"
|
||||
);
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
@ -310,7 +318,12 @@ async fn list_jobs(
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
Ok(Json(JobListResponse { jobs, total, limit, offset }))
|
||||
Ok(Json(JobListResponse {
|
||||
jobs,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
}))
|
||||
}
|
||||
// ── GET /api/v1/jobs/:id ─────────────────────────────────────────────────────
|
||||
|
||||
@ -325,11 +338,7 @@ async fn get_job(
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !allowed {
|
||||
return Err(err(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
"Access denied",
|
||||
));
|
||||
return Err(err(StatusCode::FORBIDDEN, "forbidden", "Access denied"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,12 +359,14 @@ async fn get_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "get_job: failed to fetch job");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
let job = job.ok_or_else(|| {
|
||||
err(StatusCode::NOT_FOUND, "not_found", "Job not found")
|
||||
})?;
|
||||
let job = job.ok_or_else(|| err(StatusCode::NOT_FOUND, "not_found", "Job not found"))?;
|
||||
|
||||
// Fetch per-host status rows joined to the host display name.
|
||||
let hosts: Vec<JobHostRow> = sqlx::query_as(
|
||||
@ -383,7 +394,11 @@ async fn get_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "get_job: failed to fetch host rows");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "job": job, "hosts": hosts })))
|
||||
@ -397,20 +412,22 @@ async fn cancel_job(
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Fetch the job to verify it exists and check ownership.
|
||||
let row: Option<(String, Option<Uuid>)> = sqlx::query_as(
|
||||
"SELECT status::text, created_by_user_id FROM patch_jobs WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "cancel_job: db fetch failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
let row: Option<(String, Option<Uuid>)> =
|
||||
sqlx::query_as("SELECT status::text, created_by_user_id FROM patch_jobs WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "cancel_job: db fetch failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
let (status_str, creator_id) = row.ok_or_else(|| {
|
||||
err(StatusCode::NOT_FOUND, "not_found", "Job not found")
|
||||
})?;
|
||||
let (status_str, creator_id) =
|
||||
row.ok_or_else(|| err(StatusCode::NOT_FOUND, "not_found", "Job not found"))?;
|
||||
|
||||
// Only admin or the job creator may cancel.
|
||||
if !auth.role.is_admin() {
|
||||
@ -437,16 +454,18 @@ async fn cancel_job(
|
||||
}
|
||||
|
||||
// Cancel the parent job.
|
||||
sqlx::query(
|
||||
"UPDATE patch_jobs SET status = 'cancelled'::job_status WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "cancel_job: update patch_jobs failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
sqlx::query("UPDATE patch_jobs SET status = 'cancelled'::job_status WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "cancel_job: update patch_jobs failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Cancel all queued/pending host rows for this job.
|
||||
sqlx::query(
|
||||
@ -462,7 +481,11 @@ async fn cancel_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "cancel_job: update patch_job_hosts failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
log_event(
|
||||
@ -506,7 +529,11 @@ async fn rollback_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "rollback_job: existence check failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
if !original_exists {
|
||||
@ -521,7 +548,11 @@ async fn rollback_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %id, "rollback_job: host fetch failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
if host_ids.is_empty() {
|
||||
@ -552,7 +583,11 @@ async fn rollback_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, parent_job_id = %id, "rollback_job: insert failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Replicate host list into the rollback job.
|
||||
@ -573,7 +608,11 @@ async fn rollback_job(
|
||||
error = %e, %rollback_job_id, %host_id,
|
||||
"rollback_job: insert patch_job_hosts failed"
|
||||
);
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
|
||||
@ -15,9 +15,7 @@ use axum::{
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{
|
||||
CreateMaintenanceWindowRequest, MaintenanceWindow, UpdateMaintenanceWindowRequest,
|
||||
},
|
||||
models::{CreateMaintenanceWindowRequest, MaintenanceWindow, UpdateMaintenanceWindowRequest},
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
@ -56,15 +54,18 @@ async fn list_windows(
|
||||
Path(host_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
// Verify host exists.
|
||||
let host_exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
||||
.bind(host_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "list_windows: host existence check failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
let host_exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
||||
.bind(host_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "list_windows: host existence check failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
if !host_exists {
|
||||
return Err(err(StatusCode::NOT_FOUND, "not_found", "Host not found"));
|
||||
@ -84,7 +85,11 @@ async fn list_windows(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "list_windows: query failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "windows": windows })))
|
||||
@ -101,41 +106,44 @@ async fn create_window(
|
||||
// Validate: weekly requires recurrence_day 0-6
|
||||
if req.recurrence == pm_core::models::WindowRecurrence::Weekly {
|
||||
match req.recurrence_day {
|
||||
Some(d) if (0..=6).contains(&d) => {}
|
||||
Some(d) if (0..=6).contains(&d) => {},
|
||||
_ => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bad_request",
|
||||
"Weekly recurrence requires recurrence_day 0-6 (0=Sunday)",
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: monthly requires recurrence_day 1-31
|
||||
if req.recurrence == pm_core::models::WindowRecurrence::Monthly {
|
||||
match req.recurrence_day {
|
||||
Some(d) if (1..=31).contains(&d) => {}
|
||||
Some(d) if (1..=31).contains(&d) => {},
|
||||
_ => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bad_request",
|
||||
"Monthly recurrence requires recurrence_day 1-31",
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Verify host exists.
|
||||
let host_exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
||||
.bind(host_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "create_window: host existence check failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
let host_exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM hosts WHERE id = $1)")
|
||||
.bind(host_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "create_window: host existence check failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
if !host_exists {
|
||||
return Err(err(StatusCode::NOT_FOUND, "not_found", "Host not found"));
|
||||
@ -165,7 +173,11 @@ async fn create_window(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %host_id, "create_window: insert failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
log_event(
|
||||
@ -219,44 +231,52 @@ async fn update_window(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %win_id, "update_window: fetch failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
let existing = existing.ok_or_else(|| {
|
||||
err(StatusCode::NOT_FOUND, "not_found", "Maintenance window not found")
|
||||
err(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"Maintenance window not found",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Apply partial updates using existing values as defaults.
|
||||
let new_label = req.label.unwrap_or(existing.label);
|
||||
let new_label = req.label.unwrap_or(existing.label);
|
||||
let new_recurrence = req.recurrence.unwrap_or(existing.recurrence);
|
||||
let new_start_at = req.start_at.unwrap_or(existing.start_at);
|
||||
let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes);
|
||||
let new_rec_day = req.recurrence_day.or(existing.recurrence_day);
|
||||
let new_enabled = req.enabled.unwrap_or(existing.enabled);
|
||||
let new_start_at = req.start_at.unwrap_or(existing.start_at);
|
||||
let new_duration = req.duration_minutes.unwrap_or(existing.duration_minutes);
|
||||
let new_rec_day = req.recurrence_day.or(existing.recurrence_day);
|
||||
let new_enabled = req.enabled.unwrap_or(existing.enabled);
|
||||
|
||||
// Validate recurrence_day for the final recurrence type.
|
||||
if new_recurrence == pm_core::models::WindowRecurrence::Weekly {
|
||||
match new_rec_day {
|
||||
Some(d) if (0..=6).contains(&d) => {}
|
||||
Some(d) if (0..=6).contains(&d) => {},
|
||||
_ => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bad_request",
|
||||
"Weekly recurrence requires recurrence_day 0-6",
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
if new_recurrence == pm_core::models::WindowRecurrence::Monthly {
|
||||
match new_rec_day {
|
||||
Some(d) if (1..=31).contains(&d) => {}
|
||||
Some(d) if (1..=31).contains(&d) => {},
|
||||
_ => {
|
||||
return Err(err(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"bad_request",
|
||||
"Monthly recurrence requires recurrence_day 1-31",
|
||||
));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -287,7 +307,11 @@ async fn update_window(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %win_id, "update_window: update failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
log_event(
|
||||
@ -320,17 +344,19 @@ async fn delete_window(
|
||||
auth: AuthUser,
|
||||
Path((host_id, win_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM maintenance_windows WHERE id = $1 AND host_id = $2",
|
||||
)
|
||||
.bind(win_id)
|
||||
.bind(host_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %win_id, "delete_window: delete failed");
|
||||
err(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", "Database error")
|
||||
})?;
|
||||
let result = sqlx::query("DELETE FROM maintenance_windows WHERE id = $1 AND host_id = $2")
|
||||
.bind(win_id)
|
||||
.bind(host_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %win_id, "delete_window: delete failed");
|
||||
err(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal_error",
|
||||
"Database error",
|
||||
)
|
||||
})?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(err(
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
//! Route modules for the pm-web API.
|
||||
pub mod auth;
|
||||
pub mod azure_sso;
|
||||
pub mod ca;
|
||||
pub mod discovery;
|
||||
pub mod groups;
|
||||
pub mod hosts;
|
||||
pub mod maintenance_windows;
|
||||
pub mod jobs;
|
||||
pub mod maintenance_windows;
|
||||
pub mod settings;
|
||||
pub mod status;
|
||||
pub mod users;
|
||||
pub mod settings;
|
||||
pub mod azure_sso;
|
||||
pub mod ws;
|
||||
|
||||
pub mod reports;
|
||||
|
||||
@ -28,10 +28,10 @@ struct ReportQuery {
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/compliance", get(compliance_report))
|
||||
.route("/compliance", get(compliance_report))
|
||||
.route("/patch-history", get(patch_history_report))
|
||||
.route("/vulnerability", get(vulnerability_report))
|
||||
.route("/audit", get(audit_report))
|
||||
.route("/audit", get(audit_report))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -58,21 +58,22 @@ async fn run_report(
|
||||
match result {
|
||||
Ok(bytes) => {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(ct),
|
||||
);
|
||||
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(ct));
|
||||
headers.insert(
|
||||
header::CONTENT_DISPOSITION,
|
||||
HeaderValue::from_str(&disposition)
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
|
||||
);
|
||||
(headers, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "report generation failed");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("Report error: {}", e)).into_response()
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Report error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,10 +93,13 @@ async fn compliance_report(
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
state.db,
|
||||
params,
|
||||
use_pdf,
|
||||
"compliance-report.csv",
|
||||
"compliance-report.pdf",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn patch_history_report(
|
||||
@ -110,10 +114,13 @@ async fn patch_history_report(
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
state.db,
|
||||
params,
|
||||
use_pdf,
|
||||
"patch-history-report.csv",
|
||||
"patch-history-report.pdf",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn vulnerability_report(
|
||||
@ -128,16 +135,16 @@ async fn vulnerability_report(
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
state.db,
|
||||
params,
|
||||
use_pdf,
|
||||
"vulnerability-report.csv",
|
||||
"vulnerability-report.pdf",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn audit_report(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ReportQuery>,
|
||||
) -> Response {
|
||||
async fn audit_report(State(state): State<AppState>, Query(q): Query<ReportQuery>) -> Response {
|
||||
let params = ReportParams {
|
||||
report_type: ReportType::Audit,
|
||||
from: q.from,
|
||||
@ -146,8 +153,11 @@ async fn audit_report(
|
||||
};
|
||||
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
|
||||
run_report(
|
||||
state.db, params, use_pdf,
|
||||
state.db,
|
||||
params,
|
||||
use_pdf,
|
||||
"audit-report.csv",
|
||||
"audit-report.pdf",
|
||||
).await
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@ -20,8 +20,8 @@ use lettre::{
|
||||
transport::smtp::authentication::Credentials,
|
||||
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
};
|
||||
use pm_core::audit::{log_event, verify_integrity, AuditAction};
|
||||
use pm_auth::rbac::AuthUser;
|
||||
use pm_core::audit::{log_event, verify_integrity, AuditAction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
@ -132,7 +132,10 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/", get(get_settings).put(update_settings))
|
||||
.route("/azure-sso/test", post(test_azure_sso))
|
||||
.route("/smtp/test", post(test_smtp))
|
||||
.route("/ip-whitelist", get(get_ip_whitelist).put(update_ip_whitelist))
|
||||
.route(
|
||||
"/ip-whitelist",
|
||||
get(get_ip_whitelist).put(update_ip_whitelist),
|
||||
)
|
||||
.route("/audit-integrity", post(audit_integrity))
|
||||
}
|
||||
|
||||
@ -155,26 +158,28 @@ fn admin_only(auth: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
|
||||
async fn load_system_config(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> Result<HashMap<String, String>, (StatusCode, Json<Value>)> {
|
||||
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT key, value FROM system_config",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load system_config");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
let rows: Vec<(String, String)> = sqlx::query_as("SELECT key, value FROM system_config")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "Failed to load system_config");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(rows.into_iter().collect())
|
||||
}
|
||||
|
||||
fn build_settings_response(cfg: &HashMap<String, String>, azure: AzureSsoConfig) -> SettingsResponse {
|
||||
fn build_settings_response(
|
||||
cfg: &HashMap<String, String>,
|
||||
azure: AzureSsoConfig,
|
||||
) -> SettingsResponse {
|
||||
let get = |key: &str| -> String { cfg.get(key).cloned().unwrap_or_default() };
|
||||
|
||||
let recipients: Vec<String> = serde_json::from_str(&get("notification_email_recipients")).unwrap_or_default();
|
||||
let recipients: Vec<String> =
|
||||
serde_json::from_str(&get("notification_email_recipients")).unwrap_or_default();
|
||||
|
||||
SettingsResponse {
|
||||
azure_sso: azure,
|
||||
@ -517,7 +522,7 @@ async fn test_azure_sso(
|
||||
"success": false,
|
||||
"message": "Azure SSO is not configured"
|
||||
})));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if tenant_id.is_empty() {
|
||||
@ -560,7 +565,7 @@ async fn test_azure_sso(
|
||||
"issuer": issuer
|
||||
})))
|
||||
}
|
||||
}
|
||||
},
|
||||
Ok(resp) => Ok(Json(json!({
|
||||
"success": false,
|
||||
"message": format!("Failed to reach Azure AD: HTTP {}", resp.status())
|
||||
@ -593,11 +598,17 @@ async fn test_smtp(
|
||||
}
|
||||
|
||||
let host = cfg.get("smtp_host").cloned().unwrap_or_default();
|
||||
let port: u16 = cfg.get("smtp_port").and_then(|v| v.parse().ok()).unwrap_or(587);
|
||||
let port: u16 = cfg
|
||||
.get("smtp_port")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(587);
|
||||
let username = cfg.get("smtp_username").cloned().unwrap_or_default();
|
||||
let password = cfg.get("smtp_password").cloned().unwrap_or_default();
|
||||
let from_addr = cfg.get("smtp_from").cloned().unwrap_or_default();
|
||||
let tls_mode = cfg.get("smtp_tls_mode").cloned().unwrap_or_else(|| "starttls".to_string());
|
||||
let tls_mode = cfg
|
||||
.get("smtp_tls_mode")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "starttls".to_string());
|
||||
|
||||
if host.is_empty() || from_addr.is_empty() {
|
||||
return Ok(Json(json!({
|
||||
@ -628,7 +639,9 @@ async fn send_smtp_test(
|
||||
from_addr: &str,
|
||||
tls_mode: &str,
|
||||
) -> Result<(), String> {
|
||||
let from_mailbox: Mailbox = from_addr.parse().map_err(|e| format!("Invalid from address: {}", e))?;
|
||||
let from_mailbox: Mailbox = from_addr
|
||||
.parse()
|
||||
.map_err(|e| format!("Invalid from address: {}", e))?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from_mailbox.clone())
|
||||
@ -644,33 +657,39 @@ async fn send_smtp_test(
|
||||
.map_err(|e| format!("TLS relay error: {}", e))?;
|
||||
builder = builder.port(port);
|
||||
if !username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
builder = builder
|
||||
.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
}
|
||||
let transport = builder.build();
|
||||
transport.send(email).await
|
||||
}
|
||||
},
|
||||
"starttls" => {
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)
|
||||
.map_err(|e| format!("STARTTLS relay error: {}", e))?;
|
||||
builder = builder.port(port);
|
||||
if !username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
builder = builder
|
||||
.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
}
|
||||
let transport = builder.build();
|
||||
transport.send(email).await
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// "none" — plaintext / no TLS
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(port);
|
||||
let mut builder =
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(host).port(port);
|
||||
if !username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
builder = builder
|
||||
.credentials(Credentials::new(username.to_string(), password.to_string()));
|
||||
}
|
||||
let transport = builder.build();
|
||||
transport.send(email).await
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
result.map(|_| ()).map_err(|e| format!("SMTP send error: {}", e))
|
||||
result
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("SMTP send error: {}", e))
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@ -713,12 +732,12 @@ async fn update_ip_whitelist(
|
||||
|
||||
// Validate each entry
|
||||
for entry in &req.entries {
|
||||
if entry.parse::<ipnet::IpNet>().is_err()
|
||||
&& entry.parse::<std::net::IpAddr>().is_err()
|
||||
{
|
||||
if entry.parse::<ipnet::IpNet>().is_err() && entry.parse::<std::net::IpAddr>().is_err() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": { "code": "bad_request", "message": format!("Invalid CIDR or IP: {}", entry) } })),
|
||||
Json(
|
||||
json!({ "error": { "code": "bad_request", "message": format!("Invalid CIDR or IP: {}", entry) } }),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,7 @@
|
||||
//!
|
||||
//! GET /api/v1/status/fleet — aggregate health and patch summary across all hosts.
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
|
||||
@ -15,11 +15,11 @@ use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout};
|
||||
use pm_core::{
|
||||
audit::{log_event, AuditAction},
|
||||
models::{User, CreateUserRequest, UpdateUserRequest},
|
||||
models::{CreateUserRequest, UpdateUserRequest, User},
|
||||
};
|
||||
use pm_auth::{hash_password, rbac::AuthUser, session::force_logout};
|
||||
use serde_json::{json, Value};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -38,7 +38,10 @@ async fn list_users(
|
||||
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" } }))));
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, User>(
|
||||
@ -52,7 +55,10 @@ async fn list_users(
|
||||
.map(Json)
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -62,14 +68,24 @@ async fn create_user(
|
||||
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" } }))));
|
||||
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() } })))
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let role = if req.role == "admin" { "admin" } else { "operator" };
|
||||
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)
|
||||
@ -84,12 +100,29 @@ async fn create_user(
|
||||
.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 } })))
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -108,12 +141,18 @@ async fn get_user(
|
||||
) -> 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" } }))));
|
||||
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>)> {
|
||||
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,
|
||||
@ -125,10 +164,18 @@ async fn fetch_user(pool: &sqlx::PgPool, id: Uuid) -> Result<Json<User>, (Status
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })))
|
||||
(
|
||||
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" } }))))
|
||||
user.map(Json).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": { "code": "not_found", "message": "User not found" } })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_user(
|
||||
@ -138,14 +185,25 @@ async fn update_user(
|
||||
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" } }))));
|
||||
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" } }))));
|
||||
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 role_str = req
|
||||
.role
|
||||
.as_deref()
|
||||
.map(|r| if r == "admin" { "admin" } else { "operator" });
|
||||
|
||||
let rows = sqlx::query(
|
||||
r#"UPDATE users SET
|
||||
@ -163,15 +221,33 @@ async fn update_user(
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } }))))?
|
||||
.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" } }))));
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -182,23 +258,51 @@ async fn delete_user(
|
||||
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" } }))));
|
||||
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" } }))));
|
||||
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() } }))))?
|
||||
.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" } }))));
|
||||
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;
|
||||
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" })))
|
||||
}
|
||||
@ -209,11 +313,20 @@ async fn revoke_user_sessions(
|
||||
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" } }))));
|
||||
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() } }))))?;
|
||||
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 })))
|
||||
Ok(Json(
|
||||
json!({ "message": "Sessions revoked", "count": count }),
|
||||
))
|
||||
}
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
//! GET /api/v1/ws/jobs — browser WebSocket endpoint (ticket-authenticated)
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
extract::ws::{Message, WebSocket},
|
||||
extract::{Query, State, WebSocketUpgrade},
|
||||
http::StatusCode,
|
||||
response::{Json, Response},
|
||||
routing::{get, post},
|
||||
@ -59,7 +59,6 @@ fn err(
|
||||
|
||||
// ── POST /api/v1/ws/ticket ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/// Issue a single-use WebSocket authentication ticket (60 s expiry).
|
||||
pub async fn create_ticket_handler(
|
||||
State(state): State<AppState>,
|
||||
@ -109,7 +108,7 @@ pub async fn ws_handler(
|
||||
"invalid_ticket",
|
||||
"WebSocket ticket not found or already used",
|
||||
));
|
||||
}
|
||||
},
|
||||
Some(t) => {
|
||||
if t.expires_at < Utc::now() {
|
||||
drop(t);
|
||||
@ -121,7 +120,7 @@ pub async fn ws_handler(
|
||||
));
|
||||
}
|
||||
t.clone()
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
// Single-use: remove immediately after validation.
|
||||
@ -140,11 +139,7 @@ pub async fn ws_handler(
|
||||
// ── WebSocket handler ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Drive the browser WebSocket: LISTEN on `job_update` and forward payloads.
|
||||
async fn handle_browser_ws(
|
||||
mut socket: WebSocket,
|
||||
db: sqlx::PgPool,
|
||||
ticket: WsTicket,
|
||||
) {
|
||||
async fn handle_browser_ws(mut socket: WebSocket, db: sqlx::PgPool, ticket: WsTicket) {
|
||||
// Acquire a dedicated PG listener connection.
|
||||
let mut listener = match PgListener::connect_with(&db).await {
|
||||
Ok(l) => l,
|
||||
@ -156,7 +151,7 @@ async fn handle_browser_ws(
|
||||
))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = listener.listen("job_update").await {
|
||||
|
||||
Reference in New Issue
Block a user