from-what now
This commit is contained in:
@@ -8,8 +8,8 @@ use tracing::{info, warn};
|
||||
use crate::api::json_error;
|
||||
use crate::runtime::{AppState, SessionEvent};
|
||||
use crate::state::{
|
||||
AgentDeviceObservation, AgentDeviceObservationInput, AuditEventInput, DeviceIdentifierInput,
|
||||
KnownDeviceInput,
|
||||
AgentDeviceObservationInput, AgentDeviceObservationView, AuditEventInput,
|
||||
DeviceIdentifierInput, KnownDeviceInput,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -91,6 +91,11 @@ pub struct DeviceIdentifierRequest {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AttachObservationIdentifierRequest {
|
||||
pub observation_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceResponse {
|
||||
pub device_id: String,
|
||||
@@ -592,6 +597,33 @@ pub async fn attach_device_identifier(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(device_id): AxumPath<String>,
|
||||
Json(req): Json<AttachObservationIdentifierRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.attach_observation_identifier(&device_id, &req.observation_key)
|
||||
.await
|
||||
{
|
||||
Ok(Some(device)) => Ok((StatusCode::OK, Json(known_device_response(device)))),
|
||||
Ok(None) => Err(json_error(
|
||||
StatusCode::NOT_FOUND,
|
||||
"known_device_not_found",
|
||||
"known device not found",
|
||||
)),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to attach observation identifier");
|
||||
Err(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"attach_observation_identifier_failed",
|
||||
&err.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload_agent_observations(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<UploadAgentObservationsRequest>,
|
||||
@@ -648,7 +680,7 @@ pub async fn list_agent_observations(
|
||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||
match state
|
||||
.store
|
||||
.list_agent_observations(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
||||
.list_agent_observation_views(query.agent_id.as_deref(), query.limit.unwrap_or(500))
|
||||
.await
|
||||
{
|
||||
Ok(observations) => Ok((
|
||||
@@ -696,7 +728,9 @@ pub async fn state_stats(
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_observation_response(observation: AgentDeviceObservation) -> AgentDeviceObservation {
|
||||
fn agent_observation_response(
|
||||
observation: AgentDeviceObservationView,
|
||||
) -> AgentDeviceObservationView {
|
||||
observation
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ pub use audit::list_audit_events;
|
||||
pub use commands::{list_agents, run_command};
|
||||
pub use control::{
|
||||
EnrollTokenStatus, IssueEnrollTokenResponse, RevokeAgentResponse, RevokeEnrollTokenResponse,
|
||||
StateStatsResponse, attach_device_identifier, create_known_device, enroll, forget_known_device,
|
||||
healthz, issue_enroll_token, list_agent_observations, list_enroll_tokens, list_known_devices,
|
||||
revoke_agent, revoke_enroll_token, set_agent_nickname, state_stats, upload_agent_observations,
|
||||
StateStatsResponse, attach_device_identifier, attach_observation_identifier,
|
||||
create_known_device, enroll, forget_known_device, healthz, issue_enroll_token,
|
||||
list_agent_observations, list_enroll_tokens, list_known_devices, revoke_agent,
|
||||
revoke_enroll_token, set_agent_nickname, state_stats, upload_agent_observations,
|
||||
};
|
||||
|
||||
pub fn json_error(
|
||||
|
||||
@@ -109,6 +109,10 @@ fn control_api_routes() -> Router<AppState> {
|
||||
"/api/v1/control/devices/{device_id}/identifiers",
|
||||
post(api::attach_device_identifier),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/control/devices/{device_id}/identifiers/from-observation",
|
||||
post(api::attach_observation_identifier),
|
||||
)
|
||||
.route("/api/v1/control/audit/events", get(api::list_audit_events))
|
||||
.route("/api/v1/control/alerts", get(api::active_alerts))
|
||||
.route("/api/v1/control/alerts/history", get(api::alert_history))
|
||||
|
||||
@@ -3,6 +3,6 @@ mod types;
|
||||
|
||||
pub use store::Store;
|
||||
pub use types::{
|
||||
AgentDeviceObservation, AgentDeviceObservationInput, AlertState, AuditEvent, AuditEventFilter,
|
||||
AuditEventInput, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
|
||||
AgentDeviceObservationInput, AgentDeviceObservationView, AlertState, AuditEvent,
|
||||
AuditEventFilter, AuditEventInput, DeviceIdentifierInput, KnownDevice, KnownDeviceInput,
|
||||
};
|
||||
|
||||
@@ -8,9 +8,10 @@ use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::types::{
|
||||
AgentDeviceObservation, AgentDeviceObservationInput, AlertState, AlertTransition, AuditEvent,
|
||||
AuditEventFilter, AuditEventInput, DeviceIdentifier, DeviceIdentifierInput, EnrollTokenInfo,
|
||||
IssuedAgent, IssuedEnrollToken, KnownDevice, KnownDeviceInput, StateStats,
|
||||
AgentDeviceObservation, AgentDeviceObservationInput, AgentDeviceObservationView, AlertState,
|
||||
AlertTransition, AuditEvent, AuditEventFilter, AuditEventInput, DeviceIdentifier,
|
||||
DeviceIdentifierInput, EnrollTokenInfo, IssuedAgent, IssuedEnrollToken, KnownDevice,
|
||||
KnownDeviceInput, KnownDeviceSummary, StateStats,
|
||||
};
|
||||
|
||||
pub struct Store {
|
||||
@@ -531,6 +532,42 @@ impl Store {
|
||||
self.get_known_device(device_id).await
|
||||
}
|
||||
|
||||
pub async fn attach_observation_identifier(
|
||||
&self,
|
||||
device_id: &str,
|
||||
observation_key: &str,
|
||||
) -> Result<Option<KnownDevice>> {
|
||||
let observation = sqlx::query_as!(
|
||||
ObservationIdentifierRow,
|
||||
r#"SELECT mac, ip
|
||||
FROM agent_device_observations
|
||||
WHERE observation_key = ?1"#,
|
||||
observation_key
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.context("failed reading observation identifier")?;
|
||||
|
||||
let Some(observation) = observation else {
|
||||
anyhow::bail!("observation not found");
|
||||
};
|
||||
let input = observation
|
||||
.mac
|
||||
.map(|value| DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value,
|
||||
})
|
||||
.or_else(|| {
|
||||
observation.ip.map(|value| DeviceIdentifierInput {
|
||||
kind: "ip".into(),
|
||||
value,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("observation has no attachable mac or ip"))?;
|
||||
|
||||
self.attach_device_identifier(device_id, input).await
|
||||
}
|
||||
|
||||
#[allow(unused)] // we'll get to this
|
||||
pub async fn lookup_known_device_by_identifier(
|
||||
&self,
|
||||
@@ -618,6 +655,7 @@ impl Store {
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub async fn list_agent_observations(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
@@ -658,6 +696,80 @@ impl Store {
|
||||
rows.into_iter().map(agent_observation_from_row).collect()
|
||||
}
|
||||
|
||||
pub async fn list_agent_observation_views(
|
||||
&self,
|
||||
agent_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<AgentDeviceObservationView>> {
|
||||
let limit = limit.clamp(1, 1000);
|
||||
let limit = i64::try_from(limit).context("observation limit overflow")?;
|
||||
let rows = if let Some(agent_id) = agent_id {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
WHERE observations.agent_id = ?1
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?2"#,
|
||||
agent_id,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
AgentObservationViewRow,
|
||||
r#"SELECT observations.observation_key as "observation_key!",
|
||||
observations.agent_id as "agent_id!",
|
||||
observations.kind as "kind!",
|
||||
observations.mac,
|
||||
observations.ip,
|
||||
observations.hostname,
|
||||
observations.first_seen_unix,
|
||||
observations.last_seen_unix,
|
||||
observations.last_action as "last_action!",
|
||||
known_devices.device_id,
|
||||
known_devices.display_name,
|
||||
known_devices.pinned
|
||||
FROM agent_device_observations observations
|
||||
LEFT JOIN device_identifiers identifiers
|
||||
ON identifiers.identifier_key =
|
||||
CASE
|
||||
WHEN observations.mac IS NOT NULL THEN 'mac:' || observations.mac
|
||||
WHEN observations.ip IS NOT NULL THEN 'ip:' || observations.ip
|
||||
END
|
||||
LEFT JOIN known_devices ON known_devices.device_id = identifiers.device_id
|
||||
ORDER BY observations.last_seen_unix DESC
|
||||
LIMIT ?1"#,
|
||||
limit
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
.context("failed listing agent observation views")?;
|
||||
rows.into_iter()
|
||||
.map(agent_observation_view_from_row)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn append_audit_event(&self, input: AuditEventInput) -> Result<AuditEvent> {
|
||||
let event = AuditEvent {
|
||||
event_id: format!("evt-{}", Uuid::new_v4()),
|
||||
@@ -1056,6 +1168,7 @@ struct DeviceIdentifierRow {
|
||||
created_at_unix: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
struct AgentObservationRow {
|
||||
observation_key: String,
|
||||
agent_id: String,
|
||||
@@ -1068,6 +1181,26 @@ struct AgentObservationRow {
|
||||
last_action: String,
|
||||
}
|
||||
|
||||
struct AgentObservationViewRow {
|
||||
observation_key: String,
|
||||
agent_id: String,
|
||||
kind: String,
|
||||
mac: Option<String>,
|
||||
ip: Option<String>,
|
||||
hostname: Option<String>,
|
||||
first_seen_unix: i64,
|
||||
last_seen_unix: i64,
|
||||
last_action: String,
|
||||
device_id: Option<String>,
|
||||
display_name: Option<String>,
|
||||
pinned: Option<i64>,
|
||||
}
|
||||
|
||||
struct ObservationIdentifierRow {
|
||||
mac: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
struct AlertStateRow {
|
||||
alert_id: String,
|
||||
kind: String,
|
||||
@@ -1238,6 +1371,7 @@ fn device_identifier_from_row(row: DeviceIdentifierRow) -> Result<DeviceIdentifi
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
fn agent_observation_from_row(row: AgentObservationRow) -> Result<AgentDeviceObservation> {
|
||||
Ok(AgentDeviceObservation {
|
||||
observation_key: row.observation_key,
|
||||
@@ -1254,6 +1388,33 @@ fn agent_observation_from_row(row: AgentObservationRow) -> Result<AgentDeviceObs
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_observation_view_from_row(
|
||||
row: AgentObservationViewRow,
|
||||
) -> Result<AgentDeviceObservationView> {
|
||||
let known_device = match (row.device_id, row.display_name, row.pinned) {
|
||||
(Some(device_id), Some(display_name), Some(pinned)) => Some(KnownDeviceSummary {
|
||||
device_id,
|
||||
display_name,
|
||||
pinned: pinned != 0,
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
Ok(AgentDeviceObservationView {
|
||||
observation_key: row.observation_key,
|
||||
agent_id: row.agent_id,
|
||||
kind: row.kind,
|
||||
mac: row.mac,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
first_seen_unix: u64::try_from(row.first_seen_unix)
|
||||
.context("negative observation first_seen timestamp in state db")?,
|
||||
last_seen_unix: u64::try_from(row.last_seen_unix)
|
||||
.context("negative observation last_seen timestamp in state db")?,
|
||||
last_action: row.last_action,
|
||||
known_device,
|
||||
})
|
||||
}
|
||||
|
||||
async fn import_tree_raw(
|
||||
pool: &SqlitePool,
|
||||
legacy: &sled::Db,
|
||||
@@ -1919,6 +2080,139 @@ mod tests {
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_observation_views_include_matching_known_device() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
let device = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: None,
|
||||
identifiers: vec![DeviceIdentifierInput {
|
||||
kind: "mac".into(),
|
||||
value: "aa:bb:cc:dd:ee:ff".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.expect("known device should create");
|
||||
|
||||
store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![
|
||||
crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
},
|
||||
crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("00:11:22:33:44:55".into()),
|
||||
ip: Some("192.168.1.11".into()),
|
||||
hostname: Some("guest".into()),
|
||||
first_seen_unix: 11,
|
||||
last_seen_unix: 21,
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("observation upsert should succeed");
|
||||
|
||||
let rows = store
|
||||
.list_agent_observation_views(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observation views should list");
|
||||
assert_eq!(rows.len(), 2);
|
||||
|
||||
let known = rows
|
||||
.iter()
|
||||
.find(|row| row.mac.as_deref() == Some("aa:bb:cc:dd:ee:ff"))
|
||||
.expect("known observation should be present");
|
||||
let known_device = known
|
||||
.known_device
|
||||
.as_ref()
|
||||
.expect("known observation should join device");
|
||||
assert_eq!(known_device.device_id, device.device_id);
|
||||
assert_eq!(known_device.display_name, "lda");
|
||||
assert!(known_device.pinned);
|
||||
|
||||
let unknown = rows
|
||||
.iter()
|
||||
.find(|row| row.mac.as_deref() == Some("00:11:22:33:44:55"))
|
||||
.expect("unknown observation should be present");
|
||||
assert!(unknown.known_device.is_none());
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_identifier_can_be_attached_to_known_device() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
let device = store
|
||||
.create_known_device(KnownDeviceInput {
|
||||
display_name: "lda".into(),
|
||||
pinned: true,
|
||||
notes: None,
|
||||
identifiers: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.expect("known device should create");
|
||||
|
||||
store
|
||||
.upsert_agent_observations(
|
||||
"agent-a",
|
||||
vec![crate::state::AgentDeviceObservationInput {
|
||||
kind: "dhcp".into(),
|
||||
action: "update".into(),
|
||||
mac: Some("AA:BB:CC:DD:EE:FF".into()),
|
||||
ip: Some("192.168.1.10".into()),
|
||||
hostname: Some("lda".into()),
|
||||
first_seen_unix: 10,
|
||||
last_seen_unix: 20,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.expect("observation upsert should succeed");
|
||||
|
||||
let observation = store
|
||||
.list_agent_observations(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observations should list")
|
||||
.pop()
|
||||
.expect("observation should exist");
|
||||
|
||||
let updated = store
|
||||
.attach_observation_identifier(&device.device_id, &observation.observation_key)
|
||||
.await
|
||||
.expect("observation identifier should attach")
|
||||
.expect("device should exist");
|
||||
|
||||
assert_eq!(updated.identifiers.len(), 1);
|
||||
assert_eq!(updated.identifiers[0].kind, "mac");
|
||||
assert_eq!(updated.identifiers[0].value, "aa:bb:cc:dd:ee:ff");
|
||||
|
||||
let views = store
|
||||
.list_agent_observation_views(Some("agent-a"), 10)
|
||||
.await
|
||||
.expect("observation views should list");
|
||||
assert_eq!(
|
||||
views[0]
|
||||
.known_device
|
||||
.as_ref()
|
||||
.map(|device| device.device_id.as_str()),
|
||||
Some(device.device_id.as_str())
|
||||
);
|
||||
|
||||
cleanup_dir(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_events_append_and_filter() {
|
||||
let (store, dir) = make_store().await;
|
||||
|
||||
@@ -77,6 +77,27 @@ pub struct AgentDeviceObservation {
|
||||
pub last_action: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDeviceObservationView {
|
||||
pub observation_key: String,
|
||||
pub agent_id: String,
|
||||
pub kind: String,
|
||||
pub mac: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub hostname: Option<String>,
|
||||
pub first_seen_unix: u64,
|
||||
pub last_seen_unix: u64,
|
||||
pub last_action: String,
|
||||
pub known_device: Option<KnownDeviceSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnownDeviceSummary {
|
||||
pub device_id: String,
|
||||
pub display_name: String,
|
||||
pub pinned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentDeviceObservationInput {
|
||||
pub kind: String,
|
||||
|
||||
Reference in New Issue
Block a user