feat(portal): add executive action center
This commit is contained in:
@@ -199,6 +199,7 @@ collectors.
|
|||||||
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
- [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md)
|
||||||
- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
|
- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md)
|
||||||
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
|
- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md)
|
||||||
|
- [Executive Action Center](docs/EXECUTIVE_ACTION_CENTER_RU.md)
|
||||||
- [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md)
|
- [Rust Agent baseline](docs/RUST_AGENT_BASELINE_RU.md)
|
||||||
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
|
- [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md)
|
||||||
- [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md)
|
- [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md)
|
||||||
|
|||||||
@@ -39,6 +39,9 @@
|
|||||||
{
|
{
|
||||||
"name": "risk"
|
"name": "risk"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "actions"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "pfsense"
|
"name": "pfsense"
|
||||||
},
|
},
|
||||||
@@ -891,6 +894,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/actions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"actions"
|
||||||
|
],
|
||||||
|
"summary": "Rule-based executive action center",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"in": "query",
|
||||||
|
"required": false,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PortalRole"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Recommended actions payload",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ActionCenterResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Query limits rejected"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Role denied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
@@ -1024,6 +1063,12 @@
|
|||||||
"risk_narrative": {
|
"risk_narrative": {
|
||||||
"$ref": "#/components/schemas/RiskNarrative"
|
"$ref": "#/components/schemas/RiskNarrative"
|
||||||
},
|
},
|
||||||
|
"recommended_actions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ActionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
"agent_quality": {
|
"agent_quality": {
|
||||||
"$ref": "#/components/schemas/JsonObject"
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
},
|
},
|
||||||
@@ -1054,6 +1099,96 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
|
"ActionItem": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"priority",
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"owner_role",
|
||||||
|
"recommended_deadline",
|
||||||
|
"reason_codes",
|
||||||
|
"evidence"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"priority": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"low",
|
||||||
|
"medium",
|
||||||
|
"high",
|
||||||
|
"critical"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"owner_role": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"executive",
|
||||||
|
"manager",
|
||||||
|
"security",
|
||||||
|
"forensics",
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"recommended_deadline": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reason_codes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"ActionCenterResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"ok",
|
||||||
|
"actions",
|
||||||
|
"model"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"role_context": {
|
||||||
|
"$ref": "#/components/schemas/RoleContext"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ActionItem"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"$ref": "#/components/schemas/JsonObject"
|
||||||
|
},
|
||||||
|
"generated_at_utc": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"limitations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
"RiskNarrativeEvidence": {
|
"RiskNarrativeEvidence": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -87,6 +87,30 @@ export interface RiskNarrative {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ActionPriority = "low" | "medium" | "high" | "critical";
|
||||||
|
export type ActionOwnerRole = "executive" | "manager" | "security" | "forensics" | "admin";
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
priority: ActionPriority | string;
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
owner_role: ActionOwnerRole | string;
|
||||||
|
recommended_deadline: string;
|
||||||
|
reason_codes: string[];
|
||||||
|
evidence: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCenterResponse {
|
||||||
|
ok: boolean;
|
||||||
|
role_context?: RoleContext;
|
||||||
|
actions: ActionItem[];
|
||||||
|
model?: JsonObject;
|
||||||
|
generated_at_utc?: ISODateTime;
|
||||||
|
limitations?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentQuality {
|
export interface AgentQuality {
|
||||||
collector_source?: string;
|
collector_source?: string;
|
||||||
collector_error?: string | null;
|
collector_error?: string | null;
|
||||||
@@ -218,6 +242,7 @@ export interface ReportsResponse {
|
|||||||
executive_points?: string[];
|
executive_points?: string[];
|
||||||
executive_dashboard?: ExecutiveDashboard;
|
executive_dashboard?: ExecutiveDashboard;
|
||||||
risk_narrative?: RiskNarrative;
|
risk_narrative?: RiskNarrative;
|
||||||
|
recommended_actions?: ActionItem[];
|
||||||
agent_quality?: AgentQuality;
|
agent_quality?: AgentQuality;
|
||||||
agent_coverage_sla?: AgentCoverageSla;
|
agent_coverage_sla?: AgentCoverageSla;
|
||||||
business_risk?: BusinessRiskItem[];
|
business_risk?: BusinessRiskItem[];
|
||||||
@@ -303,6 +328,7 @@ export interface DetMirPortalApi {
|
|||||||
module?: string;
|
module?: string;
|
||||||
role?: PortalRole;
|
role?: PortalRole;
|
||||||
}): Promise<RiskNarrative>;
|
}): Promise<RiskNarrative>;
|
||||||
|
getActions(options?: { role?: PortalRole }): Promise<ActionCenterResponse>;
|
||||||
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
|
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
|
||||||
getIncidents(): Promise<JsonObject>;
|
getIncidents(): Promise<JsonObject>;
|
||||||
getCases(): Promise<CaseListResponse>;
|
getCases(): Promise<CaseListResponse>;
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
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_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_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["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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,10 +23,14 @@ use serde_json::{Value, json};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
mod executive_actions;
|
||||||
mod production;
|
mod production;
|
||||||
mod risk_narrative;
|
mod risk_narrative;
|
||||||
mod workforce_kpi_explain;
|
mod workforce_kpi_explain;
|
||||||
|
|
||||||
|
use executive_actions::{
|
||||||
|
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
||||||
|
};
|
||||||
use production::{
|
use production::{
|
||||||
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
||||||
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
||||||
@@ -1316,6 +1320,7 @@ struct ReportMarkdownContext<'a> {
|
|||||||
security_events_summary: &'a SecurityEventsSummary,
|
security_events_summary: &'a SecurityEventsSummary,
|
||||||
risk_incident_candidates: &'a [RiskIncidentCandidate],
|
risk_incident_candidates: &'a [RiskIncidentCandidate],
|
||||||
incident_review_audit_summary: &'a IncidentReviewAuditSummary,
|
incident_review_audit_summary: &'a IncidentReviewAuditSummary,
|
||||||
|
recommended_actions: &'a Value,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ReportRuntimeInputs<'a> {
|
struct ReportRuntimeInputs<'a> {
|
||||||
@@ -1559,6 +1564,10 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
|
|||||||
&build_risk_narrative_from_report(&report, role, &query),
|
&build_risk_narrative_from_report(&report, role, &query),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
"/api/actions" => {
|
||||||
|
let report = build_report_payload(args, snapshot_cache, anonymize);
|
||||||
|
respond_json(request, &build_action_center_from_report(&report, role))
|
||||||
|
}
|
||||||
"/api/owner" => {
|
"/api/owner" => {
|
||||||
if !role.can_access("security") {
|
if !role.can_access("security") {
|
||||||
return respond_forbidden(request, role, "security");
|
return respond_forbidden(request, role, "security");
|
||||||
@@ -1766,7 +1775,8 @@ fn api_contract_summary() -> Value {
|
|||||||
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
|
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
|
||||||
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
|
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
|
||||||
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
||||||
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"}
|
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
|
||||||
|
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -3335,6 +3345,16 @@ fn build_reports(
|
|||||||
PortalRole::Executive,
|
PortalRole::Executive,
|
||||||
&RiskNarrativeQuery::default(),
|
&RiskNarrativeQuery::default(),
|
||||||
);
|
);
|
||||||
|
let action_signal_report = json!({
|
||||||
|
"workforce_kpi_explain": workforce_kpi_explain,
|
||||||
|
"ueba_risk": ueba_risk,
|
||||||
|
"agent_coverage_sla": agent_coverage_sla,
|
||||||
|
"security_correlation": security_correlation,
|
||||||
|
"risk_incident_candidates": risk_incident_candidates,
|
||||||
|
"risk_narrative": risk_narrative,
|
||||||
|
});
|
||||||
|
let action_center = build_action_center_from_report(&action_signal_report, PortalRole::Admin);
|
||||||
|
let recommended_actions = actions_from_center(&action_center);
|
||||||
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
|
let headline = if summary.operator_ok && summary.severity == "OK" && metrics.open_incidents == 0
|
||||||
{
|
{
|
||||||
"Контур DetMir работает штатно, критичных действий не требуется"
|
"Контур DetMir работает штатно, критичных действий не требуется"
|
||||||
@@ -3452,6 +3472,7 @@ fn build_reports(
|
|||||||
security_events_summary: &security_events_summary,
|
security_events_summary: &security_events_summary,
|
||||||
risk_incident_candidates: &risk_incident_candidates,
|
risk_incident_candidates: &risk_incident_candidates,
|
||||||
incident_review_audit_summary: &incident_review_audit_summary,
|
incident_review_audit_summary: &incident_review_audit_summary,
|
||||||
|
recommended_actions: &recommended_actions,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
json!({
|
json!({
|
||||||
@@ -3464,6 +3485,7 @@ fn build_reports(
|
|||||||
"executive_points": executive_points,
|
"executive_points": executive_points,
|
||||||
"executive_dashboard": executive_dashboard,
|
"executive_dashboard": executive_dashboard,
|
||||||
"risk_narrative": risk_narrative,
|
"risk_narrative": risk_narrative,
|
||||||
|
"recommended_actions": recommended_actions,
|
||||||
"kpis": [
|
"kpis": [
|
||||||
report_kpi("Оценка риска", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("оценка риска")),
|
report_kpi("Оценка риска", format!("{}/100", ueba_risk.get("score").and_then(Value::as_u64).unwrap_or(0)), ueba_risk.get("status").and_then(Value::as_str).unwrap_or("UNKNOWN").to_string(), ueba_risk.get("summary").and_then(Value::as_str).unwrap_or("оценка риска")),
|
||||||
report_kpi("Качество данных", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)),
|
report_kpi("Качество данных", agent_quality.quality_status.clone(), agent_quality.quality_status.clone(), &format!("источник: {}", agent_quality.collector_source)),
|
||||||
@@ -3708,6 +3730,12 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value {
|
|||||||
}
|
}
|
||||||
PortalRole::Admin => {}
|
PortalRole::Admin => {}
|
||||||
}
|
}
|
||||||
|
if let Some(actions) = object.get("recommended_actions") {
|
||||||
|
out.insert(
|
||||||
|
"recommended_actions".to_string(),
|
||||||
|
filter_actions_for_role(actions, role),
|
||||||
|
);
|
||||||
|
}
|
||||||
out.insert("ok".to_string(), Value::Bool(true));
|
out.insert("ok".to_string(), Value::Bool(true));
|
||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
@@ -7182,6 +7210,7 @@ fn render_report_markdown(
|
|||||||
context.risk_heatmap,
|
context.risk_heatmap,
|
||||||
context.security_correlation,
|
context.security_correlation,
|
||||||
);
|
);
|
||||||
|
append_recommended_actions_markdown(&mut text, context.recommended_actions);
|
||||||
append_executive_dashboard_markdown(&mut text, context.executive_dashboard);
|
append_executive_dashboard_markdown(&mut text, context.executive_dashboard);
|
||||||
text.push_str("## Ключевые показатели\n\n");
|
text.push_str("## Ключевые показатели\n\n");
|
||||||
text.push_str(&format!("- Общий статус: {}\n", summary.severity));
|
text.push_str(&format!("- Общий статус: {}\n", summary.severity));
|
||||||
@@ -7356,6 +7385,58 @@ fn append_risk_narrative_markdown(text: &mut String, narrative: &Value) {
|
|||||||
text.push('\n');
|
text.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_recommended_actions_markdown(text: &mut String, actions: &Value) {
|
||||||
|
text.push_str("\n## Рекомендуемые действия\n\n");
|
||||||
|
let Some(items) = actions.as_array().filter(|items| !items.is_empty()) else {
|
||||||
|
text.push_str("- Критичных управленческих действий по текущему срезу не требуется.\n");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for action in items.iter().take(12) {
|
||||||
|
text.push_str(&format!(
|
||||||
|
"- [{}] {} — {}; срок: {}; адресат: {}; причины: {}\n",
|
||||||
|
action
|
||||||
|
.get("priority")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("low"),
|
||||||
|
action
|
||||||
|
.get("title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Действие не указано"),
|
||||||
|
action
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("обоснование не указано"),
|
||||||
|
action
|
||||||
|
.get("recommended_deadline")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("72h"),
|
||||||
|
action
|
||||||
|
.get("owner_role")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("manager"),
|
||||||
|
action
|
||||||
|
.get("reason_codes")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
let evidence = action
|
||||||
|
.get("evidence")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.take(3)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if !evidence.is_empty() {
|
||||||
|
text.push_str(&format!(" - Обоснование: {}\n", evidence.join("; ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveDashboard) {
|
fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveDashboard) {
|
||||||
text.push_str("\n## Сводка руководителя\n\n");
|
text.push_str("\n## Сводка руководителя\n\n");
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
@@ -10637,6 +10718,12 @@ mod tests {
|
|||||||
"demo navigation missing {marker}"
|
"demo navigation missing {marker}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert!(APP_JS.contains("function renderActionCenter"));
|
||||||
|
assert!(APP_JS.contains("Рекомендуемые действия ИБ"));
|
||||||
|
assert!(APP_CSS.contains(".action-center-card"));
|
||||||
|
assert!(!APP_CSS.contains("1 px"));
|
||||||
|
assert!(!APP_CSS.contains("1 fr"));
|
||||||
|
assert!(!APP_CSS.contains("8 px"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -10656,6 +10743,7 @@ mod tests {
|
|||||||
"/workforce",
|
"/workforce",
|
||||||
"/workforce/kpi/explain",
|
"/workforce/kpi/explain",
|
||||||
"/risk/narrative",
|
"/risk/narrative",
|
||||||
|
"/actions",
|
||||||
"/security",
|
"/security",
|
||||||
"/forensics",
|
"/forensics",
|
||||||
"/ueba",
|
"/ueba",
|
||||||
@@ -10678,6 +10766,8 @@ mod tests {
|
|||||||
"PfsenseReadinessResponse",
|
"PfsenseReadinessResponse",
|
||||||
"WorkforceKpiExplainResponse",
|
"WorkforceKpiExplainResponse",
|
||||||
"RiskNarrative",
|
"RiskNarrative",
|
||||||
|
"ActionItem",
|
||||||
|
"ActionCenterResponse",
|
||||||
"CaseListResponse",
|
"CaseListResponse",
|
||||||
"IncidentReviewRequest",
|
"IncidentReviewRequest",
|
||||||
"export interface DetMirPortalApi",
|
"export interface DetMirPortalApi",
|
||||||
@@ -10750,7 +10840,36 @@ mod tests {
|
|||||||
"security_correlation": [],
|
"security_correlation": [],
|
||||||
"risk_incident_candidates": [{"id": "candidate-demo"}],
|
"risk_incident_candidates": [{"id": "candidate-demo"}],
|
||||||
"ueba_risk": {"score": 10, "level": "low", "status": "WARN", "reasons": [{"code": "activity_anomaly"}]},
|
"ueba_risk": {"score": 10, "level": "low", "status": "WARN", "reasons": [{"code": "activity_anomaly"}]},
|
||||||
"incident_review_audit_summary": {"total_changes": 0}
|
"incident_review_audit_summary": {"total_changes": 0},
|
||||||
|
"recommended_actions": [
|
||||||
|
{
|
||||||
|
"priority": "high",
|
||||||
|
"title": "Проверить подразделение",
|
||||||
|
"summary": "Низкий Workforce KPI требует ручного разбора",
|
||||||
|
"owner_role": "manager",
|
||||||
|
"recommended_deadline": "24h",
|
||||||
|
"reason_codes": ["LOW_WORKFORCE_KPI"],
|
||||||
|
"evidence": ["Workforce KPI ниже порога"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "high",
|
||||||
|
"title": "Передать в ИБ",
|
||||||
|
"summary": "UEBA score требует проверки ИБ",
|
||||||
|
"owner_role": "security",
|
||||||
|
"recommended_deadline": "24h",
|
||||||
|
"reason_codes": ["HIGH_UEBA"],
|
||||||
|
"evidence": ["UEBA score high"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"priority": "high",
|
||||||
|
"title": "Провести расследование кандидата",
|
||||||
|
"summary": "Кандидат требует ручного разбора",
|
||||||
|
"owner_role": "forensics",
|
||||||
|
"recommended_deadline": "24h",
|
||||||
|
"reason_codes": ["INCIDENT_CANDIDATE"],
|
||||||
|
"evidence": ["Есть кандидат на проверку"]
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let executive = role_filtered_report(report.clone(), PortalRole::Executive);
|
let executive = role_filtered_report(report.clone(), PortalRole::Executive);
|
||||||
@@ -10759,16 +10878,58 @@ mod tests {
|
|||||||
assert!(executive.get("security_events_summary").is_some());
|
assert!(executive.get("security_events_summary").is_some());
|
||||||
assert!(executive.get("risk_incident_candidates").is_none());
|
assert!(executive.get("risk_incident_candidates").is_none());
|
||||||
assert!(executive.get("security_correlation").is_none());
|
assert!(executive.get("security_correlation").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
executive["recommended_actions"].as_array().unwrap().len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(executive["recommended_actions"][0]["owner_role"], "manager");
|
||||||
|
|
||||||
let security = role_filtered_report(report.clone(), PortalRole::Security);
|
let security = role_filtered_report(report.clone(), PortalRole::Security);
|
||||||
assert!(security.get("ueba_risk").is_some());
|
assert!(security.get("ueba_risk").is_some());
|
||||||
assert!(security.get("risk_incident_candidates").is_some());
|
assert!(security.get("risk_incident_candidates").is_some());
|
||||||
assert!(security.get("workforce").is_none());
|
assert!(security.get("workforce").is_none());
|
||||||
assert!(security.get("workforce_policy").is_none());
|
assert!(security.get("workforce_policy").is_none());
|
||||||
|
assert_eq!(security["recommended_actions"].as_array().unwrap().len(), 2);
|
||||||
|
assert!(
|
||||||
|
security["recommended_actions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|action| action["owner_role"] == "security"
|
||||||
|
&& action["reason_codes"][0] == "HIGH_UEBA")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
security["recommended_actions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|action| action["owner_role"] == "forensics"
|
||||||
|
&& action["reason_codes"][0] == "INCIDENT_CANDIDATE")
|
||||||
|
);
|
||||||
|
|
||||||
let forensics = role_filtered_report(report, PortalRole::Forensics);
|
let forensics = role_filtered_report(report, PortalRole::Forensics);
|
||||||
assert!(forensics.get("forensics").is_some());
|
assert!(forensics.get("forensics").is_some());
|
||||||
assert!(forensics.get("workforce").is_none());
|
assert!(forensics.get("workforce").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
forensics["recommended_actions"].as_array().unwrap().len(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
forensics["recommended_actions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|action| action["owner_role"] == "security"
|
||||||
|
&& action["reason_codes"][0] == "HIGH_UEBA")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
forensics["recommended_actions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|action| action["owner_role"] == "forensics"
|
||||||
|
&& action["reason_codes"][0] == "INCIDENT_CANDIDATE")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -12221,6 +12382,26 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("## Объяснение индекса активности")
|
.contains("## Объяснение индекса активности")
|
||||||
);
|
);
|
||||||
|
assert!(report["recommended_actions"].is_array());
|
||||||
|
assert!(!report["recommended_actions"].as_array().unwrap().is_empty());
|
||||||
|
assert!(
|
||||||
|
report["recommended_actions"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|item| item["reason_codes"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|code| code == "LOW_COVERAGE"))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
report["markdown"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("## Рекомендуемые действия")
|
||||||
|
);
|
||||||
|
assert!(report["markdown"].as_str().unwrap().contains("причины:"));
|
||||||
assert!(report["risk_narrative"].is_object());
|
assert!(report["risk_narrative"].is_object());
|
||||||
assert_eq!(report["risk_narrative"]["ok"], true);
|
assert_eq!(report["risk_narrative"]["ok"], true);
|
||||||
assert_eq!(report["risk_narrative"]["model"]["type"], "rule_based");
|
assert_eq!(report["risk_narrative"]["model"]["type"], "rule_based");
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ pub(crate) fn is_limited_api_route(path: &str) -> bool {
|
|||||||
| "/api/pfsense"
|
| "/api/pfsense"
|
||||||
| "/api/workforce/kpi/explain"
|
| "/api/workforce/kpi/explain"
|
||||||
| "/api/risk/narrative"
|
| "/api/risk/narrative"
|
||||||
|
| "/api/actions"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -988,6 +988,83 @@ h1 {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-center-card {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-actions-card {
|
||||||
|
border-left: 4px solid #7c2d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-center-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 196px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item-head strong,
|
||||||
|
.action-item p,
|
||||||
|
.action-item li {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-meta span {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-reasons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-reasons span {
|
||||||
|
padding: 4px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-item ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.risk-narrative-grid {
|
.risk-narrative-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -1303,6 +1380,8 @@ pre {
|
|||||||
.executive-grid { grid-template-columns: 1fr; }
|
.executive-grid { grid-template-columns: 1fr; }
|
||||||
.risk-narrative-grid { grid-template-columns: 1fr; }
|
.risk-narrative-grid { grid-template-columns: 1fr; }
|
||||||
.risk-narrative-columns { grid-template-columns: 1fr; }
|
.risk-narrative-columns { grid-template-columns: 1fr; }
|
||||||
|
.action-center-list { grid-template-columns: 1fr; }
|
||||||
|
.action-meta { grid-template-columns: 1fr; }
|
||||||
.analytics-grid { grid-template-columns: 1fr; }
|
.analytics-grid { grid-template-columns: 1fr; }
|
||||||
.incident-row { grid-template-columns: 1fr; }
|
.incident-row { grid-template-columns: 1fr; }
|
||||||
.evidence-row { grid-template-columns: 1fr; }
|
.evidence-row { grid-template-columns: 1fr; }
|
||||||
|
|||||||
@@ -738,6 +738,64 @@ function renderRiskNarrative(report) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderActionCenter(actions, options = {}) {
|
||||||
|
const items = Array.isArray(actions) ? actions.slice(0, 6) : [];
|
||||||
|
if (!items.length) {
|
||||||
|
return `
|
||||||
|
<section class="card action-center-card">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3>${ui(options.title || "Рекомендуемые действия")}</h3>
|
||||||
|
<p class="muted">Критичных действий по текущему срезу не требуется.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge status-ok">low</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<section class="card action-center-card ${options.security ? "security-actions-card" : ""}">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h3 ${tooltip("Rule-based список действий: что сделать, кому адресовано, срок и почему система предлагает это действие.")}>${ui(options.title || "Рекомендуемые действия")}</h3>
|
||||||
|
<p class="muted">Рекомендации сформированы по Workforce KPI, UEBA, покрытию данных, корреляции безопасности и кандидатам на проверку.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge ${statusClass(items[0]?.priority)}">${ui(items[0]?.priority || "low")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-center-list">
|
||||||
|
${items.map(action => `
|
||||||
|
<article class="action-item">
|
||||||
|
<div class="action-item-head">
|
||||||
|
<span class="badge ${statusClass(action.priority)}">${ui(action.priority || "low")}</span>
|
||||||
|
<strong>${ui(action.title || "Действие не указано")}</strong>
|
||||||
|
</div>
|
||||||
|
<p>${ui(action.summary || "Описание действия не указано")}</p>
|
||||||
|
<div class="action-meta">
|
||||||
|
<span><strong>Срок</strong> ${ui(action.recommended_deadline || "72h")}</span>
|
||||||
|
<span><strong>Адресат</strong> ${ui(actionOwnerLabel(action.owner_role))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-reasons">
|
||||||
|
${(Array.isArray(action.reason_codes) ? action.reason_codes : []).slice(0, 4).map(code => `<span>${ui(code)}</span>`).join("")}
|
||||||
|
</div>
|
||||||
|
<ul>${(Array.isArray(action.evidence) ? action.evidence : []).slice(0, 3).map(item => `<li>${ui(item)}</li>`).join("")}</ul>
|
||||||
|
</article>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionOwnerLabel(value) {
|
||||||
|
const map = {
|
||||||
|
executive: "Руководитель",
|
||||||
|
manager: "Руководитель подразделения",
|
||||||
|
security: "ИБ",
|
||||||
|
forensics: "Расследования",
|
||||||
|
admin: "Эксплуатация"
|
||||||
|
};
|
||||||
|
return map[value] || value || "Ответственный";
|
||||||
|
}
|
||||||
|
|
||||||
function optionalPercent(value) {
|
function optionalPercent(value) {
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isFinite(number) ? `${Math.round(number)}%` : "нет данных";
|
return Number.isFinite(number) ? `${Math.round(number)}%` : "нет данных";
|
||||||
@@ -1289,6 +1347,7 @@ function renderOperatorRoleContent(mode, data, report, extras = {}) {
|
|||||||
function renderExecutiveView(report, incidents) {
|
function renderExecutiveView(report, incidents) {
|
||||||
return `
|
return `
|
||||||
${renderRiskNarrative(report)}
|
${renderRiskNarrative(report)}
|
||||||
|
${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия" })}
|
||||||
${renderExecutiveDashboard(report)}
|
${renderExecutiveDashboard(report)}
|
||||||
${renderKpiExplain(report?.workforce_kpi_explain)}
|
${renderKpiExplain(report?.workforce_kpi_explain)}
|
||||||
${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })}
|
${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })}
|
||||||
@@ -1301,6 +1360,7 @@ function renderExecutiveView(report, incidents) {
|
|||||||
function renderSecurityView(data, report, extras = {}) {
|
function renderSecurityView(data, report, extras = {}) {
|
||||||
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
|
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
|
||||||
return `
|
return `
|
||||||
|
${renderActionCenter(report?.recommended_actions, { title: "Рекомендуемые действия ИБ", security: true })}
|
||||||
${renderSecurityEventsSummary(report?.security_events_summary)}
|
${renderSecurityEventsSummary(report?.security_events_summary)}
|
||||||
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
|
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
|
||||||
${renderSecurityCorrelation(report?.security_correlation)}
|
${renderSecurityCorrelation(report?.security_correlation)}
|
||||||
@@ -2933,6 +2993,7 @@ function renderReports(data) {
|
|||||||
</div>
|
</div>
|
||||||
${renderPeriodBanner(data)}
|
${renderPeriodBanner(data)}
|
||||||
${renderRiskNarrative(data)}
|
${renderRiskNarrative(data)}
|
||||||
|
${renderActionCenter(data.recommended_actions, { title: "Рекомендуемые действия" })}
|
||||||
${renderExecutiveDashboard(data)}
|
${renderExecutiveDashboard(data)}
|
||||||
${renderReportTypeCards()}
|
${renderReportTypeCards()}
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Executive Action Center
|
||||||
|
|
||||||
|
Executive Action Center преобразует существующую аналитику AWatch-rus в
|
||||||
|
конкретные управленческие действия.
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
Модуль отвечает на вопросы:
|
||||||
|
|
||||||
|
- что рекомендуется сделать;
|
||||||
|
- насколько это срочно;
|
||||||
|
- кому адресовано действие;
|
||||||
|
- почему система предлагает именно это действие.
|
||||||
|
|
||||||
|
Action Center не выполняет действия автоматически. Он не блокирует
|
||||||
|
пользователей, не меняет политики и не является DLP/EDR/SIEM.
|
||||||
|
|
||||||
|
## Контракт действия
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": "high",
|
||||||
|
"title": "Проверить подразделение с низким индексом активности",
|
||||||
|
"summary": "Индекс активности ниже управленческого порога; требуется проверка причины просадки",
|
||||||
|
"owner_role": "manager",
|
||||||
|
"recommended_deadline": "24h",
|
||||||
|
"reason_codes": [
|
||||||
|
"LOW_WORKFORCE_KPI",
|
||||||
|
"HIGH_REMOTE_ACTIVITY"
|
||||||
|
],
|
||||||
|
"evidence": [
|
||||||
|
"Workforce KPI ниже целевого уровня: 62%",
|
||||||
|
"Рост удаленных сессий влияет на управленческий риск"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/actions
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживается role filtering через `role` query parameter или
|
||||||
|
`X-AWatch-Role` header.
|
||||||
|
|
||||||
|
`/api/reports` дополнительно содержит:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recommended_actions": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Уровни приоритета
|
||||||
|
|
||||||
|
- `low` - наблюдение, контроль на следующем регулярном срезе;
|
||||||
|
- `medium` - требуется плановая проверка;
|
||||||
|
- `high` - требуется действие в течение 24 часов;
|
||||||
|
- `critical` - требуется срочная ручная проверка, обычно до 4 часов.
|
||||||
|
|
||||||
|
## Rule-based правила
|
||||||
|
|
||||||
|
| Reason code | Сигнал | Рекомендуемое действие | Адресат |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `LOW_WORKFORCE_KPI` | Workforce KPI ниже целевого уровня | Проверить подразделение с низким индексом активности | `manager` |
|
||||||
|
| `HIGH_REMOTE_ACTIVITY` | Фактор удаленных сессий в explainability | Проверить причину роста удаленных сессий | `manager` |
|
||||||
|
| `LOW_COVERAGE` | Низкое покрытие агентов или плохой SLA полноты данных | Проверить состояние агентов | `admin` |
|
||||||
|
| `HIGH_UEBA` | Повышенный UEBA score | Передать данные в контур ИБ | `security` |
|
||||||
|
| `HIGH_SECURITY_CORRELATION` | Высокая корреляция активности и ИБ-событий | Проверить связь активности и ИБ-событий | `security` |
|
||||||
|
| `INCIDENT_CANDIDATE` | Есть кандидаты на проверку | Провести расследование кандидатов | `forensics` |
|
||||||
|
| `RISK_NARRATIVE_HIGH` | Высокий Risk Narrative score | Назначить владельца корректирующих действий | `executive` |
|
||||||
|
| `NORMAL_OBSERVATION` | Критичные правила не сработали | Продолжить наблюдение | `manager` |
|
||||||
|
|
||||||
|
Все правила детерминированные. ML, LLM и прогнозные модели не используются.
|
||||||
|
|
||||||
|
## Ролевое отображение
|
||||||
|
|
||||||
|
- `executive` видит управленческие, менеджерские и эксплуатационные действия;
|
||||||
|
- `manager` видит действия для руководителя подразделения;
|
||||||
|
- `security` видит действия ИБ и действия, которые нужно передать в
|
||||||
|
расследование;
|
||||||
|
- `forensics` видит действия расследований и связанные действия ИБ;
|
||||||
|
- `admin` видит полный список.
|
||||||
|
|
||||||
|
Сервер фильтрует действия по роли. UI скрывает нерелевантные действия только
|
||||||
|
после серверной фильтрации.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Action Center является decision-support слоем, а не auto-remediation.
|
||||||
|
- Рекомендации требуют ручной проверки ответственным контуром.
|
||||||
|
- Модуль не подтверждает нарушение сам по себе.
|
||||||
|
- pfSense остается `contract_only`, если ingestion отдельно не включен и не
|
||||||
|
прошел приемку.
|
||||||
|
- Planned/Future collectors не считаются реализованными источниками данных.
|
||||||
@@ -254,3 +254,56 @@ docs/EXECUTIVE_ACTION_CENTER_RU.md
|
|||||||
7. Тесты.
|
7. Тесты.
|
||||||
8. Проверки.
|
8. Проверки.
|
||||||
9. Ограничения.
|
9. Ограничения.
|
||||||
|
|
||||||
|
## Выполнение
|
||||||
|
|
||||||
|
Статус: done.
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `adk-rust/crates/detmir-portal/src/executive_actions.rs`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/main.rs`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/static/app.js`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/static/app.css`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/contracts/openapi.json`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts`;
|
||||||
|
- `adk-rust/crates/detmir-portal/src/production/limits.rs`;
|
||||||
|
- `scripts/awatch-production-hardening-smoke.mjs`;
|
||||||
|
- `docs/EXECUTIVE_ACTION_CENTER_RU.md`;
|
||||||
|
- `README.md`.
|
||||||
|
|
||||||
|
Новые endpoints:
|
||||||
|
|
||||||
|
- `GET /api/actions`.
|
||||||
|
|
||||||
|
Action model:
|
||||||
|
|
||||||
|
- `priority`;
|
||||||
|
- `title`;
|
||||||
|
- `summary`;
|
||||||
|
- `owner_role`;
|
||||||
|
- `recommended_deadline`;
|
||||||
|
- `reason_codes`;
|
||||||
|
- `evidence`.
|
||||||
|
|
||||||
|
Rule engine:
|
||||||
|
|
||||||
|
- deterministic rule-based;
|
||||||
|
- использует существующие сигналы Workforce KPI, UEBA, coverage, security
|
||||||
|
correlation, incident candidates и Risk Narrative;
|
||||||
|
- не использует ML/LLM;
|
||||||
|
- не выполняет auto-remediation.
|
||||||
|
|
||||||
|
UI:
|
||||||
|
|
||||||
|
- Executive View показывает блок `Рекомендуемые действия`;
|
||||||
|
- Security View показывает блок `Рекомендуемые действия ИБ` с ИБ-действиями и
|
||||||
|
действиями, передаваемыми в расследование;
|
||||||
|
- Markdown report содержит раздел `## Рекомендуемые действия`.
|
||||||
|
|
||||||
|
Ограничения:
|
||||||
|
|
||||||
|
- рекомендации не выполняются автоматически;
|
||||||
|
- пользователи не блокируются;
|
||||||
|
- политики не меняются;
|
||||||
|
- DLP/EDR/ML/LLM не добавлялись.
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ async function main() {
|
|||||||
assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list");
|
assert(Array.isArray(riskNarrative.json?.why), "Risk narrative must include why list");
|
||||||
assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based");
|
assert(riskNarrative.json?.model?.type === "rule_based", "Risk narrative must be rule-based");
|
||||||
|
|
||||||
|
const actions = await request("/api/actions?role=executive");
|
||||||
|
assert(actions.response.status === 200, "Actions API must return 200");
|
||||||
|
assert(Array.isArray(actions.json?.actions), "Actions API must include actions array");
|
||||||
|
assert(actions.json?.model?.type === "rule_based", "Actions model must be rule-based");
|
||||||
|
assert(actions.json?.model?.auto_remediation === false, "Actions must not enable auto-remediation");
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -92,6 +98,7 @@ async function main() {
|
|||||||
"role gates",
|
"role gates",
|
||||||
"/api/workforce/kpi/explain",
|
"/api/workforce/kpi/explain",
|
||||||
"/api/risk/narrative",
|
"/api/risk/narrative",
|
||||||
|
"/api/actions",
|
||||||
],
|
],
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user