diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index ee80fb4..631939c 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -351,6 +351,8 @@ struct RiskIncidentCandidate { #[serde(skip_serializing_if = "Option::is_none")] recommendation: Option, incident_review: IncidentReviewState, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + incident_review_audit: Vec, } #[derive(Debug, Serialize)] @@ -477,6 +479,28 @@ struct IncidentReviewResponse { review: IncidentReviewState, } +#[derive(Clone, Debug, Deserialize, Serialize)] +struct IncidentReviewAuditEntry { + candidate_id: String, + old_status: String, + new_status: String, + #[serde(skip_serializing_if = "Option::is_none")] + reviewer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + comment: Option, + changed_at_utc: String, +} + +#[derive(Clone, Debug, Default, Serialize)] +struct IncidentReviewAuditSummary { + total_changes: usize, + confirmed_count: usize, + false_positive_count: usize, + postponed_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + last_change_utc: Option, +} + #[derive(Debug, Serialize)] struct IncidentAuditEntry { generated_at_utc: String, @@ -811,11 +835,13 @@ struct ReportMarkdownContext<'a> { business_risk_history: &'a [BusinessRiskHistoryItem], business_risk_history_summary: &'a BusinessRiskHistorySummary, risk_incident_candidates: &'a [RiskIncidentCandidate], + incident_review_audit_summary: &'a IncidentReviewAuditSummary, } struct ReportRuntimeInputs<'a> { incident_state: &'a IncidentStateFile, incident_reviews: &'a IncidentReviewFile, + incident_review_audit: &'a [IncidentReviewAuditEntry], evidence: &'a DlpEvidenceResponse, } @@ -835,17 +861,21 @@ fn run() -> Result { if args.json_smoke { let snapshot = build_snapshot(&args); let incident_state = load_incident_state_best_effort(&args); + let incident_reviews = load_incident_review_best_effort(&args); + let incident_review_audit = load_incident_review_audit_best_effort(&args); + let evidence = build_dlp_evidence_response(&args); let ueba_baseline_path = ueba_baseline_state_path(&args); let smoke = json!({ "health": build_health(&snapshot), "summary": build_summary(&snapshot), "reports": build_reports(&snapshot, ReportRuntimeInputs { incident_state: &incident_state, - incident_reviews: &load_incident_review_best_effort(&args), - evidence: &build_dlp_evidence_response(&args), + incident_reviews: &incident_reviews, + incident_review_audit: &incident_review_audit, + evidence: &evidence, }, &args.workforce_policy_path, &args.ueba_policy_path, &ueba_baseline_path, false), "incidents": build_incidents(&snapshot, &incident_state), - "dlp_evidence": build_dlp_evidence_response(&args), + "dlp_evidence": evidence, }); println!("{}", serde_json::to_string_pretty(&smoke)?); return Ok(if build_health(&snapshot).ok { 0 } else { 2 }); @@ -944,6 +974,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) let snapshot = cached_snapshot(args, snapshot_cache); let incident_state = load_incident_state_best_effort(args); let incident_reviews = load_incident_review_best_effort(args); + let incident_review_audit = load_incident_review_audit_best_effort(args); let evidence = build_dlp_evidence_response(args); let ueba_baseline_path = ueba_baseline_state_path(args); respond_json( @@ -953,6 +984,7 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) ReportRuntimeInputs { incident_state: &incident_state, incident_reviews: &incident_reviews, + incident_review_audit: &incident_review_audit, evidence: &evidence, }, &args.workforce_policy_path, @@ -2046,7 +2078,13 @@ fn build_reports( let business_risk_history_summary = summarize_business_risk_history(&business_risk_history); let mut risk_incident_candidates = build_risk_incident_candidates(snapshot, &business_risk, &business_risk_history); - apply_incident_reviews_to_candidates(&mut risk_incident_candidates, inputs.incident_reviews); + apply_incident_reviews_to_candidates( + &mut risk_incident_candidates, + inputs.incident_reviews, + inputs.incident_review_audit, + ); + let incident_review_audit_summary = + summarize_incident_review_audit(inputs.incident_review_audit); let trend = workforce_trend_json(snapshot); let insight_items = workforce_insight_items(snapshot); let workforce_policy_explain = @@ -2158,6 +2196,7 @@ fn build_reports( business_risk_history: &business_risk_history, business_risk_history_summary: &business_risk_history_summary, risk_incident_candidates: &risk_incident_candidates, + incident_review_audit_summary: &incident_review_audit_summary, }, ); json!({ @@ -2249,6 +2288,7 @@ fn build_reports( "business_risk_history": business_risk_history, "business_risk_history_summary": business_risk_history_summary, "risk_incident_candidates": risk_incident_candidates, + "incident_review_audit_summary": incident_review_audit_summary, "workforce_policy": workforce_policy_explain, "workforce": { "department_comparison": department_items, @@ -2664,6 +2704,7 @@ fn build_risk_incident_candidates( fn apply_incident_reviews_to_candidates( candidates: &mut [RiskIncidentCandidate], reviews: &IncidentReviewFile, + audit_entries: &[IncidentReviewAuditEntry], ) { for candidate in candidates { candidate.incident_review = @@ -2678,9 +2719,39 @@ fn apply_incident_reviews_to_candidates( comment: None, updated_at: String::new(), }); + candidate.incident_review_audit = audit_entries + .iter() + .filter(|entry| entry.candidate_id == candidate.id) + .cloned() + .collect(); } } +fn summarize_incident_review_audit( + entries: &[IncidentReviewAuditEntry], +) -> IncidentReviewAuditSummary { + let mut summary = IncidentReviewAuditSummary { + total_changes: entries.len(), + ..IncidentReviewAuditSummary::default() + }; + for entry in entries { + match entry.new_status.as_str() { + "CONFIRMED" => summary.confirmed_count += 1, + "FALSE_POSITIVE" => summary.false_positive_count += 1, + "POSTPONED" => summary.postponed_count += 1, + _ => {} + } + if summary + .last_change_utc + .as_ref() + .is_none_or(|current| entry.changed_at_utc > *current) + { + summary.last_change_utc = Some(entry.changed_at_utc.clone()); + } + } + summary +} + fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec { let mut by_department = BTreeMap::>::new(); for item in history { @@ -2720,6 +2791,7 @@ fn stable_high_risk_candidates(history: &[BusinessRiskHistoryItem]) -> Vec Vec last_seen_utc: Some(snapshot.generated_at_utc.clone()), recommendation: Some(node.recommendation.clone()), incident_review: IncidentReviewState::default(), + incident_review_audit: Vec::new(), }) .collect() } @@ -4385,6 +4461,11 @@ fn render_report_markdown( ); append_risk_incident_candidates_markdown(&mut text, context.risk_incident_candidates); append_incident_review_markdown(&mut text, context.risk_incident_candidates); + append_incident_review_audit_markdown( + &mut text, + context.risk_incident_candidates, + context.incident_review_audit_summary, + ); append_ueba_risk_markdown(&mut text, context.ueba_risk); append_workforce_policy_markdown(&mut text, context.workforce_policy); text.push_str("\nПримечание: DLP/case показатели являются derived detections/cases и требуют регламентной валидации перед подачей как подтвержденные инциденты.\n"); @@ -4666,6 +4747,48 @@ fn append_incident_review_markdown(text: &mut String, candidates: &[RiskIncident } } +fn append_incident_review_audit_markdown( + text: &mut String, + candidates: &[RiskIncidentCandidate], + summary: &IncidentReviewAuditSummary, +) { + text.push_str("\n## Аудит проверки инцидентов\n\n"); + text.push_str(&format!("- Всего изменений: {}\n", summary.total_changes)); + text.push_str(&format!("- Подтверждено: {}\n", summary.confirmed_count)); + text.push_str(&format!( + "- Ложных срабатываний: {}\n", + summary.false_positive_count + )); + text.push_str(&format!("- Отложено: {}\n", summary.postponed_count)); + text.push_str(&format!( + "- Последнее изменение: {}\n", + summary.last_change_utc.as_deref().unwrap_or("-") + )); + let mut shown = 0usize; + for entry in candidates + .iter() + .flat_map(|candidate| candidate.incident_review_audit.iter()) + .rev() + { + text.push_str(&format!( + "- {}: {} -> {}, reviewer={}, changed_at={}, comment={}\n", + entry.candidate_id, + entry.old_status, + entry.new_status, + entry.reviewer.as_deref().unwrap_or("-"), + entry.changed_at_utc, + entry.comment.as_deref().unwrap_or("-") + )); + shown += 1; + if shown >= 20 { + break; + } + } + if shown == 0 { + text.push_str("- История изменений отсутствует.\n"); + } +} + fn append_ueba_risk_markdown(text: &mut String, risk: &Value) { text.push_str("\n## UEBA риск\n\n"); text.push_str(&format!( @@ -5337,16 +5460,33 @@ fn apply_incident_review(args: &Cli, actor: &str, body: &str) -> Result IncidentReviewFile { } } +fn load_incident_review_audit_best_effort(args: &Cli) -> Vec { + match load_incident_review_audit(args) { + Ok(entries) => entries, + Err(err) => { + eprintln!("detmir-portal incident review audit read failed: {err:#}"); + Vec::new() + } + } +} + fn load_incident_state(args: &Cli) -> Result { let path = incident_state_path(args); if !path.exists() { @@ -5388,6 +5538,25 @@ fn load_incident_review(args: &Cli) -> Result { serde_json::from_str(&data).with_context(|| format!("parse {}", path.display())) } +fn load_incident_review_audit(args: &Cli) -> Result> { + let path = incident_review_audit_path(args); + if !path.exists() { + return Ok(Vec::new()); + } + let data = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let mut entries = Vec::new(); + for (index, line) in data.lines().enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let entry = serde_json::from_str(line) + .with_context(|| format!("parse {} line {}", path.display(), index + 1))?; + entries.push(entry); + } + Ok(entries) +} + fn save_incident_state(args: &Cli, state: &IncidentStateFile) -> Result<()> { fs::create_dir_all(&args.state_dir) .with_context(|| format!("create {}", args.state_dir.display()))?; @@ -5425,6 +5594,21 @@ fn append_incident_audit(args: &Cli, entry: &IncidentAuditEntry) -> Result<()> { Ok(()) } +fn append_incident_review_audit(args: &Cli, entry: &IncidentReviewAuditEntry) -> Result<()> { + let path = incident_review_audit_path(args); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("open {}", path.display()))?; + serde_json::to_writer(&mut file, entry)?; + file.write_all(b"\n")?; + Ok(()) +} + fn incident_state_path(args: &Cli) -> PathBuf { args.state_dir.join("incidents-state.json") } @@ -5433,6 +5617,12 @@ fn incident_review_path(args: &Cli) -> PathBuf { args.state_dir.join("data").join("incident_reviews.json") } +fn incident_review_audit_path(args: &Cli) -> PathBuf { + args.state_dir + .join("data") + .join("incident_review_audit.jsonl") +} + fn build_dlp_evidence_response(args: &Cli) -> DlpEvidenceResponse { let generated_at_utc = now(); let db_available = args.dlp_db_path.exists(); @@ -7316,6 +7506,14 @@ mod tests { stored.reviews["risk-candidate-123"].comment.as_deref(), Some("confirmed by manual check") ); + assert!(incident_review_audit_path(&args).ends_with("data/incident_review_audit.jsonl")); + let audit = load_incident_review_audit(&args).unwrap(); + assert_eq!(audit.len(), 1); + assert_eq!(audit[0].candidate_id, "risk-candidate-123"); + assert_eq!(audit[0].old_status, "NEW"); + assert_eq!(audit[0].new_status, "CONFIRMED"); + assert_eq!(audit[0].reviewer.as_deref(), Some("operator")); + assert_eq!(summarize_incident_review_audit(&audit).confirmed_count, 1); let mut candidates = vec![RiskIncidentCandidate { id: "risk-candidate-123".to_string(), department: Some("Подразделение".to_string()), @@ -7328,9 +7526,11 @@ mod tests { last_seen_utc: None, recommendation: None, incident_review: IncidentReviewState::default(), + incident_review_audit: Vec::new(), }]; - apply_incident_reviews_to_candidates(&mut candidates, &stored); + apply_incident_reviews_to_candidates(&mut candidates, &stored, &audit); assert_eq!(candidates[0].incident_review.status, "CONFIRMED"); + assert_eq!(candidates[0].incident_review_audit.len(), 1); assert_eq!( validate_incident_review_status("FALSE_POSITIVE").unwrap(), "FALSE_POSITIVE" @@ -7713,6 +7913,7 @@ mod tests { ReportRuntimeInputs { incident_state: &IncidentStateFile::default(), incident_reviews: &IncidentReviewFile::default(), + incident_review_audit: &[], evidence: &evidence, }, &missing_policy, @@ -7823,6 +8024,7 @@ mod tests { report["risk_incident_candidates"][0]["incident_review"]["status"], "NEW" ); + assert_eq!(report["incident_review_audit_summary"]["total_changes"], 0); assert!( report["executive_points"] .as_array() @@ -7880,6 +8082,12 @@ mod tests { .unwrap() .contains("## Проверка кандидатов в инциденты") ); + assert!( + report["markdown"] + .as_str() + .unwrap() + .contains("## Аудит проверки инцидентов") + ); assert!(report["markdown"].as_str().unwrap().contains("причины:")); assert!( report["markdown"] diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index c0e18bd..deea633 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -604,6 +604,29 @@ h1 { font-weight: 800; } +.review-history { + margin-top: 6px; + max-width: 320px; +} + +.review-history summary { + cursor: pointer; + color: var(--link); + font-size: 12px; + font-weight: 800; +} + +.review-history ul { + display: grid; + gap: 6px; + margin: 6px 0 0; + padding-left: 16px; +} + +.review-history li { + overflow-wrap: anywhere; +} + .heatmap-table td { min-width: 120px; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index 02b570d..3503fc6 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -1779,7 +1779,28 @@ function renderCandidateReview(item) { const comment = review.comment || "комментария нет"; const reviewer = review.reviewer || "проверяющий не указан"; const updated = review.updated_at || "не обновлялось"; - return `${ui(reviewStatusText(status))}
${ui(reviewer)} · ${ui(updated)}
${ui(comment)}`; + const audit = Array.isArray(item?.incident_review_audit) ? item.incident_review_audit.slice(-4).reverse() : []; + const history = audit.length ? ` +
+ История изменений +
    + ${audit.map(entry => ` +
  • + ${ui(reviewStatusText(entry.old_status))} → ${ui(reviewStatusText(entry.new_status))}
    + ${ui(entry.reviewer || "проверяющий не указан")} · ${ui(entry.changed_at_utc || "-")}
    + ${ui(entry.comment || "комментария нет")} +
  • + `).join("")} +
+
+ ` : `История изменений отсутствует`; + return ` + ${ui(reviewStatusText(status))}
+ Изменил: ${ui(reviewer)}
+ Когда: ${ui(updated)}
+ Комментарий: ${ui(comment)} + ${history} + `; } function renderCandidateReviewActions(item) { diff --git a/docs/BUSINESS_RISK_RU.md b/docs/BUSINESS_RISK_RU.md index e4065c5..f9654be 100644 --- a/docs/BUSINESS_RISK_RU.md +++ b/docs/BUSINESS_RISK_RU.md @@ -69,6 +69,7 @@ Business Risk не является автоматическим обвинен - `last_seen_utc` - последнее наблюдение; - `recommendation` - рекомендуемое действие. - `incident_review` - статус проверки кандидата. +- `incident_review_audit` - история изменений статуса проверки кандидата. `incident_review`: @@ -78,6 +79,15 @@ Business Risk не является автоматическим обвинен - `comment` - комментарий проверки; - `updated_at` - время последнего изменения статуса. +`incident_review_audit`: + +- `candidate_id` - идентификатор кандидата; +- `old_status` - предыдущий статус; +- `new_status` - новый статус; +- `reviewer` - проверяющий, если указан; +- `comment` - комментарий к изменению; +- `changed_at_utc` - время изменения в UTC. + Для изменения review-статуса используется: ```http @@ -99,9 +109,13 @@ POST /api/incident-review ```text /data/incident_reviews.json +/data/incident_review_audit.jsonl ``` Если файла нет, все кандидаты отображаются как `NEW`. +Если audit-файла нет, портал продолжает работать, а история изменений +отображается как отсутствующая. Audit JSONL является append-only журналом: +история изменений не удаляется и не перезаписывается при смене статуса. ## Логика риска @@ -155,6 +169,14 @@ POST /api/incident-review - `Ложный` -> `FALSE_POSITIVE`; - `Отложить` -> `POSTPONED`. +Для каждого кандидата портал показывает текущий статус, проверяющего, время +последнего изменения, последний комментарий и раскрываемую историю изменений. +Это формирует доказательную цепочку: + +```text +кандидат -> проверка -> решение -> кто/когда/почему +``` + ## Markdown Оперативный отчет содержит раздел: @@ -164,6 +186,7 @@ POST /api/incident-review ## Динамика бизнес-рисков ## Кандидаты в инциденты ## Проверка кандидатов в инциденты +## Аудит проверки инцидентов ``` Раздел можно использовать в PDF/Markdown-отчете для руководителя.