     1→//! Agent management API endpoints
     2→
     3→use axum::{
     4→    extract::{Path, State},
     5→    http::StatusCode,
     6→    Json,
     7→};
     8→use serde::{Deserialize, Serialize};
     9→use uuid::Uuid;
    10→
    11→use tracing::info;
    12→
    13→use crate::auth::AuthUser;
    14→use crate::db::{self, AgentResponse, AgentStats};
    15→use crate::ws::{generate_api_key, hash_api_key, ServerMessage, UpdatePayload};
    16→use crate::AppState;
    17→
    18→/// Response for agent registration
    19→#[derive(Debug, Serialize)]
    20→pub struct RegisterAgentResponse {
    21→    pub agent_id: Uuid,
    22→    pub api_key: String,
    23→    pub message: String,
    24→}
    25→
    26→/// Request to register a new agent
    27→#[derive(Debug, Deserialize)]
    28→pub struct RegisterAgentRequest {
    29→    pub hostname: String,
    30→    pub os_type: String,
    31→    pub os_version: Option<String>,
    32→}
    33→
    34→/// Register a new agent (generates API key)
    35→pub async fn register_agent(
    36→    State(state): State<AppState>,
    37→    Json(req): Json<RegisterAgentRequest>,
    38→) -> Result<Json<RegisterAgentResponse>, (StatusCode, String)> {
    39→    // Generate a new API key
    40→    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    41→    let api_key_hash = hash_api_key(&api_key);
    42→
    43→    // Create the agent
    44→    let create = db::CreateAgent {
    45→        hostname: req.hostname,
    46→        api_key_hash,
    47→        os_type: req.os_type,
    48→        os_version: req.os_version,
    49→        agent_version: None,
    50→    };
    51→
    52→    let agent = db::create_agent(&state.db, create)
    53→        .await
    54→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    55→
    56→    Ok(Json(RegisterAgentResponse {
    57→        agent_id: agent.id,
    58→        api_key, // Return the plain API key (only shown once!)
    59→        message: "Agent registered successfully. Save the API key - it will not be shown again."
    60→            .to_string(),
    61→    }))
    62→}
    63→
    64→/// List all agents
    65→pub async fn list_agents(
    66→    State(state): State<AppState>,
    67→) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
    68→    let agents = db::get_all_agents(&state.db)
    69→        .await
    70→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    71→
    72→    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
    73→    Ok(Json(responses))
    74→}
    75→
    76→/// Get a specific agent
    77→pub async fn get_agent(
    78→    State(state): State<AppState>,
    79→    Path(id): Path<Uuid>,
    80→) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    81→    let agent = db::get_agent_by_id(&state.db, id)
    82→        .await
    83→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
    84→        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
    85→
    86→    Ok(Json(agent.into()))
    87→}
    88→
    89→/// Delete an agent
    90→pub async fn delete_agent(
    91→    State(state): State<AppState>,
    92→    Path(id): Path<Uuid>,
    93→) -> Result<StatusCode, (StatusCode, String)> {
    94→    // Check if agent is connected and disconnect it
    95→    if state.agents.read().await.is_connected(&id) {
    96→        // In a real implementation, we'd send a disconnect message
    97→        state.agents.write().await.remove(&id);
    98→    }
    99→
   100→    let deleted = db::delete_agent(&state.db, id)
   101→        .await
   102→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   103→
   104→    if deleted {
   105→        Ok(StatusCode::NO_CONTENT)
   106→    } else {
   107→        Err((StatusCode::NOT_FOUND, "Agent not found".to_string()))
   108→    }
   109→}
   110→
   111→/// Get agent statistics
   112→pub async fn get_stats(
   113→    State(state): State<AppState>,
   114→) -> Result<Json<AgentStats>, (StatusCode, String)> {
   115→    let stats = db::get_agent_stats(&state.db)
   116→        .await
   117→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   118→
   119→    Ok(Json(stats))
   120→}
   121→
   122→/// Request to move an agent to a different site
   123→#[derive(Debug, Deserialize)]
   124→pub struct MoveAgentRequest {
   125→    pub site_id: Option<Uuid>, // None to unassign from site
   126→}
   127→
   128→/// Move an agent to a different site
   129→pub async fn move_agent(
   130→    State(state): State<AppState>,
   131→    Path(id): Path<Uuid>,
   132→    Json(req): Json<MoveAgentRequest>,
   133→) -> Result<Json<AgentResponse>, (StatusCode, String)> {
   134→    // Verify the site exists if provided
   135→    if let Some(site_id) = req.site_id {
   136→        let site = db::get_site_by_id(&state.db, site_id)
   137→            .await
   138→            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   139→
   140→        if site.is_none() {
   141→            return Err((StatusCode::NOT_FOUND, "Site not found".to_string()));
   142→        }
   143→    }
   144→
   145→    // Move the agent
   146→    let agent = db::move_agent_to_site(&state.db, id, req.site_id)
   147→        .await
   148→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   149→        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
   150→
   151→    Ok(Json(agent.into()))
   152→}
   153→
   154→/// List all agents with full details (site/client info)
   155→pub async fn list_agents_with_details(
   156→    State(state): State<AppState>,
   157→) -> Result<Json<Vec<db::AgentWithDetails>>, (StatusCode, String)> {
   158→    let agents = db::get_all_agents_with_details(&state.db)
   159→        .await
   160→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   161→
   162→    Ok(Json(agents))
   163→}
   164→
   165→/// List unassigned agents (not belonging to any site)
   166→pub async fn list_unassigned_agents(
   167→    State(state): State<AppState>,
   168→) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
   169→    let agents = db::get_unassigned_agents(&state.db)
   170→        .await
   171→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   172→
   173→    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
   174→    Ok(Json(responses))
   175→}
   176→
   177→/// Get extended state for an agent (network interfaces, uptime, etc.)
   178→pub async fn get_agent_state(
   179→    State(state): State<AppState>,
   180→    Path(id): Path<Uuid>,
   181→) -> Result<Json<db::AgentState>, (StatusCode, String)> {
   182→    let agent_state = db::get_agent_state(&state.db, id)
   183→        .await
   184→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   185→        .ok_or((StatusCode::NOT_FOUND, "Agent state not found".to_string()))?;
   186→
   187→    Ok(Json(agent_state))
   188→}
   189→
   190→// ============================================================================
   191→// Legacy Agent Endpoints (PowerShell agent for 2008 R2)
   192→// ============================================================================
   193→
   194→/// Request to register a legacy agent with site code
   195→#[derive(Debug, Deserialize)]
   196→pub struct RegisterLegacyRequest {
   197→    pub site_code: String,
   198→    pub hostname: String,
   199→    pub os_type: String,
   200→    pub os_version: Option<String>,
   201→    pub agent_version: Option<String>,
   202→    pub agent_type: Option<String>,
   203→}
   204→
   205→/// Response for legacy agent registration
   206→#[derive(Debug, Serialize)]
   207→pub struct RegisterLegacyResponse {
   208→    pub agent_id: Uuid,
   209→    pub api_key: String,
   210→    pub site_name: String,
   211→    pub client_name: String,
   212→    pub message: String,
   213→}
   214→
   215→/// Register a legacy agent using site code
   216→pub async fn register_legacy(
   217→    State(state): State<AppState>,
   218→    Json(req): Json<RegisterLegacyRequest>,
   219→) -> Result<Json<RegisterLegacyResponse>, (StatusCode, String)> {
   220→    // Look up site by code
   221→    let site = db::get_site_by_code(&state.db, &req.site_code)
   222→        .await
   223→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   224→        .ok_or((StatusCode::NOT_FOUND, format!("Site code '{}' not found", req.site_code)))?;
   225→
   226→    // Get client info
   227→    let client = db::get_client_by_id(&state.db, site.client_id)
   228→        .await
   229→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   230→        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;
   231→
   232→    // Generate API key for this agent
   233→    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
   234→    let api_key_hash = hash_api_key(&api_key);
   235→
   236→    // Create the agent
   237→    let create = db::CreateAgent {
   238→        hostname: req.hostname,
   239→        api_key_hash,
   240→        os_type: req.os_type,
   241→        os_version: req.os_version,
   242→        agent_version: req.agent_version,
   243→    };
   244→
   245→    let agent = db::create_agent(&state.db, create)
   246→        .await
   247→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   248→
   249→    // Assign agent to site
   250→    db::move_agent_to_site(&state.db, agent.id, Some(site.id))
   251→        .await
   252→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   253→
   254→    tracing::info!(
   255→        "Legacy agent registered: {} ({}) -> Site: {} ({})",
   256→        agent.hostname,
   257→        agent.id,
   258→        site.name,
   259→        req.site_code
   260→    );
   261→
   262→    Ok(Json(RegisterLegacyResponse {
   263→        agent_id: agent.id,
   264→        api_key,
   265→        site_name: site.name,
   266→        client_name: client.name,
   267→        message: "Agent registered successfully".to_string(),
   268→    }))
   269→}
   270→
   271→/// Heartbeat request from legacy agent
   272→#[derive(Debug, Deserialize)]
   273→pub struct HeartbeatRequest {
   274→    pub agent_id: Uuid,
   275→    pub timestamp: String,
   276→    pub system_info: serde_json::Value,
   277→}
   278→
   279→/// Heartbeat response with pending commands
   280→#[derive(Debug, Serialize)]
   281→pub struct HeartbeatResponse {
   282→    pub success: bool,
   283→    pub pending_commands: Vec<PendingCommand>,
   284→}
   285→
   286→#[derive(Debug, Serialize)]
   287→pub struct PendingCommand {
   288→    pub id: Uuid,
   289→    #[serde(rename = "type")]
   290→    pub cmd_type: String,
   291→    pub script: String,
   292→}
   293→
   294→/// Receive heartbeat from legacy agent
   295→pub async fn heartbeat(
   296→    State(state): State<AppState>,
   297→    Json(req): Json<HeartbeatRequest>,
   298→) -> Result<Json<HeartbeatResponse>, (StatusCode, String)> {
   299→    // Update agent last_seen
   300→    db::update_agent_last_seen(&state.db, req.agent_id)
   301→        .await
   302→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   303→
   304→    // TODO: Store system_info metrics, get pending commands
   305→    // For now, return empty pending commands
   306→    Ok(Json(HeartbeatResponse {
   307→        success: true,
   308→        pending_commands: vec![],
   309→    }))
   310→}
   311→
   312→/// Command result from legacy agent
   313→#[derive(Debug, Deserialize)]
   314→pub struct CommandResultRequest {
   315→    pub command_id: Uuid,
   316→    pub started_at: String,
   317→    pub completed_at: String,
   318→    pub success: bool,
   319→    pub output: String,
   320→    pub error: Option<String>,
   321→}
   322→
   323→/// Receive command execution result
   324→pub async fn command_result(
   325→    State(_state): State<AppState>,
   326→    Json(_req): Json<CommandResultRequest>,
   327→) -> Result<StatusCode, (StatusCode, String)> {
   328→    // TODO: Store command result in database
   329→    Ok(StatusCode::OK)
   330→}
   331→
   332→/// Response for trigger update
   333→#[derive(Debug, Serialize)]
   334→pub struct TriggerUpdateResponse {
   335→    pub success: bool,
   336→    pub update_id: Option<Uuid>,
   337→    pub target_version: Option<String>,
   338→    pub message: String,
   339→}
   340→
   341→/// Trigger an update for a specific agent
   342→///
   343→/// Requires JWT authentication. The user who triggered the update is logged.
   344→pub async fn trigger_update(
   345→    State(state): State<AppState>,
   346→    auth: AuthUser,
   347→    Path(agent_id): Path<Uuid>,
   348→) -> Result<Json<TriggerUpdateResponse>, (StatusCode, String)> {
   349→    info!("Update trigger: user={} agent={}", auth.user_id, agent_id);
   350→
   351→    // Get agent info
   352→    let agent = db::get_agent_by_id(&state.db, agent_id)
   353→        .await
   354→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   355→        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;
   356→
   357→    // Check if agent is connected
   358→    let agents = state.agents.read().await;
   359→    if !agents.is_connected(&agent_id) {
   360→        return Ok(Json(TriggerUpdateResponse {
   361→            success: false,
   362→            update_id: None,
   363→            target_version: None,
   364→            message: "Agent is offline".to_string(),
   365→        }));
   366→    }
   367→
   368→    // Determine architecture (default to amd64 for older agents)
   369→    let arch = "amd64"; // TODO: Store/retrieve from agent state
   370→
   371→    // Get current version (default to 0.0.0 if not set)
   372→    let current_version = agent.agent_version.as_deref().unwrap_or("0.0.0");
   373→
   374→    // Check if update is available
   375→    let available = match state.updates.needs_update(
   376→        current_version,
   377→        &agent.os_type,
   378→        arch,
   379→    ).await {
   380→        Some(v) => v,
   381→        None => {
   382→            return Ok(Json(TriggerUpdateResponse {
   383→                success: false,
   384→                update_id: None,
   385→                target_version: None,
   386→                message: format!("Agent already at latest version ({})", current_version),
   387→            }));
   388→        }
   389→    };
   390→
   391→    let update_id = Uuid::new_v4();
   392→
   393→    // Record update in database
   394→    if let Err(e) = db::create_agent_update(
   395→        &state.db,
   396→        agent_id,
   397→        update_id,
   398→        current_version,
   399→        &available.version.to_string(),
   400→        &available.download_url,
   401→        &available.checksum_sha256,
   402→    ).await {
   403→        return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to record update: {}", e)));
   404→    }
   405→
   406→    // Send update command
   407→    let update_msg = ServerMessage::Update(UpdatePayload {
   408→        update_id,
   409→        target_version: available.version.to_string(),
   410→        download_url: available.download_url.clone(),
   411→        checksum_sha256: available.checksum_sha256.clone(),
   412→        force: false,
   413→    });
   414→
   415→    if agents.send_to(&agent_id, update_msg).await {
   416→        Ok(Json(TriggerUpdateResponse {
   417→            success: true,
   418→            update_id: Some(update_id),
   419→            target_version: Some(available.version.to_string()),
   420→            message: format!("Update triggered: {} -> {}", current_version, available.version),
   421→        }))
   422→    } else {
   423→        Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command".to_string()))
   424→    }
   425→}
   426→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Agent management API endpoints

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use tracing::info;

use crate::auth::AuthUser;
use crate::db::{self, AgentResponse, AgentStats};
use crate::ws::{generate_api_key, hash_api_key, ServerMessage, UpdatePayload};
use crate::AppState;

/// Response for agent registration
#[derive(Debug, Serialize)]
pub struct RegisterAgentResponse {
    pub agent_id: Uuid,
    pub api_key: String,
    pub message: String,
}

/// Request to register a new agent
#[derive(Debug, Deserialize)]
pub struct RegisterAgentRequest {
    pub hostname: String,
    pub os_type: String,
    pub os_version: Option<String>,
}

/// Register a new agent (generates API key)
pub async fn register_agent(
    State(state): State<AppState>,
    Json(req): Json<RegisterAgentRequest>,
) -> Result<Json<RegisterAgentResponse>, (StatusCode, String)> {
    // Generate a new API key
    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    let api_key_hash = hash_api_key(&api_key);

    // Create the agent
    let create = db::CreateAgent {
        hostname: req.hostname,
        api_key_hash,
        os_type: req.os_type,
        os_version: req.os_version,
        agent_version: None,
    };

    let agent = db::create_agent(&state.db, create)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(RegisterAgentResponse {
        agent_id: agent.id,
        api_key, // Return the plain API key (only shown once!)
        message: "Agent registered successfully. Save the API key - it will not be shown again."
            .to_string(),
    }))
}

/// List all agents
pub async fn list_agents(
    State(state): State<AppState>,
) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
    let agents = db::get_all_agents(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
    Ok(Json(responses))
}

/// Get a specific agent
pub async fn get_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    let agent = db::get_agent_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;

    Ok(Json(agent.into()))
}

/// Delete an agent
pub async fn delete_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    // Check if agent is connected and disconnect it
    if state.agents.read().await.is_connected(&id) {
        // In a real implementation, we'd send a disconnect message
        state.agents.write().await.remove(&id);
    }

    let deleted = db::delete_agent(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "Agent not found".to_string()))
    }
}

/// Get agent statistics
pub async fn get_stats(
    State(state): State<AppState>,
) -> Result<Json<AgentStats>, (StatusCode, String)> {
    let stats = db::get_agent_stats(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(stats))
}

/// Request to move an agent to a different site
#[derive(Debug, Deserialize)]
pub struct MoveAgentRequest {
    pub site_id: Option<Uuid>, // None to unassign from site
}

/// Move an agent to a different site
pub async fn move_agent(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<MoveAgentRequest>,
) -> Result<Json<AgentResponse>, (StatusCode, String)> {
    // Verify the site exists if provided
    if let Some(site_id) = req.site_id {
        let site = db::get_site_by_id(&state.db, site_id)
            .await
            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

        if site.is_none() {
            return Err((StatusCode::NOT_FOUND, "Site not found".to_string()));
        }
    }

    // Move the agent
    let agent = db::move_agent_to_site(&state.db, id, req.site_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;

    Ok(Json(agent.into()))
}

/// List all agents with full details (site/client info)
pub async fn list_agents_with_details(
    State(state): State<AppState>,
) -> Result<Json<Vec<db::AgentWithDetails>>, (StatusCode, String)> {
    let agents = db::get_all_agents_with_details(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(agents))
}

/// List unassigned agents (not belonging to any site)
pub async fn list_unassigned_agents(
    State(state): State<AppState>,
) -> Result<Json<Vec<AgentResponse>>, (StatusCode, String)> {
    let agents = db::get_unassigned_agents(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<AgentResponse> = agents.into_iter().map(|a| a.into()).collect();
    Ok(Json(responses))
}

/// Get extended state for an agent (network interfaces, uptime, etc.)
pub async fn get_agent_state(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<db::AgentState>, (StatusCode, String)> {
    let agent_state = db::get_agent_state(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent state not found".to_string()))?;

    Ok(Json(agent_state))
}

// ============================================================================
// Legacy Agent Endpoints (PowerShell agent for 2008 R2)
// ============================================================================

/// Request to register a legacy agent with site code
#[derive(Debug, Deserialize)]
pub struct RegisterLegacyRequest {
    pub site_code: String,
    pub hostname: String,
    pub os_type: String,
    pub os_version: Option<String>,
    pub agent_version: Option<String>,
    pub agent_type: Option<String>,
}

/// Response for legacy agent registration
#[derive(Debug, Serialize)]
pub struct RegisterLegacyResponse {
    pub agent_id: Uuid,
    pub api_key: String,
    pub site_name: String,
    pub client_name: String,
    pub message: String,
}

/// Register a legacy agent using site code
pub async fn register_legacy(
    State(state): State<AppState>,
    Json(req): Json<RegisterLegacyRequest>,
) -> Result<Json<RegisterLegacyResponse>, (StatusCode, String)> {
    // Look up site by code
    let site = db::get_site_by_code(&state.db, &req.site_code)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, format!("Site code '{}' not found", req.site_code)))?;

    // Get client info
    let client = db::get_client_by_id(&state.db, site.client_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;

    // Generate API key for this agent
    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    let api_key_hash = hash_api_key(&api_key);

    // Create the agent
    let create = db::CreateAgent {
        hostname: req.hostname,
        api_key_hash,
        os_type: req.os_type,
        os_version: req.os_version,
        agent_version: req.agent_version,
    };

    let agent = db::create_agent(&state.db, create)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    // Assign agent to site
    db::move_agent_to_site(&state.db, agent.id, Some(site.id))
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    tracing::info!(
        "Legacy agent registered: {} ({}) -> Site: {} ({})",
        agent.hostname,
        agent.id,
        site.name,
        req.site_code
    );

    Ok(Json(RegisterLegacyResponse {
        agent_id: agent.id,
        api_key,
        site_name: site.name,
        client_name: client.name,
        message: "Agent registered successfully".to_string(),
    }))
}

/// Heartbeat request from legacy agent
#[derive(Debug, Deserialize)]
pub struct HeartbeatRequest {
    pub agent_id: Uuid,
    pub timestamp: String,
    pub system_info: serde_json::Value,
}

/// Heartbeat response with pending commands
#[derive(Debug, Serialize)]
pub struct HeartbeatResponse {
    pub success: bool,
    pub pending_commands: Vec<PendingCommand>,
}

#[derive(Debug, Serialize)]
pub struct PendingCommand {
    pub id: Uuid,
    #[serde(rename = "type")]
    pub cmd_type: String,
    pub script: String,
}

/// Receive heartbeat from legacy agent
pub async fn heartbeat(
    State(state): State<AppState>,
    Json(req): Json<HeartbeatRequest>,
) -> Result<Json<HeartbeatResponse>, (StatusCode, String)> {
    // Update agent last_seen
    db::update_agent_last_seen(&state.db, req.agent_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    // TODO: Store system_info metrics, get pending commands
    // For now, return empty pending commands
    Ok(Json(HeartbeatResponse {
        success: true,
        pending_commands: vec![],
    }))
}

/// Command result from legacy agent
#[derive(Debug, Deserialize)]
pub struct CommandResultRequest {
    pub command_id: Uuid,
    pub started_at: String,
    pub completed_at: String,
    pub success: bool,
    pub output: String,
    pub error: Option<String>,
}

/// Receive command execution result
pub async fn command_result(
    State(_state): State<AppState>,
    Json(_req): Json<CommandResultRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
    // TODO: Store command result in database
    Ok(StatusCode::OK)
}

/// Response for trigger update
#[derive(Debug, Serialize)]
pub struct TriggerUpdateResponse {
    pub success: bool,
    pub update_id: Option<Uuid>,
    pub target_version: Option<String>,
    pub message: String,
}

/// Trigger an update for a specific agent
///
/// Requires JWT authentication. The user who triggered the update is logged.
pub async fn trigger_update(
    State(state): State<AppState>,
    auth: AuthUser,
    Path(agent_id): Path<Uuid>,
) -> Result<Json<TriggerUpdateResponse>, (StatusCode, String)> {
    info!("Update trigger: user={} agent={}", auth.user_id, agent_id);

    // Get agent info
    let agent = db::get_agent_by_id(&state.db, agent_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Agent not found".to_string()))?;

    // Check if agent is connected
    let agents = state.agents.read().await;
    if !agents.is_connected(&agent_id) {
        return Ok(Json(TriggerUpdateResponse {
            success: false,
            update_id: None,
            target_version: None,
            message: "Agent is offline".to_string(),
        }));
    }

    // Determine architecture (default to amd64 for older agents)
    let arch = "amd64"; // TODO: Store/retrieve from agent state

    // Get current version (default to 0.0.0 if not set)
    let current_version = agent.agent_version.as_deref().unwrap_or("0.0.0");

    // Check if update is available
    let available = match state.updates.needs_update(
        current_version,
        &agent.os_type,
        arch,
    ).await {
        Some(v) => v,
        None => {
            return Ok(Json(TriggerUpdateResponse {
                success: false,
                update_id: None,
                target_version: None,
                message: format!("Agent already at latest version ({})", current_version),
            }));
        }
    };

    let update_id = Uuid::new_v4();

    // Record update in database
    if let Err(e) = db::create_agent_update(
        &state.db,
        agent_id,
        update_id,
        current_version,
        &available.version.to_string(),
        &available.download_url,
        &available.checksum_sha256,
    ).await {
        return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to record update: {}", e)));
    }

    // Send update command
    let update_msg = ServerMessage::Update(UpdatePayload {
        update_id,
        target_version: available.version.to_string(),
        download_url: available.download_url.clone(),
        checksum_sha256: available.checksum_sha256.clone(),
        force: false,
    });

    if agents.send_to(&agent_id, update_msg).await {
        Ok(Json(TriggerUpdateResponse {
            success: true,
            update_id: Some(update_id),
            target_version: Some(available.version.to_string()),
            message: format!("Update triggered: {} -> {}", current_version, available.version),
        }))
    } else {
        Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command".to_string()))
    }
}


     1→//! Site management API endpoints
     2→
     3→use axum::{
     4→    extract::{Path, State},
     5→    http::StatusCode,
     6→    Json,
     7→};
     8→use serde::{Deserialize, Serialize};
     9→use uuid::Uuid;
    10→
    11→use crate::auth::AuthUser;
    12→use crate::db;
    13→use crate::ws::{generate_api_key, hash_api_key};
    14→use crate::AppState;
    15→
    16→/// Response for site operations
    17→#[derive(Debug, Serialize)]
    18→pub struct SiteResponse {
    19→    pub id: Uuid,
    20→    pub client_id: Uuid,
    21→    pub client_name: Option<String>,
    22→    pub name: String,
    23→    pub site_code: String,
    24→    pub address: Option<String>,
    25→    pub notes: Option<String>,
    26→    pub is_active: bool,
    27→    pub created_at: chrono::DateTime<chrono::Utc>,
    28→    pub agent_count: i64,
    29→}
    30→
    31→/// Response when creating a site (includes one-time API key)
    32→#[derive(Debug, Serialize)]
    33→pub struct CreateSiteResponse {
    34→    pub site: SiteResponse,
    35→    /// The API key for agents at this site (shown only once!)
    36→    pub api_key: String,
    37→    pub message: String,
    38→}
    39→
    40→/// Request to create a new site
    41→#[derive(Debug, Deserialize)]
    42→pub struct CreateSiteRequest {
    43→    pub client_id: Uuid,
    44→    pub name: String,
    45→    pub address: Option<String>,
    46→    pub notes: Option<String>,
    47→}
    48→
    49→/// Request to update a site
    50→#[derive(Debug, Deserialize)]
    51→pub struct UpdateSiteRequest {
    52→    pub name: Option<String>,
    53→    pub address: Option<String>,
    54→    pub notes: Option<String>,
    55→    pub is_active: Option<bool>,
    56→}
    57→
    58→/// List all sites
    59→pub async fn list_sites(
    60→    _user: AuthUser,
    61→    State(state): State<AppState>,
    62→) -> Result<Json<Vec<SiteResponse>>, (StatusCode, String)> {
    63→    let sites = db::get_all_sites_with_details(&state.db)
    64→        .await
    65→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    66→
    67→    let responses: Vec<SiteResponse> = sites
    68→        .into_iter()
    69→        .map(|s| SiteResponse {
    70→            id: s.id,
    71→            client_id: s.client_id,
    72→            client_name: Some(s.client_name),
    73→            name: s.name,
    74→            site_code: s.site_code,
    75→            address: s.address,
    76→            notes: s.notes,
    77→            is_active: s.is_active,
    78→            created_at: s.created_at,
    79→            agent_count: s.agent_count,
    80→        })
    81→        .collect();
    82→
    83→    Ok(Json(responses))
    84→}
    85→
    86→/// List sites for a specific client
    87→pub async fn list_sites_by_client(
    88→    _user: AuthUser,
    89→    State(state): State<AppState>,
    90→    Path(client_id): Path<Uuid>,
    91→) -> Result<Json<Vec<SiteResponse>>, (StatusCode, String)> {
    92→    let sites = db::get_sites_by_client(&state.db, client_id)
    93→        .await
    94→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    95→
    96→    let responses: Vec<SiteResponse> = sites
    97→        .into_iter()
    98→        .map(|s| SiteResponse {
    99→            id: s.id,
   100→            client_id: s.client_id,
   101→            client_name: None,
   102→            name: s.name,
   103→            site_code: s.site_code,
   104→            address: s.address,
   105→            notes: s.notes,
   106→            is_active: s.is_active,
   107→            created_at: s.created_at,
   108→            agent_count: 0, // Would need separate query
   109→        })
   110→        .collect();
   111→
   112→    Ok(Json(responses))
   113→}
   114→
   115→/// Create a new site
   116→pub async fn create_site(
   117→    _user: AuthUser,
   118→    State(state): State<AppState>,
   119→    Json(req): Json<CreateSiteRequest>,
   120→) -> Result<Json<CreateSiteResponse>, (StatusCode, String)> {
   121→    // Verify client exists
   122→    let client = db::get_client_by_id(&state.db, req.client_id)
   123→        .await
   124→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   125→        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;
   126→
   127→    // Generate unique site code and API key
   128→    let site_code = db::generate_unique_site_code(&state.db)
   129→        .await
   130→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   131→
   132→    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
   133→    let api_key_hash = hash_api_key(&api_key);
   134→
   135→    let create = db::CreateSiteInternal {
   136→        client_id: req.client_id,
   137→        name: req.name,
   138→        site_code: site_code.clone(),
   139→        api_key_hash,
   140→        address: req.address,
   141→        notes: req.notes,
   142→    };
   143→
   144→    let site = db::create_site(&state.db, create)
   145→        .await
   146→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   147→
   148→    Ok(Json(CreateSiteResponse {
   149→        site: SiteResponse {
   150→            id: site.id,
   151→            client_id: site.client_id,
   152→            client_name: Some(client.name),
   153→            name: site.name,
   154→            site_code: site.site_code,
   155→            address: site.address,
   156→            notes: site.notes,
   157→            is_active: site.is_active,
   158→            created_at: site.created_at,
   159→            agent_count: 0,
   160→        },
   161→        api_key,
   162→        message: "Site created. Save the API key - it will not be shown again.".to_string(),
   163→    }))
   164→}
   165→
   166→/// Get a specific site
   167→pub async fn get_site(
   168→    _user: AuthUser,
   169→    State(state): State<AppState>,
   170→    Path(id): Path<Uuid>,
   171→) -> Result<Json<SiteResponse>, (StatusCode, String)> {
   172→    let site = db::get_site_by_id(&state.db, id)
   173→        .await
   174→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   175→        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;
   176→
   177→    // Get client name and agent count
   178→    let client = db::get_client_by_id(&state.db, site.client_id)
   179→        .await
   180→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   181→
   182→    let agents = db::get_agents_by_site(&state.db, id)
   183→        .await
   184→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   185→
   186→    Ok(Json(SiteResponse {
   187→        id: site.id,
   188→        client_id: site.client_id,
   189→        client_name: client.map(|c| c.name),
   190→        name: site.name,
   191→        site_code: site.site_code,
   192→        address: site.address,
   193→        notes: site.notes,
   194→        is_active: site.is_active,
   195→        created_at: site.created_at,
   196→        agent_count: agents.len() as i64,
   197→    }))
   198→}
   199→
   200→/// Update a site
   201→pub async fn update_site(
   202→    _user: AuthUser,
   203→    State(state): State<AppState>,
   204→    Path(id): Path<Uuid>,
   205→    Json(req): Json<UpdateSiteRequest>,
   206→) -> Result<Json<SiteResponse>, (StatusCode, String)> {
   207→    let update = db::UpdateSite {
   208→        name: req.name,
   209→        address: req.address,
   210→        notes: req.notes,
   211→        is_active: req.is_active,
   212→    };
   213→
   214→    let site = db::update_site(&state.db, id, update)
   215→        .await
   216→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   217→        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;
   218→
   219→    Ok(Json(SiteResponse {
   220→        id: site.id,
   221→        client_id: site.client_id,
   222→        client_name: None,
   223→        name: site.name,
   224→        site_code: site.site_code,
   225→        address: site.address,
   226→        notes: site.notes,
   227→        is_active: site.is_active,
   228→        created_at: site.created_at,
   229→        agent_count: 0,
   230→    }))
   231→}
   232→
   233→/// Regenerate API key for a site
   234→#[derive(Debug, Serialize)]
   235→pub struct RegenerateApiKeyResponse {
   236→    pub api_key: String,
   237→    pub message: String,
   238→}
   239→
   240→pub async fn regenerate_api_key(
   241→    _user: AuthUser,
   242→    State(state): State<AppState>,
   243→    Path(id): Path<Uuid>,
   244→) -> Result<Json<RegenerateApiKeyResponse>, (StatusCode, String)> {
   245→    // Verify site exists
   246→    let _site = db::get_site_by_id(&state.db, id)
   247→        .await
   248→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
   249→        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;
   250→
   251→    // Generate new API key
   252→    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
   253→    let api_key_hash = hash_api_key(&api_key);
   254→
   255→    db::regenerate_site_api_key(&state.db, id, &api_key_hash)
   256→        .await
   257→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   258→
   259→    Ok(Json(RegenerateApiKeyResponse {
   260→        api_key,
   261→        message: "API key regenerated. Save it - it will not be shown again. Existing agents will need to be reconfigured.".to_string(),
   262→    }))
   263→}
   264→
   265→/// Delete a site
   266→pub async fn delete_site(
   267→    _user: AuthUser,
   268→    State(state): State<AppState>,
   269→    Path(id): Path<Uuid>,
   270→) -> Result<StatusCode, (StatusCode, String)> {
   271→    let deleted = db::delete_site(&state.db, id)
   272→        .await
   273→        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
   274→
   275→    if deleted {
   276→        Ok(StatusCode::NO_CONTENT)
   277→    } else {
   278→        Err((StatusCode::NOT_FOUND, "Site not found".to_string()))
   279→    }
   280→}
   281→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Site management API endpoints

use axum::{
    extract::{Path, State},
    http::StatusCode,
    Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::auth::AuthUser;
use crate::db;
use crate::ws::{generate_api_key, hash_api_key};
use crate::AppState;

/// Response for site operations
#[derive(Debug, Serialize)]
pub struct SiteResponse {
    pub id: Uuid,
    pub client_id: Uuid,
    pub client_name: Option<String>,
    pub name: String,
    pub site_code: String,
    pub address: Option<String>,
    pub notes: Option<String>,
    pub is_active: bool,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub agent_count: i64,
}

/// Response when creating a site (includes one-time API key)
#[derive(Debug, Serialize)]
pub struct CreateSiteResponse {
    pub site: SiteResponse,
    /// The API key for agents at this site (shown only once!)
    pub api_key: String,
    pub message: String,
}

/// Request to create a new site
#[derive(Debug, Deserialize)]
pub struct CreateSiteRequest {
    pub client_id: Uuid,
    pub name: String,
    pub address: Option<String>,
    pub notes: Option<String>,
}

/// Request to update a site
#[derive(Debug, Deserialize)]
pub struct UpdateSiteRequest {
    pub name: Option<String>,
    pub address: Option<String>,
    pub notes: Option<String>,
    pub is_active: Option<bool>,
}

/// List all sites
pub async fn list_sites(
    _user: AuthUser,
    State(state): State<AppState>,
) -> Result<Json<Vec<SiteResponse>>, (StatusCode, String)> {
    let sites = db::get_all_sites_with_details(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<SiteResponse> = sites
        .into_iter()
        .map(|s| SiteResponse {
            id: s.id,
            client_id: s.client_id,
            client_name: Some(s.client_name),
            name: s.name,
            site_code: s.site_code,
            address: s.address,
            notes: s.notes,
            is_active: s.is_active,
            created_at: s.created_at,
            agent_count: s.agent_count,
        })
        .collect();

    Ok(Json(responses))
}

/// List sites for a specific client
pub async fn list_sites_by_client(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(client_id): Path<Uuid>,
) -> Result<Json<Vec<SiteResponse>>, (StatusCode, String)> {
    let sites = db::get_sites_by_client(&state.db, client_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let responses: Vec<SiteResponse> = sites
        .into_iter()
        .map(|s| SiteResponse {
            id: s.id,
            client_id: s.client_id,
            client_name: None,
            name: s.name,
            site_code: s.site_code,
            address: s.address,
            notes: s.notes,
            is_active: s.is_active,
            created_at: s.created_at,
            agent_count: 0, // Would need separate query
        })
        .collect();

    Ok(Json(responses))
}

/// Create a new site
pub async fn create_site(
    _user: AuthUser,
    State(state): State<AppState>,
    Json(req): Json<CreateSiteRequest>,
) -> Result<Json<CreateSiteResponse>, (StatusCode, String)> {
    // Verify client exists
    let client = db::get_client_by_id(&state.db, req.client_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Client not found".to_string()))?;

    // Generate unique site code and API key
    let site_code = db::generate_unique_site_code(&state.db)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    let api_key_hash = hash_api_key(&api_key);

    let create = db::CreateSiteInternal {
        client_id: req.client_id,
        name: req.name,
        site_code: site_code.clone(),
        api_key_hash,
        address: req.address,
        notes: req.notes,
    };

    let site = db::create_site(&state.db, create)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(CreateSiteResponse {
        site: SiteResponse {
            id: site.id,
            client_id: site.client_id,
            client_name: Some(client.name),
            name: site.name,
            site_code: site.site_code,
            address: site.address,
            notes: site.notes,
            is_active: site.is_active,
            created_at: site.created_at,
            agent_count: 0,
        },
        api_key,
        message: "Site created. Save the API key - it will not be shown again.".to_string(),
    }))
}

/// Get a specific site
pub async fn get_site(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<SiteResponse>, (StatusCode, String)> {
    let site = db::get_site_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;

    // Get client name and agent count
    let client = db::get_client_by_id(&state.db, site.client_id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    let agents = db::get_agents_by_site(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(SiteResponse {
        id: site.id,
        client_id: site.client_id,
        client_name: client.map(|c| c.name),
        name: site.name,
        site_code: site.site_code,
        address: site.address,
        notes: site.notes,
        is_active: site.is_active,
        created_at: site.created_at,
        agent_count: agents.len() as i64,
    }))
}

/// Update a site
pub async fn update_site(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
    Json(req): Json<UpdateSiteRequest>,
) -> Result<Json<SiteResponse>, (StatusCode, String)> {
    let update = db::UpdateSite {
        name: req.name,
        address: req.address,
        notes: req.notes,
        is_active: req.is_active,
    };

    let site = db::update_site(&state.db, id, update)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;

    Ok(Json(SiteResponse {
        id: site.id,
        client_id: site.client_id,
        client_name: None,
        name: site.name,
        site_code: site.site_code,
        address: site.address,
        notes: site.notes,
        is_active: site.is_active,
        created_at: site.created_at,
        agent_count: 0,
    }))
}

/// Regenerate API key for a site
#[derive(Debug, Serialize)]
pub struct RegenerateApiKeyResponse {
    pub api_key: String,
    pub message: String,
}

pub async fn regenerate_api_key(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<Json<RegenerateApiKeyResponse>, (StatusCode, String)> {
    // Verify site exists
    let _site = db::get_site_by_id(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
        .ok_or((StatusCode::NOT_FOUND, "Site not found".to_string()))?;

    // Generate new API key
    let api_key = generate_api_key(&state.config.auth.api_key_prefix);
    let api_key_hash = hash_api_key(&api_key);

    db::regenerate_site_api_key(&state.db, id, &api_key_hash)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    Ok(Json(RegenerateApiKeyResponse {
        api_key,
        message: "API key regenerated. Save it - it will not be shown again. Existing agents will need to be reconfigured.".to_string(),
    }))
}

/// Delete a site
pub async fn delete_site(
    _user: AuthUser,
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
    let deleted = db::delete_site(&state.db, id)
        .await
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if deleted {
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "Site not found".to_string()))
    }
}


Let me check the remaining migration files and see if there are any more details about roles or permissions:


Bash

grep -r "role\|permission\|access" "C:\Users\MikeSwanson\claude-projects\gururmm\server\src" --include="*.rs" | head -50

Search for role/permission references in server code


C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/auth.rs:        &user.role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/auth.rs:        role: Some("admin".to_string()),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/auth.rs:        &user.role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/mod.rs:        .route("/users/:id/role", put(users::update_user_role))
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/sso.rs:    access_token: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/sso.rs:        default_role: entra_config.default_role.clone(),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/sso.rs:    info!("SSO user authenticated: id={} email={} role={}", user.id, user.email, user.role);
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/sso.rs:        &user.role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs://! Admin-only endpoints for managing users, roles, and permissions.
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if auth.role != "admin" {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        return Err((StatusCode::FORBIDDEN, "Admin access required".to_string()));
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:/// Request to update a user's role
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    pub role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:/// Update a user's role (admin only)
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:pub async fn update_user_role(
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if auth.role != "admin" {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        return Err((StatusCode::FORBIDDEN, "Admin access required".to_string()));
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    // Validate role
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    let valid_roles = ["admin", "user", "viewer"];
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if !valid_roles.contains(&req.role.as_str()) {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:            format!("Invalid role. Must be one of: {}", valid_roles.join(", ")),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if user_id == auth.user_id && req.role != "admin" {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:            "Cannot change your own role".to_string(),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    // Update role
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    db::update_user_role(&state.db, user_id, &req.role)
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        "User role updated: admin={} target={} old_role={} new_role={}",
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        auth.user_id, user_id, target_user.role, req.role
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        message: format!("Role updated to '{}'", req.role),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if auth.role != "admin" && auth.user_id != user_id {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:    if auth.role != "admin" {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/api/users.rs:        return Err((StatusCode::FORBIDDEN, "Admin access required".to_string()));
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:    /// User role
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:    pub role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:pub fn create_jwt(user_id: Uuid, role: &str, secret: &str, expiry_hours: u64) -> Result<String> {
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:        role: role.to_string(),
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:    pub role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:            role: claims.role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/auth/mod.rs:        assert_eq!(claims.role, "admin");
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/config.rs:    /// Default role assigned to new SSO users
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/config.rs:    pub default_role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/config.rs:                let default_role = std::env::var("ENTRA_DEFAULT_ROLE")
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/config.rs:                    default_role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/mod.rs://! Provides database access for clients, sites, agents, metrics, commands, users, updates,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:    pub role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:    pub role: String,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:            role: user.role,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:    pub role: Option<String>,
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:        INSERT INTO users (email, password_hash, name, role)
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:    .bind(&user.role)
C:\Users\MikeSwanson\claude-projects\gururmm\server\src/db/users.rs:    pub default_role: String,


Let me check the API routes configuration to see the full picture:


     1→//! REST API routes
     2→//!
     3→//! Provides endpoints for:
     4→//! - Agent management (registration, listing, deletion)
     5→//! - Client and site management
     6→//! - Metrics retrieval
     7→//! - Command execution
     8→//! - User authentication
     9→//! - Policy management
    10→//! - Alert management
    11→
    12→pub mod agents;
    13→pub mod alerts;
    14→pub mod auth;
    15→pub mod clients;
    16→pub mod commands;
    17→pub mod metrics;
    18→pub mod policies;
    19→pub mod sites;
    20→pub mod sso;
    21→pub mod users;
    22→
    23→use axum::{
    24→    routing::{delete, get, post, put},
    25→    Router,
    26→};
    27→
    28→use crate::AppState;
    29→
    30→/// Build all API routes
    31→pub fn routes() -> Router<AppState> {
    32→    Router::new()
    33→        // Authentication
    34→        .route("/auth/login", post(auth::login))
    35→        .route("/auth/register", post(auth::register))
    36→        .route("/auth/me", get(auth::me))
    37→        // SSO
    38→        .route("/auth/sso/entra/config", get(sso::entra_config))
    39→        .route("/auth/sso/entra/callback", post(sso::entra_callback))
    40→        // Users (admin)
    41→        .route("/users", get(users::list_users))
    42→        .route("/users/:id", get(users::get_user))
    43→        .route("/users/:id", delete(users::delete_user))
    44→        .route("/users/:id/role", put(users::update_user_role))
    45→        // Clients
    46→        .route("/clients", get(clients::list_clients))
    47→        .route("/clients", post(clients::create_client))
    48→        .route("/clients/:id", get(clients::get_client))
    49→        .route("/clients/:id", put(clients::update_client))
    50→        .route("/clients/:id", delete(clients::delete_client))
    51→        .route("/clients/:id/sites", get(sites::list_sites_by_client))
    52→        // Sites
    53→        .route("/sites", get(sites::list_sites))
    54→        .route("/sites", post(sites::create_site))
    55→        .route("/sites/:id", get(sites::get_site))
    56→        .route("/sites/:id", put(sites::update_site))
    57→        .route("/sites/:id", delete(sites::delete_site))
    58→        .route("/sites/:id/regenerate-key", post(sites::regenerate_api_key))
    59→        // Agents
    60→        .route("/agents", get(agents::list_agents_with_details))
    61→        .route("/agents", post(agents::register_agent))
    62→        .route("/agents/stats", get(agents::get_stats))
    63→        .route("/agents/unassigned", get(agents::list_unassigned_agents))
    64→        .route("/agents/:id", get(agents::get_agent))
    65→        .route("/agents/:id", delete(agents::delete_agent))
    66→        .route("/agents/:id/move", post(agents::move_agent))
    67→        .route("/agents/:id/state", get(agents::get_agent_state))
    68→        .route("/agents/:id/update", post(agents::trigger_update))
    69→        // Metrics
    70→        .route("/agents/:id/metrics", get(metrics::get_agent_metrics))
    71→        .route("/metrics/summary", get(metrics::get_summary))
    72→        // Commands
    73→        .route("/agents/:id/command", post(commands::send_command))
    74→        .route("/commands", get(commands::list_commands))
    75→        .route("/commands/:id", get(commands::get_command))
    76→        // Legacy Agent (PowerShell for 2008 R2)
    77→        .route("/agent/register-legacy", post(agents::register_legacy))
    78→        .route("/agent/heartbeat", post(agents::heartbeat))
    79→        .route("/agent/command-result", post(agents::command_result))
    80→        // Policies
    81→        .route("/policies", get(policies::list_policies))
    82→        .route("/policies", post(policies::create_policy))
    83→        .route("/policies/:id", get(policies::get_policy))
    84→        .route("/policies/:id", put(policies::update_policy))
    85→        .route("/policies/:id", delete(policies::delete_policy))
    86→        .route("/policies/:id/assign", post(policies::assign_policy))
    87→        .route("/policies/:id/assignments", get(policies::list_policy_assignments))
    88→        .route("/policy-assignments/:id", delete(policies::remove_assignment))
    89→        .route("/agents/:id/effective-policy", get(policies::get_agent_effective_policy))
    90→        // Alerts
    91→        .route("/alerts", get(alerts::list_alerts))
    92→        .route("/alerts/summary", get(alerts::get_alert_summary))
    93→        .route("/alerts/:id", get(alerts::get_alert))
    94→        .route("/alerts/:id/acknowledge", post(alerts::acknowledge_alert))
    95→        .route("/alerts/:id/resolve", post(alerts::resolve_alert))
    96→        .route("/agents/:id/alerts", get(alerts::get_agent_alerts))
    97→}
    98→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! REST API routes
//!
//! Provides endpoints for:
//! - Agent management (registration, listing, deletion)
//! - Client and site management
//! - Metrics retrieval
//! - Command execution
//! - User authentication
//! - Policy management
//! - Alert management

pub mod agents;
pub mod alerts;
pub mod auth;
pub mod clients;
pub mod commands;
pub mod metrics;
pub mod policies;
pub mod sites;
pub mod sso;
pub mod users;

use axum::{
    routing::{delete, get, post, put},
    Router,
};

use crate::AppState;

/// Build all API routes
pub fn routes() -> Router<AppState> {
    Router::new()
        // Authentication
        .route("/auth/login", post(auth::login))
        .route("/auth/register", post(auth::register))
        .route("/auth/me", get(auth::me))
        // SSO
        .route("/auth/sso/entra/config", get(sso::entra_config))
        .route("/auth/sso/entra/callback", post(sso::entra_callback))
        // Users (admin)
        .route("/users", get(users::list_users))
        .route("/users/:id", get(users::get_user))
        .route("/users/:id", delete(users::delete_user))
        .route("/users/:id/role", put(users::update_user_role))
        // Clients
        .route("/clients", get(clients::list_clients))
        .route("/clients", post(clients::create_client))
        .route("/clients/:id", get(clients::get_client))
        .route("/clients/:id", put(clients::update_client))
        .route("/clients/:id", delete(clients::delete_client))
        .route("/clients/:id/sites", get(sites::list_sites_by_client))
        // Sites
        .route("/sites", get(sites::list_sites))
        .route("/sites", post(sites::create_site))
        .route("/sites/:id", get(sites::get_site))
        .route("/sites/:id", put(sites::update_site))
        .route("/sites/:id", delete(sites::delete_site))
        .route("/sites/:id/regenerate-key", post(sites::regenerate_api_key))
        // Agents
        .route("/agents", get(agents::list_agents_with_details))
        .route("/agents", post(agents::register_agent))
        .route("/agents/stats", get(agents::get_stats))
        .route("/agents/unassigned", get(agents::list_unassigned_agents))
        .route("/agents/:id", get(agents::get_agent))
        .route("/agents/:id", delete(agents::delete_agent))
        .route("/agents/:id/move", post(agents::move_agent))
        .route("/agents/:id/state", get(agents::get_agent_state))
        .route("/agents/:id/update", post(agents::trigger_update))
        // Metrics
        .route("/agents/:id/metrics", get(metrics::get_agent_metrics))
        .route("/metrics/summary", get(metrics::get_summary))
        // Commands
        .route("/agents/:id/command", post(commands::send_command))
        .route("/commands", get(commands::list_commands))
        .route("/commands/:id", get(commands::get_command))
        // Legacy Agent (PowerShell for 2008 R2)
        .route("/agent/register-legacy", post(agents::register_legacy))
        .route("/agent/heartbeat", post(agents::heartbeat))
        .route("/agent/command-result", post(agents::command_result))
        // Policies
        .route("/policies", get(policies::list_policies))
        .route("/policies", post(policies::create_policy))
        .route("/policies/:id", get(policies::get_policy))
        .route("/policies/:id", put(policies::update_policy))
        .route("/policies/:id", delete(policies::delete_policy))
        .route("/policies/:id/assign", post(policies::assign_policy))
        .route("/policies/:id/assignments", get(policies::list_policy_assignments))
        .route("/policy-assignments/:id", delete(policies::remove_assignment))
        .route("/agents/:id/effective-policy", get(policies::get_agent_effective_policy))
        // Alerts
        .route("/alerts", get(alerts::list_alerts))
        .route("/alerts/summary", get(alerts::get_alert_summary))
        .route("/alerts/:id", get(alerts::get_alert))
        .route("/alerts/:id/acknowledge", post(alerts::acknowledge_alert))
        .route("/alerts/:id/resolve", post(alerts::resolve_alert))
        .route("/agents/:id/alerts", get(alerts::get_agent_alerts))
}


     1→//! Database models and queries
     2→//!
     3→//! Provides database access for clients, sites, agents, metrics, commands, users, updates,
     4→//! policies, and alerts.
     5→
     6→pub mod agents;
     7→pub mod alerts;
     8→pub mod clients;
     9→pub mod commands;
    10→pub mod metrics;
    11→pub mod policies;
    12→pub mod sites;
    13→pub mod updates;
    14→pub mod users;
    15→
    16→pub use agents::*;
    17→pub use alerts::*;
    18→pub use clients::*;
    19→pub use commands::*;
    20→pub use metrics::*;
    21→pub use policies::*;
    22→pub use sites::*;
    23→pub use updates::*;
    24→pub use users::*;
    25→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

//! Database models and queries
//!
//! Provides database access for clients, sites, agents, metrics, commands, users, updates,
//! policies, and alerts.

pub mod agents;
pub mod alerts;
pub mod clients;
pub mod commands;
pub mod metrics;
pub mod policies;
pub mod sites;
pub mod updates;
pub mod users;

pub use agents::*;
pub use alerts::*;
pub use clients::*;
pub use commands::*;
pub use metrics::*;
pub use policies::*;
pub use sites::*;
pub use updates::*;
pub use users::*;


Perfect! Now let me create a comprehensive summary report based on my analysis:

## GuruRMM Authorization Model Analysis

Based on my thorough exploration of the GuruRMM server codebase, here's a detailed summary of the current user/role system and authorization model:

---

### 1. CURRENT USER TABLE SCHEMA

**Location:** `C:\Users\MikeSwanson\claude-projects\gururmm\server\migrations\001_initial.sql` (lines 51-68)

```sql
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255),
    name VARCHAR(255),
    role VARCHAR(50) DEFAULT 'user',
    sso_provider VARCHAR(50),
    sso_id VARCHAR(255),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    last_login TIMESTAMPTZ
);
```

**Key observations:**
- Simple flat structure with no client/site association
- Role stored as a VARCHAR string (no enum constraint)
- Single password_hash field (optional for SSO users)
- No tenant/organization scoping
- Current roles hardcoded as: `admin`, `user`, `viewer` (from `server/src/api/users.rs` line 62)

---

### 2. HOW ROLES ARE CURRENTLY DEFINED AND USED

**Role Definition:** String-based, validated at runtime in `server/src/api/users.rs:62`:
```rust
let valid_roles = ["admin", "user", "viewer"];
```

**Role Hierarchy:**
- **admin** - Full system access, can manage users and change roles
- **user** - Standard user with access to most features
- **viewer** - Read-only access (appears to be intended but not enforced)

**Current Usage:**
- Roles are stored in JWT claims during authentication (`server/src/auth/mod.rs:25`)
- Roles are embedded in JWT tokens and passed with every authenticated request
- No permission-based system exists; only simple role equality checks

---

### 3. HOW API ENDPOINTS CHECK PERMISSIONS (AuthUser Extractor)

**Location:** `C:\Users\MikeSwanson\claude-projects\gururmm\server\src\auth\mod.rs` (lines 101-135)

The `AuthUser` extractor structure:
```rust
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub user_id: Uuid,
    pub role: String,
}
```

**Authentication Flow:**
1. JWT token extracted from `Authorization: Bearer <token>` header
2. Token verified with secret from config
3. Claims decoded, extracting `user_id` and `role`
4. AuthUser struct populated and passed to route handlers

**Permission Checking Pattern:**
All current checks follow this pattern (from `server/src/api/users.rs:23-26`):
```rust
if auth.role != "admin" {
    return Err((StatusCode::FORBIDDEN, "Admin access required".to_string()));
}
```

**Critical Finding:** Most endpoints perform NO permission checks at all:
- **Clients API** (`server/src/api/clients.rs`) - Takes `_user: AuthUser` but never checks role
- **Sites API** (`server/src/api/sites.rs`) - Takes `_user: AuthUser` but never checks role
- **Agents API** (`server/src/api/agents.rs`) - Most endpoints have NO auth requirement at all
- **Metrics API** - No auth requirement
- **Commands API** - Only trigger_update checks auth, others have no checks

---

### 4. RELATIONSHIP BETWEEN USERS AND CLIENTS/SITES

**Current State: No relationship exists**

The schema shows:
- Users table is completely separate from clients/sites
- No foreign key linking users to clients
- No user_client join table
- No client_id field in users table

**Client/Site Architecture** (from `server/migrations/002_clients_sites.sql`):
```sql
CREATE TABLE clients (
    id UUID PRIMARY KEY,
    name VARCHAR(255),
    code VARCHAR(50) UNIQUE,
    is_active BOOLEAN,
    ...
);

CREATE TABLE sites (
    id UUID PRIMARY KEY,
    client_id UUID REFERENCES clients(id),
    name VARCHAR(255),
    site_code VARCHAR(50) UNIQUE,
    api_key_hash VARCHAR(255),  -- All agents at site share this
    ...
);

ALTER TABLE agents ADD COLUMN site_id UUID REFERENCES sites(id);
```

**Key Insight:** The system is already multi-tenant (multiple clients with multiple sites), but users have no tenant association.

---

### 5. EXISTING ROLE-BASED ACCESS CONTROL

**What Exists:**
- Admin-only routes in `server/src/api/users.rs` (user management)
- Basic role checks on 2 endpoints: `list_users` and `update_user_role`

**What's Missing:**
- No row-level security (users can see all clients/sites/agents)
- No permission inheritance
- No fine-grained permissions
- "viewer" role is defined but never enforced
- Data isolation between users/clients is non-existent

---

## CHANGES NEEDED FOR REQUESTED FEATURES

### Feature 1: "Development Tier" Super-Admin

**Required Changes:**

1. **Database Migration:**
   - Add new role: `"dev-super-admin"`
   - Keep existing roles: `"admin"`, `"user"`, `"viewer"`

2. **Auth Module Update** (`server/src/auth/mod.rs`):
   - No changes needed - role passed as string already

3. **API Changes** - Update all permission checks to:
   ```rust
   // Allow both dev-super-admin and admin
   if auth.role != "admin" && auth.role != "dev-super-admin" {
       return Err((StatusCode::FORBIDDEN, "Admin access required".to_string()));
   }

   // Or create a helper function:
   fn is_admin(auth: &AuthUser) -> bool {
       auth.role == "admin" || auth.role == "dev-super-admin"
   }
   ```

4. **Configuration:**
   - Add environment variable for initial dev-super-admin user
   - Or provide migration script to grant role

5. **Audit Trail:**
   - Add `created_by` and `updated_by` to relevant tables (already have this for commands)
   - Track role change history

---

### Feature 2: Client-Scoped Users

**Required Changes:**

1. **Database Schema:**
   - Add `client_id UUID` to users table (nullable initially for backwards compatibility)
   - Create user_client association table for multi-client support:
   ```sql
   CREATE TABLE user_clients (
       user_id UUID REFERENCES users(id) ON DELETE CASCADE,
       client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
       PRIMARY KEY (user_id, client_id)
   );
   ```
   - Add new role: `"client-admin"` (admin for specific client only)

2. **AuthUser Extractor Enhancement** (`server/src/auth/mod.rs`):
   - Expand Claims structure:
   ```rust
   pub struct Claims {
       pub sub: Uuid,           // user_id
       pub role: String,
       pub client_ids: Vec<Uuid>,  // Which clients user can access
       pub exp: i64,
       pub iat: i64,
   }
   ```

3. **Create Authorization Helper Functions** (new module):
   ```rust
   pub mod authz {
       pub fn can_access_client(auth: &AuthUser, client_id: Uuid) -> bool {
           auth.role == "dev-super-admin" ||
           auth.role == "admin" ||
           auth.client_ids.contains(&client_id)
       }

       pub fn can_access_site(auth: &AuthUser, site_client_id: Uuid) -> bool {
           can_access_client(auth, site_client_id)
       }
   }
   ```

4. **API Endpoint Changes - Pattern:**
   ```rust
   pub async fn list_sites_by_client(
       auth: AuthUser,
       State(state): State<AppState>,
       Path(client_id): Path<Uuid>,
   ) -> Result<Json<Vec<SiteResponse>>, (StatusCode, String)> {
       // NEW: Check if user can access this client
       if !authz::can_access_client(&auth, client_id) {
           return Err((StatusCode::FORBIDDEN, "Access denied".to_string()));
       }

       // ... rest of handler
   }
   ```

5. **Database Query Changes:**
   - Add WHERE clause to filter by client_id for scoped users
   - Example:
   ```sql
   -- For scoped user, add: WHERE client_id = $2
   SELECT * FROM sites WHERE client_id = $1
   ```

6. **Affected Endpoints (all need scoping):**
   - `GET /sites` - Filter by user's clients
   - `GET /sites/:id` - Check client_id access
   - `POST /sites` - Validate client_id is accessible
   - `PUT /sites/:id` - Validate access
   - `DELETE /sites/:id` - Validate access
   - `GET /clients` - Only return user's clients
   - `GET /clients/:id` - Check access
   - `PUT /clients/:id` - Check access
   - `DELETE /clients/:id` - Check access
   - `GET /agents` - Filter by sites user can access
   - `POST /agents/:id/move` - Validate target site client_id
   - All metrics/commands/alerts endpoints - Filter by accessible clients

---

## SUMMARY TABLE

| Feature | Current State | Required Changes |
|---------|---|---|
| **User-Client Association** | None | Add client_id to users, create user_clients join table |
| **Role System** | 3 roles (admin, user, viewer) | Add dev-super-admin, client-admin |
| **Permission Checks** | Only on 2 endpoints | Must add to all data endpoints |
| **JWT Claims** | user_id, role only | Must add client_ids array |
| **Data Isolation** | Non-existent | Requires WHERE clause filtering on all queries |
| **Row-Level Security** | None | Implement authz helper functions |
| **Audit Trail** | Partial (commands only) | Extend to users, clients, sites |

---

## PRIORITY RECOMMENDATIONS

1. **Phase 1** - Add dev-super-admin support (simplest, minimal risk)
2. **Phase 2** - Add user-client association at DB level + update AuthUser extractor
3. **Phase 3** - Implement permission checks on high-value endpoints (clients, sites)
4. **Phase 4** - Extend to agents, metrics, commands, alerts (comprehensive scoping)
5. **Phase 5** - Audit logging and client-admin role support

All findings are absolute file paths ready for implementation work.