Files
AWatch-rus/adk-rust/crates/detmir-portal/src/executive_actions.rs
T

495 lines
18 KiB
Rust

use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{PortalRole, role_envelope};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct ExecutiveAction {
pub priority: ActionPriority,
pub title: String,
pub summary: String,
pub owner_role: ActionOwnerRole,
pub recommended_deadline: String,
pub reason_codes: Vec<String>,
pub evidence: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ActionPriority {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ActionOwnerRole {
Executive,
Manager,
Security,
Forensics,
Admin,
}
pub(crate) fn build_action_center_from_report(report: &Value, role: PortalRole) -> Value {
let actions = generate_actions(report)
.into_iter()
.filter(|action| action_visible_for_role(action.owner_role, role))
.collect::<Vec<_>>();
json!({
"ok": true,
"role_context": role_envelope(role, "actions"),
"actions": actions,
"model": {
"type": "rule_based",
"version": "executive-action-center-v1",
"ml": false,
"llm": false,
"auto_remediation": false
},
"generated_at_utc": Utc::now().to_rfc3339(),
"limitations": [
"Рекомендуемые действия не выполняются автоматически",
"Action Center не блокирует пользователей и не меняет политики",
"Все действия требуют ручного подтверждения ответственным контуром"
]
})
}
pub(crate) fn filter_actions_for_role(actions: &Value, role: PortalRole) -> Value {
let filtered = actions
.as_array()
.into_iter()
.flatten()
.filter(|item| {
item.get("owner_role")
.and_then(Value::as_str)
.and_then(parse_owner_role)
.is_some_and(|owner| action_visible_for_role(owner, role))
})
.cloned()
.collect::<Vec<_>>();
Value::Array(filtered)
}
pub(crate) fn actions_from_center(center: &Value) -> Value {
center
.get("actions")
.and_then(Value::as_array)
.cloned()
.map(Value::Array)
.unwrap_or_else(|| Value::Array(Vec::new()))
}
fn generate_actions(report: &Value) -> Vec<ExecutiveAction> {
let mut actions = Vec::new();
add_workforce_kpi_action(report, &mut actions);
add_coverage_action(report, &mut actions);
add_ueba_confidence_action(report, &mut actions);
add_ueba_action(report, &mut actions);
add_security_correlation_action(report, &mut actions);
add_incident_candidate_action(report, &mut actions);
add_risk_narrative_action(report, &mut actions);
if actions.is_empty() {
actions.push(ExecutiveAction {
priority: ActionPriority::Low,
title: "Продолжить наблюдение".to_string(),
summary: "Критичных управленческих действий по текущему срезу не требуется".to_string(),
owner_role: ActionOwnerRole::Manager,
recommended_deadline: "72h".to_string(),
reason_codes: vec!["NORMAL_OBSERVATION".to_string()],
evidence: vec!["Критичные сигналы не выявлены".to_string()],
});
}
actions.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| left.owner_role.as_str().cmp(right.owner_role.as_str()))
.then_with(|| left.title.cmp(&right.title))
});
actions
}
fn add_workforce_kpi_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let Some(score) = report
.pointer("/workforce_kpi_explain/kpi_score")
.and_then(Value::as_u64)
else {
return;
};
if score >= 70 {
return;
}
let mut reason_codes = vec!["LOW_WORKFORCE_KPI".to_string()];
let mut evidence = vec![format!("Workforce KPI ниже целевого уровня: {score}%")];
if has_kpi_factor(report, "remote_session_activity") {
reason_codes.push("HIGH_REMOTE_ACTIVITY".to_string());
evidence.push("Рост удаленных сессий влияет на управленческий риск".to_string());
}
if let Some(confidence) = report
.pointer("/workforce_kpi_explain/confidence")
.and_then(Value::as_str)
.filter(|value| *value == "low")
{
reason_codes.push("LOW_KPI_CONFIDENCE".to_string());
evidence.push(format!("Доверие к KPI: {confidence}"));
}
actions.push(ExecutiveAction {
priority: if score < 50 {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Проверить подразделение с низким индексом активности".to_string(),
summary:
"Индекс активности ниже управленческого порога; требуется проверка причины просадки"
.to_string(),
owner_role: ActionOwnerRole::Manager,
recommended_deadline: if score < 50 { "4h" } else { "24h" }.to_string(),
reason_codes,
evidence,
});
}
fn add_coverage_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let coverage = report
.pointer("/agent_coverage_sla/coverage_pct")
.or_else(|| report.pointer("/workforce_kpi_explain/coverage/agent_coverage_percent"))
.and_then(Value::as_u64)
.unwrap_or(100);
let sla_status = report
.pointer("/agent_coverage_sla/sla_status")
.and_then(Value::as_str)
.unwrap_or("OK");
if coverage >= 80 && !matches!(sla_status, "WARNING" | "CRITICAL") {
return;
}
actions.push(ExecutiveAction {
priority: if coverage < 60 || sla_status == "CRITICAL" {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Проверить состояние агентов".to_string(),
summary: "Полнота данных ниже целевого уровня; показатели могут быть нерепрезентативны"
.to_string(),
owner_role: ActionOwnerRole::Admin,
recommended_deadline: if coverage < 60 { "4h" } else { "24h" }.to_string(),
reason_codes: vec!["LOW_COVERAGE".to_string()],
evidence: vec![
format!("Покрытие агентов: {coverage}%"),
format!("SLA полноты данных: {sla_status}"),
],
});
}
fn add_ueba_confidence_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let score = report
.pointer("/ueba_risk/score")
.and_then(Value::as_u64)
.unwrap_or(0);
let explicit_confidence = report
.pointer("/ueba_risk/confidence_level")
.and_then(Value::as_str);
let explicit_classification = report
.pointer("/ueba_risk/classification")
.and_then(Value::as_str);
if score < 70 && explicit_confidence.is_none() && explicit_classification.is_none() {
return;
}
let confidence = report
.pointer("/ueba_risk/confidence_level")
.and_then(Value::as_str)
.unwrap_or("unknown");
let classification = report
.pointer("/ueba_risk/classification")
.and_then(Value::as_str)
.unwrap_or("insufficient_data");
if score < 70 && !matches!(classification, "needs_investigation" | "insufficient_data") {
return;
}
if !matches!(confidence, "low" | "unknown")
&& !matches!(classification, "needs_investigation" | "insufficient_data")
{
return;
}
let reasons = report
.pointer("/ueba_risk/confidence_reasons")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.take(3)
.map(ToString::to_string)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut evidence = vec![
format!("UEBA confidence: {confidence}"),
format!("UEBA classification: {classification}"),
];
evidence.extend(reasons);
actions.push(ExecutiveAction {
priority: ActionPriority::Critical,
title: "Проверить полноту данных".to_string(),
summary: "Перед жестким выводом по UEBA нужно подтвердить покрытие, свежесть и полноту телеметрии"
.to_string(),
owner_role: ActionOwnerRole::Admin,
recommended_deadline: "4h".to_string(),
reason_codes: vec![
"LOW_UEBA_CONFIDENCE".to_string(),
"CHECK_DATA_COMPLETENESS".to_string(),
],
evidence,
});
}
fn add_ueba_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let score = report
.pointer("/ueba_risk/score")
.and_then(Value::as_u64)
.unwrap_or(0);
let level = report
.pointer("/ueba_risk/level")
.and_then(Value::as_str)
.unwrap_or("low");
if score < 70 && !matches!(level, "high" | "critical") {
return;
}
actions.push(ExecutiveAction {
priority: if score >= 90 || level == "critical" {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Передать данные в контур ИБ".to_string(),
summary: "UEBA score повышен; требуется ручная проверка безопасности".to_string(),
owner_role: ActionOwnerRole::Security,
recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(),
reason_codes: vec!["HIGH_UEBA".to_string()],
evidence: vec![format!("UEBA score: {score}, уровень: {level}")],
});
}
fn add_security_correlation_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let max_score = report
.get("security_correlation")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| item.get("correlation_score").and_then(Value::as_u64))
.max()
.unwrap_or(0);
if max_score < 60 {
return;
}
actions.push(ExecutiveAction {
priority: if max_score >= 80 {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Проверить связь активности и ИБ-событий".to_string(),
summary: "Есть корреляция между операционным риском и событиями безопасности".to_string(),
owner_role: ActionOwnerRole::Security,
recommended_deadline: "24h".to_string(),
reason_codes: vec!["HIGH_SECURITY_CORRELATION".to_string()],
evidence: vec![format!("Security correlation score: {max_score}")],
});
}
fn add_incident_candidate_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let Some(candidates) = report
.get("risk_incident_candidates")
.and_then(Value::as_array)
else {
return;
};
if candidates.is_empty() {
return;
}
let critical = candidates.iter().any(|item| {
item.get("risk_level")
.and_then(Value::as_str)
.is_some_and(|level| level.eq_ignore_ascii_case("critical"))
});
actions.push(ExecutiveAction {
priority: if critical {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Провести расследование кандидатов".to_string(),
summary: "В очереди есть кандидаты на проверку; требуется ручной разбор и фиксация решения"
.to_string(),
owner_role: ActionOwnerRole::Forensics,
recommended_deadline: if critical { "4h" } else { "24h" }.to_string(),
reason_codes: vec!["INCIDENT_CANDIDATE".to_string()],
evidence: vec![format!("Кандидатов на проверку: {}", candidates.len())],
});
}
fn add_risk_narrative_action(report: &Value, actions: &mut Vec<ExecutiveAction>) {
let score = report
.pointer("/risk_narrative/risk_score")
.and_then(Value::as_u64)
.unwrap_or(0);
if score < 75 {
return;
}
let level = report
.pointer("/risk_narrative/risk_level")
.and_then(Value::as_str)
.unwrap_or("high");
actions.push(ExecutiveAction {
priority: if score >= 90 {
ActionPriority::Critical
} else {
ActionPriority::High
},
title: "Назначить владельца корректирующих действий".to_string(),
summary: "Риск-нарратив показывает высокий управленческий риск; нужен ответственный и срок контроля"
.to_string(),
owner_role: ActionOwnerRole::Executive,
recommended_deadline: if score >= 90 { "4h" } else { "24h" }.to_string(),
reason_codes: vec!["RISK_NARRATIVE_HIGH".to_string()],
evidence: vec![format!("Risk Narrative: {score}/100, уровень: {level}")],
});
}
fn has_kpi_factor(report: &Value, factor_name: &str) -> bool {
report
.pointer("/workforce_kpi_explain/factors")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|item| item.get("name").and_then(Value::as_str) == Some(factor_name))
}
fn action_visible_for_role(owner_role: ActionOwnerRole, role: PortalRole) -> bool {
match role {
PortalRole::Admin => true,
PortalRole::Executive => matches!(
owner_role,
ActionOwnerRole::Executive | ActionOwnerRole::Manager | ActionOwnerRole::Admin
),
PortalRole::Manager => matches!(owner_role, ActionOwnerRole::Manager),
PortalRole::Security => {
matches!(
owner_role,
ActionOwnerRole::Security | ActionOwnerRole::Forensics
)
}
PortalRole::Forensics => {
matches!(
owner_role,
ActionOwnerRole::Forensics | ActionOwnerRole::Security
)
}
}
}
fn parse_owner_role(value: &str) -> Option<ActionOwnerRole> {
match value {
"executive" => Some(ActionOwnerRole::Executive),
"manager" => Some(ActionOwnerRole::Manager),
"security" => Some(ActionOwnerRole::Security),
"forensics" => Some(ActionOwnerRole::Forensics),
"admin" => Some(ActionOwnerRole::Admin),
_ => None,
}
}
impl ActionOwnerRole {
fn as_str(self) -> &'static str {
match self {
Self::Executive => "executive",
Self::Manager => "manager",
Self::Security => "security",
Self::Forensics => "forensics",
Self::Admin => "admin",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_report() -> Value {
json!({
"workforce_kpi_explain": {
"kpi_score": 48,
"confidence": "low",
"coverage": {"agent_coverage_percent": 58},
"factors": [{"name": "remote_session_activity"}]
},
"ueba_risk": {"score": 91, "level": "critical"},
"agent_coverage_sla": {"coverage_pct": 58, "sla_status": "CRITICAL"},
"security_correlation": [{"correlation_score": 81}],
"risk_incident_candidates": [{"risk_level": "CRITICAL"}],
"risk_narrative": {"risk_score": 92, "risk_level": "critical"}
})
}
#[test]
fn generates_rule_based_actions_with_priorities() {
let payload = build_action_center_from_report(&sample_report(), PortalRole::Admin);
let actions = payload["actions"].as_array().unwrap();
assert!(actions.len() >= 5);
assert_eq!(payload["model"]["type"], "rule_based");
assert_eq!(payload["model"]["ml"], false);
assert_eq!(payload["model"]["llm"], false);
assert!(actions.iter().any(|item| {
item["reason_codes"]
.as_array()
.unwrap()
.iter()
.any(|code| code == "LOW_WORKFORCE_KPI")
}));
assert!(actions.iter().any(|item| {
item["reason_codes"]
.as_array()
.unwrap()
.iter()
.any(|code| code == "LOW_UEBA_CONFIDENCE")
}));
assert!(actions.iter().any(|item| item["priority"] == "critical"));
}
#[test]
fn filters_actions_by_role() {
let admin = build_action_center_from_report(&sample_report(), PortalRole::Admin);
let security = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Security);
let security_actions = security.as_array().unwrap();
assert!(!security_actions.is_empty());
assert!(
security_actions
.iter()
.all(|item| item["owner_role"] == "security" || item["owner_role"] == "forensics")
);
let manager = filter_actions_for_role(&actions_from_center(&admin), PortalRole::Manager);
let manager_actions = manager.as_array().unwrap();
assert!(
manager_actions
.iter()
.all(|item| item["owner_role"] == "manager")
);
}
#[test]
fn emits_observation_when_no_rule_matches() {
let payload = build_action_center_from_report(&json!({}), PortalRole::Executive);
let actions = payload["actions"].as_array().unwrap();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0]["priority"], "low");
assert_eq!(actions[0]["reason_codes"][0], "NORMAL_OBSERVATION");
}
}