feat(server): operator removal of stale sessions/machines (SPEC-004 Task 5, server)
All checks were successful
All checks were successful
Admin-gated soft-delete + purge so operators can clear ghost machines/sessions (the ~15-rows-for-one-host accumulation) from the console. - migration 009: deleted_at on connect_sessions + connect_machines, with partial indexes WHERE deleted_at IS NULL. - DELETE /api/machines/:agent_id?purge=true and DELETE /api/sessions/:id?purge=true soft-delete the row and purge the in-memory session (remove_session); the non-purge path keeps the legacy hard-delete / live-only disconnect. POST /api/machines/bulk-remove handles multi-select (batch cap 500). All admin-gated (AdminUser -> 403; tightens the prior any-user delete) and audited to connect_session_events (actor + target + trusted client IP). - list/get queries filter deleted_at IS NULL so removed units leave the console; upsert revives (deleted_at = NULL) a genuinely-reconnecting machine. The keyed-reattach identity resolver (get_machine_by_id) is intentionally unfiltered. Dashboard removal UI is the A3b follow-up. 86 server tests pass; fmt/clippy/test clean. Implements specs/v2-stable-identity/plan.md Task 5 (server portion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ pub mod proto {
|
||||
use anyhow::Result;
|
||||
use axum::http::{HeaderValue, Method};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Json, Path, Query, Request, State},
|
||||
extract::{ConnectInfo, Json, Path, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::{self as axum_middleware, Next},
|
||||
response::IntoResponse,
|
||||
@@ -454,7 +454,9 @@ async fn main() -> Result<()> {
|
||||
// REST API - Sessions
|
||||
.route("/api/sessions", get(list_sessions))
|
||||
.route("/api/sessions/:id", get(get_session))
|
||||
.route("/api/sessions/:id", delete(disconnect_session))
|
||||
// DELETE: live-only disconnect by default; `?purge=true` soft-deletes +
|
||||
// removes in-memory + audits (admin-only). Task 5 (api::removal).
|
||||
.route("/api/sessions/:id", delete(api::removal::remove_session))
|
||||
// Session-scoped viewer-token minting (dashboard JWT; bound to one session)
|
||||
.route(
|
||||
"/api/sessions/:id/viewer-token",
|
||||
@@ -462,8 +464,20 @@ async fn main() -> Result<()> {
|
||||
)
|
||||
// REST API - Machines
|
||||
.route("/api/machines", get(list_machines))
|
||||
// Bulk operator removal (admin-only). Registered before the `:agent_id`
|
||||
// routes; matchit (axum 0.7) prefers the static `bulk-remove` segment over
|
||||
// the `:agent_id` capture, so it never shadows a real agent_id. Task 5.
|
||||
.route(
|
||||
"/api/machines/bulk-remove",
|
||||
post(api::removal::bulk_remove_machines),
|
||||
)
|
||||
.route("/api/machines/:agent_id", get(get_machine))
|
||||
.route("/api/machines/:agent_id", delete(delete_machine))
|
||||
// DELETE: legacy hard-delete by default; `?purge=true` soft-deletes +
|
||||
// removes in-memory + audits (admin-only). Task 5 (api::removal).
|
||||
.route(
|
||||
"/api/machines/:agent_id",
|
||||
delete(api::removal::remove_machine),
|
||||
)
|
||||
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
||||
.route(
|
||||
"/api/machines/:agent_id/update",
|
||||
@@ -740,28 +754,6 @@ async fn get_session(
|
||||
Ok(Json(api::SessionInfo::from(session)))
|
||||
}
|
||||
|
||||
async fn disconnect_session(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let session_id = match uuid::Uuid::parse_str(&id) {
|
||||
Ok(id) => id,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid session ID"),
|
||||
};
|
||||
|
||||
if state
|
||||
.sessions
|
||||
.disconnect_session(session_id, "Disconnected by administrator")
|
||||
.await
|
||||
{
|
||||
info!("Session {} disconnected by admin", session_id);
|
||||
(StatusCode::OK, "Session disconnected")
|
||||
} else {
|
||||
(StatusCode::NOT_FOUND, "Session not found")
|
||||
}
|
||||
}
|
||||
|
||||
// Machine API handlers
|
||||
|
||||
async fn list_machines(
|
||||
@@ -836,89 +828,6 @@ async fn get_machine_history(
|
||||
Ok(Json(history))
|
||||
}
|
||||
|
||||
async fn delete_machine(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
Query(params): Query<api::DeleteMachineParams>,
|
||||
) -> Result<Json<api::DeleteMachineResponse>, (StatusCode, &'static str)> {
|
||||
let db = state
|
||||
.db
|
||||
.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get machine first
|
||||
let machine = db::machines::get_machine_by_agent_id(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Machine not found"))?;
|
||||
|
||||
// Export history if requested
|
||||
let history = if params.export {
|
||||
let sessions = db::sessions::get_sessions_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
let events = db::events::get_events_for_machine(db.pool(), machine.id)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?;
|
||||
|
||||
Some(api::MachineHistory {
|
||||
machine: api::MachineInfo::from(machine.clone()),
|
||||
sessions: sessions.into_iter().map(api::SessionRecord::from).collect(),
|
||||
events: events.into_iter().map(api::EventRecord::from).collect(),
|
||||
exported_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Send uninstall command if requested and agent is online
|
||||
let mut uninstall_sent = false;
|
||||
if params.uninstall {
|
||||
// Find session for this agent
|
||||
if let Some(session) = state.sessions.get_session_by_agent(&agent_id).await {
|
||||
if session.is_online {
|
||||
uninstall_sent = state
|
||||
.sessions
|
||||
.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUninstall,
|
||||
"Deleted by administrator",
|
||||
)
|
||||
.await;
|
||||
if uninstall_sent {
|
||||
info!("Sent uninstall command to agent {}", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from session manager
|
||||
state.sessions.remove_agent(&agent_id).await;
|
||||
|
||||
// Delete from database (cascades to sessions and events)
|
||||
db::machines::delete_machine(db.pool(), &agent_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to delete machine",
|
||||
)
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"Deleted machine {} (uninstall_sent: {})",
|
||||
agent_id, uninstall_sent
|
||||
);
|
||||
|
||||
Ok(Json(api::DeleteMachineResponse {
|
||||
success: true,
|
||||
message: format!("Machine {} deleted", machine.hostname),
|
||||
uninstall_sent,
|
||||
history,
|
||||
}))
|
||||
}
|
||||
|
||||
// Update trigger request
|
||||
#[derive(Deserialize)]
|
||||
struct TriggerUpdateRequest {
|
||||
|
||||
Reference in New Issue
Block a user