From 07dfb95b6b3593792d3a47a8fefc7d29604dbc02 Mon Sep 17 00:00:00 2001 From: igor04091968 Date: Sun, 7 Jun 2026 14:12:32 +0300 Subject: [PATCH] feat(portal): harden readiness and explain workforce KPI --- README.md | 2 + .../detmir-portal/src/contracts/openapi.json | 173 ++ .../src/contracts/typescript.d.ts | 35 + adk-rust/crates/detmir-portal/src/main.rs | 1862 ++++++++++++++++- .../crates/detmir-portal/src/static/app.css | 20 +- .../crates/detmir-portal/src/static/app.js | 99 +- docs/EXPLAINABLE_KPI_RU.md | 111 + docs/PRODUCTION_READINESS_RU.md | 109 + scripts/awatch-production-hardening-smoke.mjs | 94 + 9 files changed, 2458 insertions(+), 47 deletions(-) create mode 100644 docs/EXPLAINABLE_KPI_RU.md create mode 100644 scripts/awatch-production-hardening-smoke.mjs diff --git a/README.md b/README.md index a9f25ee..f9d89a7 100755 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ collectors. - [Pilot v1 demo](docs/PILOT_DEMO_SCENARIO_RU.md) - [Pilot v1.0 acceptance checklist](docs/PILOT_V1_ACCEPTANCE_CHECKLIST_RU.md) - [Pilot v1.0 evidence](docs/PILOT_V1_EVIDENCE_RU.md) +- [Production readiness портала](docs/PRODUCTION_READINESS_RU.md) +- [Explainable Workforce KPI](docs/EXPLAINABLE_KPI_RU.md) - [Итог production-расследования 2026-06-07](docs/PRODUCTION_INCIDENT_REPORT_2026-06-07_RU.md) - [Runbook восстановления worktime reports](docs/OPERATIONS_RUNBOOK_WORKTIME_RU.md) - [Позиционирование продукта](docs/PRODUCT_POSITIONING_RU.md) diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json index f873059..6b72747 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/openapi.json +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -766,6 +766,67 @@ } } } + }, + "/workforce/kpi/explain": { + "get": { + "tags": [ + "workforce" + ], + "summary": "Rule-based Workforce KPI explanation", + "parameters": [ + { + "name": "date", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date" + } + }, + { + "name": "department", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "owner", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "role", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/PortalRole" + } + } + ], + "responses": { + "200": { + "description": "Explainable Workforce KPI payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkforceKpiExplain" + } + } + } + }, + "400": { + "description": "Query limits rejected" + }, + "403": { + "description": "Role denied" + } + } + } } }, "components": { @@ -1172,6 +1233,118 @@ } }, "additionalProperties": true + }, + "WorkforceKpiExplainFactor": { + "type": "object", + "required": [ + "name", + "label", + "impact", + "explanation" + ], + "properties": { + "name": { + "type": "string" + }, + "label": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "explanation": { + "type": "string" + } + }, + "additionalProperties": true + }, + "WorkforceKpiExplain": { + "type": "object", + "required": [ + "ok", + "kpi_score", + "confidence", + "coverage", + "factors", + "top_applications", + "warnings", + "recommendations" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "role_context": { + "$ref": "#/components/schemas/RoleContext" + }, + "scope": { + "type": "string" + }, + "kpi_score": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "kpi_status": { + "type": "string" + }, + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low" + ] + }, + "coverage": { + "type": "object", + "properties": { + "agent_coverage_percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "data_freshness": { + "type": "string" + }, + "missing_sources": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": true + }, + "factors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkforceKpiExplainFactor" + } + }, + "top_applications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonObject" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommendations": { + "type": "array", + "items": { + "type": "string" + } + }, + "model": { + "$ref": "#/components/schemas/JsonObject" + } + }, + "additionalProperties": true } } } diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts index bed5154..1bec9b9 100644 --- a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -88,6 +88,35 @@ export interface AgentCoverageSla { [key: string]: unknown; } +export interface WorkforceKpiExplainFactor { + name: string; + label: string; + impact: string; + explanation: string; + [key: string]: unknown; +} + +export interface WorkforceKpiExplainResponse { + ok: boolean; + role_context?: RoleContext; + scope?: "aggregate" | string; + kpi_score: number; + kpi_status?: string; + confidence: "high" | "medium" | "low" | string; + coverage: { + agent_coverage_percent: number; + data_freshness: "fresh" | "stale" | "missing" | string; + missing_sources: string[]; + [key: string]: unknown; + }; + factors: WorkforceKpiExplainFactor[]; + top_applications: JsonObject[]; + warnings: string[]; + recommendations: string[]; + model?: JsonObject; + [key: string]: unknown; +} + export interface BusinessRiskItem { department?: string; trust_score?: number; @@ -263,4 +292,10 @@ export interface DetMirPortalApi { getReadinessBundle(): Promise; verifyReadiness(): Promise; getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise; + getWorkforceKpiExplain(options?: { + date?: string; + department?: string; + owner?: string; + role?: PortalRole; + }): Promise; } diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 8df47e2..5b87c45 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -1,9 +1,12 @@ +use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as FmtWrite; use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -33,6 +36,24 @@ const UEBA_BASELINE_MIN_SAMPLES: usize = 3; const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120); const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению"; const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения"; +const PORTAL_SCHEMA_VERSION: &str = "pilot-v1"; +const DEFAULT_MAX_PAGE_SIZE: u32 = 500; +const DEFAULT_PAGE_SIZE: u32 = 100; +const DEFAULT_MAX_REPORT_DATE_RANGE_DAYS: i64 = 31; +const DEFAULT_REQUEST_TIMEOUT_SECONDS: u64 = 20; +const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 16 * 1024 * 1024; +const DEFAULT_SLOW_REQUEST_LOG_MS: u64 = 1_000; +const MAX_ALLOWED_PAGE_SIZE: u32 = 5_000; +const MAX_ALLOWED_REPORT_DATE_RANGE_DAYS: i64 = 366; +const MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS: u64 = 120; +const MAX_ALLOWED_REQUEST_BODY_BYTES: u64 = 64 * 1024 * 1024; + +static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1); +static PORTAL_METRICS: OnceLock> = OnceLock::new(); + +thread_local! { + static REQUEST_STARTED_AT: RefCell> = const { RefCell::new(None) }; +} #[cfg(unix)] const SIGKILL: i32 = 9; @@ -45,6 +66,30 @@ unsafe extern "C" { type SnapshotCache = Arc>>; +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)] +struct HttpMetricKey { + method: String, + route: String, + status: u16, + module: String, +} + +#[derive(Clone, Debug, Default)] +struct HttpMetricValue { + requests_total: u64, + duration_seconds_sum: f64, + duration_seconds_count: u64, +} + +#[derive(Clone, Debug, Default)] +struct PortalMetrics { + http: BTreeMap, + reports_generated_total: u64, + ingestion_records_total: u64, + ingestion_rejected_total: u64, + role_denied_total: u64, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] enum PortalRole { @@ -179,6 +224,58 @@ struct Cli { #[arg(long, default_value_t = 10, env = "DETMIR_PORTAL_TIMEOUT_SECONDS")] timeout_seconds: u64, + #[arg( + long, + default_value_t = DEFAULT_MAX_PAGE_SIZE, + env = "AWATCH_PORTAL_MAX_PAGE_SIZE" + )] + max_page_size: u32, + + #[arg( + long, + default_value_t = DEFAULT_PAGE_SIZE, + env = "AWATCH_PORTAL_DEFAULT_PAGE_SIZE" + )] + default_page_size: u32, + + #[arg( + long, + default_value_t = DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + env = "AWATCH_PORTAL_MAX_REPORT_DATE_RANGE_DAYS" + )] + max_report_date_range_days: i64, + + #[arg( + long, + default_value_t = DEFAULT_REQUEST_TIMEOUT_SECONDS, + env = "AWATCH_PORTAL_REQUEST_TIMEOUT_SECONDS" + )] + request_timeout_seconds: u64, + + #[arg( + long, + default_value_t = DEFAULT_MAX_REQUEST_BODY_BYTES, + env = "AWATCH_PORTAL_MAX_REQUEST_BODY_BYTES" + )] + max_request_body_bytes: u64, + + #[arg( + long, + default_value_t = DEFAULT_SLOW_REQUEST_LOG_MS, + env = "AWATCH_PORTAL_SLOW_REQUEST_LOG_MS" + )] + slow_request_log_ms: u64, + + #[arg(long, default_value = "local", env = "AWATCH_PORTAL_ENVIRONMENT")] + environment: String, + + #[arg( + long, + default_value = "executive,workforce,security,forensics,admin", + env = "AWATCH_PORTAL_ENABLED_MODULES" + )] + enabled_modules: String, + #[arg( long, default_value = "/var/lib/detmir-portal", @@ -1206,6 +1303,15 @@ struct WeightedActivity { employee_details: Vec, } +struct KpiFactorInputs<'a> { + users_count: usize, + active_seconds: i64, + apps_count: usize, + kpi_score: u8, + agent_coverage_percent: u8, + missing_sources: &'a [String], +} + #[derive(Debug)] struct AppWeightDetail { application: String, @@ -1227,6 +1333,7 @@ struct ReportMarkdownContext<'a> { workforce: &'a ReportWorkforceSummary, executive_dashboard: &'a ExecutiveDashboard, workforce_policy: &'a Value, + workforce_kpi_explain: &'a Value, ueba_risk: &'a Value, business_risk: &'a [BusinessRiskItem], business_risk_history: &'a [BusinessRiskHistoryItem], @@ -1269,8 +1376,107 @@ fn main() { std::process::exit(code); } +fn validate_portal_config(args: &Cli) -> Result<()> { + let (host, port) = args + .bind + .rsplit_once(':') + .ok_or_else(|| anyhow!("invalid config bind: expected host:port"))?; + if host.trim().is_empty() { + return Err(anyhow!("invalid config host: value is empty")); + } + let port = port + .parse::() + .map_err(|_| anyhow!("invalid config port: expected 1..65535"))?; + if port == 0 { + return Err(anyhow!("invalid config port: expected 1..65535")); + } + if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE { + return Err(anyhow!( + "invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}" + )); + } + if args.default_page_size == 0 || args.default_page_size > args.max_page_size { + return Err(anyhow!( + "invalid config default_page_size: expected 1..=max_page_size" + )); + } + if args.max_report_date_range_days <= 0 + || args.max_report_date_range_days > MAX_ALLOWED_REPORT_DATE_RANGE_DAYS + { + return Err(anyhow!( + "invalid config max_report_date_range_days: expected 1..={MAX_ALLOWED_REPORT_DATE_RANGE_DAYS}" + )); + } + if args.request_timeout_seconds == 0 + || args.request_timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS + { + return Err(anyhow!( + "invalid config request_timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}" + )); + } + if args.timeout_seconds == 0 || args.timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS { + return Err(anyhow!( + "invalid config timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}" + )); + } + if args.max_request_body_bytes < 1024 + || args.max_request_body_bytes > MAX_ALLOWED_REQUEST_BODY_BYTES + { + return Err(anyhow!( + "invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}" + )); + } + if !is_safe_environment_name(&args.environment) { + return Err(anyhow!( + "invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -" + )); + } + let modules = enabled_modules(&args.enabled_modules); + if modules.is_empty() { + return Err(anyhow!( + "invalid config enabled_modules: no modules enabled" + )); + } + let allowed = [ + "executive", + "workforce", + "security", + "forensics", + "admin", + "ueba", + "pfsense", + "reports", + ]; + for module in modules { + if !allowed.contains(&module.as_str()) { + return Err(anyhow!( + "invalid config enabled_modules: unsupported module {module}" + )); + } + } + Ok(()) +} + +fn is_safe_environment_name(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && value.len() <= 32 + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')) +} + +fn enabled_modules(value: &str) -> BTreeSet { + value + .split(',') + .map(|item| item.trim().to_ascii_lowercase()) + .filter(|item| !item.is_empty()) + .collect() +} + fn run() -> Result { let args = Cli::parse(); + validate_portal_config(&args)?; if args.json_smoke { let snapshot = build_snapshot(&args); let incident_state = load_incident_state_best_effort(&args); @@ -1317,6 +1523,7 @@ fn run() -> Result { } fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) -> Result<()> { + mark_request_started(); let method = request.method().clone(); let url = request.url().to_string(); let path = normalize_path(&url); @@ -1354,6 +1561,11 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) if method != Method::Get { return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain"); } + if is_limited_api_route(&path) { + if let Err(err) = validate_api_query_limits(&url, args) { + return respond_json_status(request, err.status, &err.payload); + } + } if path == "/api/dlp/evidence" { if !role.can_access("forensics") && !role.can_access("incidents") { return respond_forbidden(request, role, "forensics"); @@ -1400,6 +1612,23 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) API_CONTRACT_TYPESCRIPT, "text/plain; charset=utf-8", ), + "/healthz" | "/api/healthz" => respond_json(request, &build_healthz()), + "/readyz" | "/api/readyz" => { + let readiness = build_readyz(args); + let status = if readiness.get("status").and_then(Value::as_str) == Some("ready") { + StatusCode(200) + } else { + StatusCode(503) + }; + respond_json_status(request, status, &readiness) + } + "/version" | "/api/version" => respond_json(request, &build_version(args)), + "/metrics" | "/api/metrics" => respond_text( + request, + StatusCode(200), + &render_prometheus_metrics(args), + "text/plain; version=0.0.4; charset=utf-8", + ), "/api/health" => respond_json(request, &build_fast_health(snapshot_cache)), "/api/readiness/latest" => respond_json(request, &readiness_latest(args)), "/api/readiness/bundle" => respond_json(request, &readiness_bundle(args)), @@ -1430,6 +1659,23 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) &build_workforce_policy_explain(&snapshot, &args.workforce_policy_path, anonymize), ) } + "/api/workforce/kpi/explain" => { + if !role.can_access("workforce") + && !role.can_access("security") + && !role.can_access("forensics") + && !role.can_access("admin") + { + return respond_forbidden(request, role, "workforce"); + } + let snapshot = cached_snapshot(args, snapshot_cache); + let policy_explain = + build_workforce_policy_explain(&snapshot, &args.workforce_policy_path, anonymize); + let query = KpiExplainQuery::from_url(&url); + respond_json( + request, + &build_workforce_kpi_explain(&snapshot, &policy_explain, role, &query, anonymize), + ) + } "/api/owner" => { if !role.can_access("security") { return respond_forbidden(request, role, "security"); @@ -1515,6 +1761,7 @@ fn portal_html_route(path: &str) -> Option<&'static str> { } fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { + mark_request_started(); let method = request.method().clone(); let path = normalize_path(request.url()); if method == Method::Post && path == "/api/dlp/evidence/upload" { @@ -1536,6 +1783,29 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { }), ); } + if path == "/healthz" || path == "/api/healthz" { + return respond_json(request, &build_healthz()); + } + if path == "/readyz" || path == "/api/readyz" { + let readiness = build_readyz(args); + let status = if readiness.get("status").and_then(Value::as_str) == Some("ready") { + StatusCode(200) + } else { + StatusCode(503) + }; + return respond_json_status(request, status, &readiness); + } + if path == "/version" || path == "/api/version" { + return respond_json(request, &build_version(args)); + } + if path == "/metrics" || path == "/api/metrics" { + return respond_text( + request, + StatusCode(200), + &render_prometheus_metrics(args), + "text/plain; version=0.0.4; charset=utf-8", + ); + } if path == "/api/readiness/latest" { return respond_json(request, &readiness_latest(args)); } @@ -1588,6 +1858,10 @@ fn api_contract_summary() -> Value { "typescript": "/api/contracts/typescript.d.ts" }, "stable_endpoints": [ + {"method": "GET", "path": "/healthz", "purpose": "process liveness without external dependency checks"}, + {"method": "GET", "path": "/readyz", "purpose": "local readiness and contract-only dependency status"}, + {"method": "GET", "path": "/version", "purpose": "safe build and schema version metadata"}, + {"method": "GET", "path": "/metrics", "purpose": "Prometheus metrics without high-cardinality labels"}, {"method": "GET", "path": "/api/health", "purpose": "light service health"}, {"method": "GET", "path": "/api/contracts", "purpose": "contract index"}, {"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"}, @@ -1607,11 +1881,260 @@ fn api_contract_summary() -> Value { {"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"}, {"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"}, {"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"} ] }) } +#[derive(Debug)] +struct ApiLimitError { + status: StatusCode, + payload: Value, +} + +fn build_healthz() -> Value { + json!({ + "status": "ok", + "generated_at_utc": now(), + }) +} + +fn build_readyz(args: &Cli) -> Value { + let config_ok = validate_portal_config(args).is_ok(); + let storage_status = storage_readiness_status(&args.state_dir); + let telemetry_status = storage_parent_readiness_status(&args.telemetry_store_path); + let evidence_status = storage_readiness_status(&args.evidence_root); + let ready = config_ok && !matches!(storage_status.as_str(), "error"); + json!({ + "status": if ready { "ready" } else { "not_ready" }, + "generated_at_utc": now(), + "checks": { + "config": if config_ok { "ok" } else { "error" }, + "storage": storage_status, + "telemetry_store": telemetry_status, + "evidence_storage": evidence_status, + "pfsense": "contract_only", + "security_events": security_events_readiness_status(args), + "clickhouse": if args.security_events_backend == "clickhouse" { "configured" } else { "not_required" } + } + }) +} + +fn storage_readiness_status(path: &Path) -> String { + if path.exists() { + if path.is_dir() { + "ok".to_string() + } else { + "error".to_string() + } + } else { + "not_configured".to_string() + } +} + +fn storage_parent_readiness_status(path: &Path) -> String { + path.parent() + .map(storage_readiness_status) + .unwrap_or_else(|| "not_configured".to_string()) +} + +fn security_events_readiness_status(args: &Cli) -> &'static str { + match args + .security_events_backend + .trim() + .to_ascii_lowercase() + .as_str() + { + "disabled" | "" => "disabled", + "clickhouse" => "configured", + _ => "configured", + } +} + +fn build_version(args: &Cli) -> Value { + json!({ + "app_version": env!("CARGO_PKG_VERSION"), + "git_commit": option_env!("GIT_COMMIT").unwrap_or("unknown"), + "build_time": option_env!("BUILD_TIME").unwrap_or("unknown"), + "schema_version": PORTAL_SCHEMA_VERSION, + "environment": args.environment, + }) +} + +fn is_limited_api_route(path: &str) -> bool { + matches!( + path, + "/api/reports" + | "/api/executive" + | "/api/workforce" + | "/api/security" + | "/api/forensics" + | "/api/ueba" + | "/api/pfsense" + | "/api/workforce/kpi/explain" + ) +} + +fn validate_api_query_limits(url: &str, args: &Cli) -> std::result::Result<(), ApiLimitError> { + for key in ["page_size", "limit"] { + if let Some(value) = query_param(url, key) { + let parsed = value.parse::().ok(); + if parsed.is_none_or(|page_size| page_size == 0 || page_size > args.max_page_size) { + return Err(api_limit_error( + StatusCode(400), + "invalid_page_size", + &format!("{key} must be between 1 and {}", args.max_page_size), + )); + } + } + } + + for (from_key, to_key) in [("date_from", "date_to"), ("from", "to"), ("start", "end")] { + let Some(from) = query_param(url, from_key).and_then(|value| parse_query_date(&value)) + else { + continue; + }; + let Some(to) = query_param(url, to_key).and_then(|value| parse_query_date(&value)) else { + continue; + }; + let days = (to - from).num_days().abs() + 1; + if days > args.max_report_date_range_days { + return Err(api_limit_error( + StatusCode(400), + "report_range_too_large", + &format!( + "report date range must be <= {} days", + args.max_report_date_range_days + ), + )); + } + } + Ok(()) +} + +fn parse_query_date(value: &str) -> Option { + let date = value.split('T').next().unwrap_or(value); + NaiveDate::parse_from_str(date, "%Y-%m-%d").ok() +} + +fn api_limit_error(status: StatusCode, code: &str, message: &str) -> ApiLimitError { + ApiLimitError { + status, + payload: json!({ + "ok": false, + "error_code": code, + "message": message, + }), + } +} + +fn render_prometheus_metrics(args: &Cli) -> String { + let mut text = String::new(); + let ready_value = if build_readyz(args) + .get("status") + .and_then(Value::as_str) + .unwrap_or("not_ready") + == "ready" + { + 1 + } else { + 0 + }; + writeln!( + &mut text, + "# HELP awatch_http_requests_total HTTP requests handled by AWatch-rus portal" + ) + .ok(); + writeln!(&mut text, "# TYPE awatch_http_requests_total counter").ok(); + let metrics = portal_metrics() + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default(); + for (key, value) in &metrics.http { + writeln!( + &mut text, + "awatch_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}", + prom_escape(&key.method), + prom_escape(&key.route), + key.status, + prom_escape(&key.module), + value.requests_total + ) + .ok(); + } + writeln!( + &mut text, + "# HELP awatch_http_request_duration_seconds HTTP request duration in seconds" + ) + .ok(); + writeln!( + &mut text, + "# TYPE awatch_http_request_duration_seconds summary" + ) + .ok(); + for (key, value) in &metrics.http { + writeln!( + &mut text, + "awatch_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {:.6}", + prom_escape(&key.method), + prom_escape(&key.route), + key.status, + prom_escape(&key.module), + value.duration_seconds_sum + ) + .ok(); + writeln!( + &mut text, + "awatch_http_request_duration_seconds_count{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}", + prom_escape(&key.method), + prom_escape(&key.route), + key.status, + prom_escape(&key.module), + value.duration_seconds_count + ) + .ok(); + } + for (name, help, value) in [ + ( + "awatch_reports_generated_total", + "Reports generated by the portal", + metrics.reports_generated_total, + ), + ( + "awatch_ingestion_records_total", + "Telemetry ingestion records accepted", + metrics.ingestion_records_total, + ), + ( + "awatch_ingestion_rejected_total", + "Telemetry ingestion records rejected", + metrics.ingestion_rejected_total, + ), + ( + "awatch_role_denied_total", + "Requests denied by role gates", + metrics.role_denied_total, + ), + ] { + writeln!(&mut text, "# HELP {name} {help}").ok(); + writeln!(&mut text, "# TYPE {name} counter").ok(); + writeln!(&mut text, "{name} {value}").ok(); + } + writeln!( + &mut text, + "# HELP awatch_readyz_status Portal readiness status, 1=ready, 0=not_ready" + ) + .ok(); + writeln!(&mut text, "# TYPE awatch_readyz_status gauge").ok(); + writeln!(&mut text, "awatch_readyz_status {ready_value}").ok(); + text +} + +fn prom_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + fn readiness_latest(args: &Cli) -> Value { read_json_file( &args @@ -3145,6 +3668,13 @@ fn build_reports( let insight_items = workforce_insight_items(snapshot); let workforce_policy_explain = build_workforce_policy_explain(snapshot, workforce_policy_path, anonymize); + let workforce_kpi_explain = build_workforce_kpi_explain( + snapshot, + &workforce_policy_explain, + PortalRole::Executive, + &KpiExplainQuery::default(), + anonymize, + ); let ueba_baseline = build_ueba_baseline_analysis(snapshot, ueba_baseline_path, anonymize); let ueba_risk = build_ueba_risk( snapshot, @@ -3260,6 +3790,7 @@ fn build_reports( workforce: &workforce_summary, executive_dashboard: &executive_dashboard, workforce_policy: &workforce_policy_explain, + workforce_kpi_explain: &workforce_kpi_explain, ueba_risk: &ueba_risk, business_risk: &business_risk, business_risk_history: &business_risk_history, @@ -3368,6 +3899,7 @@ fn build_reports( "risk_incident_candidates": risk_incident_candidates, "incident_review_audit_summary": incident_review_audit_summary, "workforce_policy": workforce_policy_explain, + "workforce_kpi_explain": workforce_kpi_explain, "workforce": { "department_comparison": department_items, "owner_comparison": owner_items, @@ -3382,6 +3914,7 @@ fn build_reports( } fn build_report_payload(args: &Cli, snapshot_cache: &SnapshotCache, anonymize: bool) -> Value { + record_report_generated(); 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); @@ -3446,6 +3979,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { "risk_heatmap", "security_events_summary", "workforce", + "workforce_kpi_explain", "markdown", ] { copy_json_key(object, &mut out, key); @@ -3464,6 +3998,7 @@ fn role_filtered_report(report: Value, role: PortalRole) -> Value { "risk_heatmap", "workforce", "workforce_policy", + "workforce_kpi_explain", "markdown", ] { copy_json_key(object, &mut out, key); @@ -6581,6 +7116,517 @@ fn build_workforce_policy_explain( ) } +#[derive(Clone, Debug, Default)] +struct KpiExplainQuery { + date: Option, + department: Option, + owner: Option, +} + +impl KpiExplainQuery { + fn from_url(url: &str) -> Self { + Self { + date: query_param(url, "date").filter(|value| parse_query_date(value).is_some()), + department: query_param(url, "department"), + owner: query_param(url, "owner"), + } + } +} + +fn build_workforce_kpi_explain( + snapshot: &Snapshot, + policy_explain: &Value, + role: PortalRole, + query: &KpiExplainQuery, + anonymize: bool, +) -> Value { + let (users_count, active_seconds, apps_count) = worktime_totals(snapshot); + let base_index = workforce_index(users_count, active_seconds); + let policy_index = policy_explain + .get("index") + .and_then(Value::as_u64) + .map(|value| value.min(100) as u8); + let kpi_score = policy_index.or(base_index).unwrap_or(0); + let agent_coverage_percent = kpi_agent_coverage(snapshot); + let data_freshness = kpi_data_freshness(snapshot); + let missing_sources = kpi_missing_sources(snapshot, users_count, apps_count); + let confidence = kpi_confidence( + users_count, + agent_coverage_percent, + &data_freshness, + &missing_sources, + ); + let factors = kpi_explain_factors( + snapshot, + policy_explain, + KpiFactorInputs { + users_count, + active_seconds, + apps_count, + kpi_score, + agent_coverage_percent, + missing_sources: &missing_sources, + }, + ); + let top_applications = kpi_top_applications(snapshot, anonymize); + let warnings = kpi_warnings(kpi_score, confidence, &data_freshness, &missing_sources); + let recommendations = kpi_recommendations(confidence, &missing_sources, kpi_score); + let mut payload = json!({ + "ok": true, + "scope": "aggregate", + "role_context": role_envelope(role, "workforce_kpi_explain"), + "query": { + "date": query.date.clone(), + "department": query.department.clone(), + "owner": query.owner.clone(), + "employee_id_supported": false + }, + "kpi_score": kpi_score, + "kpi_status": workforce_index_status(Some(kpi_score)), + "confidence": confidence, + "coverage": { + "agent_coverage_percent": agent_coverage_percent, + "data_freshness": data_freshness, + "missing_sources": missing_sources, + }, + "factors": factors, + "top_applications": top_applications, + "warnings": warnings, + "recommendations": recommendations, + "formula": "rule_based: activity + business app usage - idle/afterhours/missing data with coverage confidence", + "model": { + "type": "rule_based", + "ml": false, + "llm": false, + "version": "workforce-kpi-explain-v1" + }, + "generated_at_utc": snapshot.generated_at_utc, + }); + filter_kpi_explain_for_role(&mut payload, role); + payload +} + +fn kpi_agent_coverage(snapshot: &Snapshot) -> u8 { + if snapshot.agent_coverage_sla.expected_nodes > 0 { + snapshot.agent_coverage_sla.coverage_pct + } else if snapshot.agent_quality.sessions_collected_total > 0 { + 75 + } else if snapshot.worktime.ok { + 60 + } else { + 0 + } +} + +fn kpi_data_freshness(snapshot: &Snapshot) -> String { + if snapshot.worktime.status.eq_ignore_ascii_case("OK") + && snapshot + .worktime_management + .status + .eq_ignore_ascii_case("OK") + { + "fresh".to_string() + } else if snapshot.worktime.status.eq_ignore_ascii_case("DEGRADED") + || snapshot + .worktime_management + .status + .eq_ignore_ascii_case("DEGRADED") + || snapshot + .worktime + .summary + .to_ascii_lowercase() + .contains("stale") + || snapshot + .worktime_management + .summary + .to_ascii_lowercase() + .contains("stale") + { + "stale".to_string() + } else { + "missing".to_string() + } +} + +fn kpi_missing_sources(snapshot: &Snapshot, users_count: usize, apps_count: usize) -> Vec { + let mut missing = Vec::new(); + if !snapshot.worktime.ok || users_count == 0 { + missing.push("worktime".to_string()); + } + if !snapshot.worktime_management.ok { + missing.push("worktime_management".to_string()); + } + if apps_count == 0 { + missing.push("applications".to_string()); + } + if snapshot.agent_coverage_sla.expected_nodes > 0 + && snapshot.agent_coverage_sla.coverage_pct < 50 + { + missing.push("agent_coverage".to_string()); + } + missing.sort(); + missing.dedup(); + missing +} + +fn kpi_confidence( + users_count: usize, + agent_coverage_percent: u8, + data_freshness: &str, + missing_sources: &[String], +) -> &'static str { + if users_count == 0 + || agent_coverage_percent < 50 + || data_freshness == "missing" + || missing_sources.iter().any(|item| item == "worktime") + { + "low" + } else if agent_coverage_percent < 80 + || data_freshness != "fresh" + || !missing_sources.is_empty() + { + "medium" + } else { + "high" + } +} + +fn kpi_explain_factors( + snapshot: &Snapshot, + policy_explain: &Value, + inputs: KpiFactorInputs<'_>, +) -> Vec { + let planned_seconds = (inputs.users_count as i64).saturating_mul(8 * 3600); + let idle_ratio = if planned_seconds > 0 { + ((planned_seconds - inputs.active_seconds).max(0) as f64 / planned_seconds as f64) + .clamp(0.0, 1.0) + } else { + 1.0 + }; + let weighted_apps = policy_explain + .get("matched_applications") + .and_then(Value::as_u64) + .unwrap_or(0); + let afterhours_seconds = kpi_afterhours_seconds(snapshot); + let remote_sessions = snapshot.agent_quality.rdp_sessions_total as u64; + let trend = trend_status(&workforce_trend_json(snapshot)); + + vec![ + kpi_factor( + "productive_activity", + "Полезная активность", + positive_impact((inputs.kpi_score as i64 * 40) / 100), + if inputs.kpi_score >= 80 { + "Высокая доля активности относительно планового рабочего времени" + } else if inputs.kpi_score >= 60 { + "Активность близка к рабочему уровню, но есть просадка" + } else { + "Активность ниже ожидаемого рабочего уровня" + }, + ), + kpi_factor( + "business_app_usage", + "Рабочие приложения", + positive_impact( + ((weighted_apps.max(inputs.apps_count as u64).min(12) as i64) * 2).min(24), + ), + if weighted_apps > 0 { + "В данных есть приложения, попавшие под рабочие правила" + } else if inputs.apps_count > 0 { + "Есть активность по приложениям, но правила рабочих приложений требуют настройки" + } else { + "Данных о рабочих приложениях нет" + }, + ), + kpi_factor( + "idle_time", + "Простой", + negative_impact((idle_ratio * 30.0).round() as i64), + if idle_ratio > 0.35 { + "Есть значимые периоды неактивности в рабочее время" + } else { + "Простой не является основным фактором снижения индекса" + }, + ), + kpi_factor( + "afterhours_activity", + "Активность вне рабочего времени", + negative_impact((afterhours_seconds / 3600).min(12)), + if afterhours_seconds > 0 { + "Есть признаки активности за пределами рабочего окна" + } else { + "Существенная активность вне рабочего времени не выявлена" + }, + ), + kpi_factor( + "remote_session_activity", + "Удаленные сессии", + positive_impact((remote_sessions.min(5) * 2) as i64), + if remote_sessions > 0 { + "RDP/удаленные сессии подтверждают источник активности" + } else { + "Удаленные сессии не подтверждены текущим срезом" + }, + ), + kpi_factor( + "data_coverage", + "Полнота данных", + if inputs.agent_coverage_percent >= 80 { + positive_impact(12) + } else { + negative_impact( + ((80_u8.saturating_sub(inputs.agent_coverage_percent) as i64) / 4).max(1), + ) + }, + if inputs.agent_coverage_percent >= 80 { + "Покрытие данных достаточно для уверенного управленческого вывода" + } else { + "Покрытие данных снижает доверие к индексу" + }, + ), + kpi_factor( + "missing_data", + "Отсутствующие данные", + negative_impact((inputs.missing_sources.len() as i64 * 8).min(32)), + if inputs.missing_sources.is_empty() { + "Критичных пропусков источников не выявлено" + } else { + "Есть пропуски источников, влияющие на надежность KPI" + }, + ), + kpi_factor( + "trend_change", + "Изменение тренда", + match trend.as_str() { + "monthly" | "weekly" => positive_impact(6), + "daily_only" => negative_impact(2), + _ => "0".to_string(), + }, + match trend.as_str() { + "monthly" => "Есть месячная история для оценки тренда", + "weekly" => "Есть недельная история для оценки тренда", + "daily_only" => "Доступен только дневной срез, исторический тренд ограничен", + _ => "История тренда пока не накоплена", + }, + ), + ] +} + +fn kpi_factor(name: &str, label: &str, impact: String, explanation: &str) -> Value { + json!({ + "name": name, + "label": label, + "impact": impact, + "explanation": explanation, + }) +} + +fn positive_impact(value: i64) -> String { + format!("+{}", value.max(0)) +} + +fn negative_impact(value: i64) -> String { + if value <= 0 { + "0".to_string() + } else { + format!("-{value}") + } +} + +fn kpi_afterhours_seconds(snapshot: &Snapshot) -> i64 { + snapshot + .worktime_management + .payload + .as_ref() + .and_then(|payload| payload.get("department_rollups")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let total = item + .get("calendar_total_active_seconds") + .or_else(|| item.get("total_active_seconds")) + .and_then(Value::as_i64)?; + let workday = item + .get("workday_total_active_seconds") + .or_else(|| item.get("active_seconds")) + .and_then(Value::as_i64) + .unwrap_or(total); + Some((total - workday).max(0)) + }) + .sum() + }) + .unwrap_or(0) +} + +fn kpi_top_applications(snapshot: &Snapshot, anonymize: bool) -> Vec { + let Some(apps) = snapshot + .worktime + .payload + .as_ref() + .and_then(|payload| payload.get("true_active_apps")) + .and_then(Value::as_array) + else { + return Vec::new(); + }; + let mut items = apps + .iter() + .enumerate() + .filter_map(|(idx, app)| { + let raw_name = app.get("application").and_then(Value::as_str)?; + let seconds = app + .get("proved_work_seconds") + .or_else(|| app.get("active_seconds")) + .and_then(Value::as_i64) + .unwrap_or(0) + .max(0); + if seconds == 0 { + return None; + } + let name = if anonymize { + format!("Приложение {}", idx + 1) + } else { + display_text_opt(Some(raw_name), &format!("Приложение {}", idx + 1)) + }; + let category = kpi_application_category(raw_name); + let contribution = if category == "business" { + "positive" + } else { + "neutral" + }; + Some(json!({ + "name": name, + "category": category, + "active_minutes": seconds / 60, + "contribution": contribution, + })) + }) + .collect::>(); + items.sort_by_key(|item| { + -item + .get("active_minutes") + .and_then(Value::as_i64) + .unwrap_or(0) + }); + items.truncate(8); + items +} + +fn kpi_application_category(name: &str) -> &'static str { + let lower = name.to_ascii_lowercase(); + let lower_ru = name.to_lowercase(); + if lower.contains("1c") + || lower_ru.contains("1с") + || lower.contains("erp") + || lower.contains("sap") + || lower.contains("excel") + || lower.contains("office") + || lower.contains("word") + { + "business" + } else if lower.contains("browser") + || lower.contains("chrome") + || lower.contains("edge") + || lower.contains("firefox") + { + "mixed" + } else { + "other" + } +} + +fn kpi_warnings( + kpi_score: u8, + confidence: &str, + data_freshness: &str, + missing_sources: &[String], +) -> Vec { + let mut warnings = Vec::new(); + if confidence == "low" { + warnings + .push("Низкое доверие к KPI: данных недостаточно для уверенного вывода.".to_string()); + } + if data_freshness != "fresh" { + warnings.push(format!("Свежесть данных: {data_freshness}.")); + } + for source in missing_sources { + warnings.push(format!("Не хватает источника данных: {source}.")); + } + if kpi_score < 60 { + warnings.push("Индекс активности ниже рабочего ориентира.".to_string()); + } + warnings +} + +fn kpi_recommendations(confidence: &str, missing_sources: &[String], kpi_score: u8) -> Vec { + let mut recommendations = Vec::new(); + if !missing_sources.is_empty() { + recommendations + .push("Проверить свежесть источников и восстановить пропущенные данные.".to_string()); + } + if confidence != "high" { + recommendations.push( + "Перед управленческим выводом проверить покрытие данных рабочих мест.".to_string(), + ); + } + if kpi_score < 60 { + recommendations + .push("Проверить подразделения или ответственных с низкой активностью.".to_string()); + } + if recommendations.is_empty() { + recommendations.push("Использовать KPI как агрегированный управленческий индикатор, не как персональную HR-оценку.".to_string()); + } + recommendations +} + +fn filter_kpi_explain_for_role(payload: &mut Value, role: PortalRole) { + match role { + PortalRole::Executive | PortalRole::Manager => { + payload["scope_note"] = json!("Агрегированный Workforce KPI без персональных деталей."); + } + PortalRole::Security => { + payload["scope_note"] = + json!("ИБ видит только факторы, релевантные риску и надежности данных."); + filter_kpi_factors( + payload, + &[ + "afterhours_activity", + "remote_session_activity", + "data_coverage", + "missing_data", + "trend_change", + ], + ); + payload["top_applications"] = Value::Array(Vec::new()); + } + PortalRole::Forensics => { + payload["scope_note"] = json!( + "Расследования видят только контекст надежности данных и временных отклонений." + ); + filter_kpi_factors( + payload, + &["afterhours_activity", "data_coverage", "missing_data"], + ); + payload["top_applications"] = Value::Array(Vec::new()); + } + PortalRole::Admin => { + payload["scope_note"] = + json!("Администратор видит техническое покрытие источников и rule-based факторы."); + } + } +} + +fn filter_kpi_factors(payload: &mut Value, allowed: &[&str]) { + if let Some(factors) = payload.get_mut("factors").and_then(Value::as_array_mut) { + factors.retain(|item| { + item.get("name") + .and_then(Value::as_str) + .is_some_and(|name| allowed.contains(&name)) + }); + } +} + fn weighted_activity( snapshot: &Snapshot, policy: &WorkforcePolicy, @@ -7054,6 +8100,7 @@ fn render_report_markdown( "- Открытые вопросы: {}, в работе: {}\n\n", metrics.open_incidents, metrics.acknowledged_incidents )); + append_workforce_kpi_explain_markdown(&mut text, context.workforce_kpi_explain); text.push_str("## Рекомендации\n\n"); for item in recommendations { text.push_str(&format!("- {item}\n")); @@ -7173,6 +8220,107 @@ fn append_executive_dashboard_markdown(text: &mut String, dashboard: &ExecutiveD )); } +fn append_workforce_kpi_explain_markdown(text: &mut String, explain: &Value) { + text.push_str("## Объяснение индекса активности\n\n"); + text.push_str(&format!( + "- KPI score: {}%\n", + explain + .get("kpi_score") + .and_then(Value::as_u64) + .unwrap_or(0) + )); + text.push_str(&format!( + "- Confidence: {}\n", + explain + .get("confidence") + .and_then(Value::as_str) + .unwrap_or("low") + )); + let coverage = explain.get("coverage").unwrap_or(&Value::Null); + text.push_str(&format!( + "- Coverage: agent={}%, freshness={}\n", + coverage + .get("agent_coverage_percent") + .and_then(Value::as_u64) + .unwrap_or(0), + coverage + .get("data_freshness") + .and_then(Value::as_str) + .unwrap_or("missing") + )); + let missing = coverage + .get("missing_sources") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + text.push_str(&format!( + "- Отсутствующие источники: {}\n", + if missing.is_empty() { + "нет".to_string() + } else { + missing + } + )); + text.push_str("\n### Основные факторы\n\n"); + for item in explain + .get("factors") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(12) + { + text.push_str(&format!( + "- {}: {} — {}\n", + item.get("label").and_then(Value::as_str).unwrap_or("-"), + item.get("impact").and_then(Value::as_str).unwrap_or("0"), + item.get("explanation") + .and_then(Value::as_str) + .unwrap_or("-") + )); + } + append_string_list_markdown( + text, + "### Предупреждения по KPI", + explain.get("warnings").and_then(Value::as_array), + "предупреждений нет", + ); + append_string_list_markdown( + text, + "### Рекомендации по KPI", + explain.get("recommendations").and_then(Value::as_array), + "рекомендаций нет", + ); + text.push('\n'); +} + +fn append_string_list_markdown( + text: &mut String, + title: &str, + items: Option<&Vec>, + empty: &str, +) { + text.push('\n'); + text.push_str(title); + text.push_str("\n\n"); + let Some(items) = items else { + text.push_str(&format!("- {empty}\n")); + return; + }; + if items.is_empty() { + text.push_str(&format!("- {empty}\n")); + return; + } + for item in items { + text.push_str(&format!("- {}\n", item.as_str().unwrap_or("-"))); + } +} + fn append_agent_quality_markdown(text: &mut String, quality: &AgentQuality) { let explain = agent_quality_explain(quality); text.push_str("\n## Качество данных\n\n"); @@ -8071,11 +9219,11 @@ fn incident_id(kind: &str, source: &str, summary: &str) -> String { fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> { let actor = request_actor(&request); - let mut body = String::new(); - request - .as_reader() - .take(32 * 1024) - .read_to_string(&mut body)?; + let body = match read_limited_body(&mut request, 32 * 1024) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; let response = apply_incident_action(args, &actor, &body); match response { Ok(response) => respond_json(request, &response), @@ -8091,13 +9239,42 @@ fn handle_incident_action(mut request: Request, args: &Cli) -> Result<()> { } } -fn handle_incident_review(mut request: Request, args: &Cli) -> Result<()> { - let actor = request_actor(&request); +fn read_limited_body(request: &mut Request, limit: u64) -> Result { let mut body = String::new(); request .as_reader() - .take(32 * 1024) + .take(limit.saturating_add(1)) .read_to_string(&mut body)?; + if body.len() as u64 > limit { + return Err(anyhow!("request payload exceeds configured limit")); + } + Ok(body) +} + +fn is_payload_too_large(err: &anyhow::Error) -> bool { + err.to_string() + .contains("request payload exceeds configured limit") +} + +fn respond_payload_too_large(request: Request) -> Result<()> { + respond_json_status( + request, + StatusCode(413), + &json!({ + "ok": false, + "error_code": "payload_too_large", + "message": "request body exceeds configured payload limit" + }), + ) +} + +fn handle_incident_review(mut request: Request, args: &Cli) -> Result<()> { + let actor = request_actor(&request); + let body = match read_limited_body(&mut request, 32 * 1024) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; let response = apply_incident_review(args, &actor, &body); match response { Ok(response) => respond_json(request, &response), @@ -8165,11 +9342,11 @@ fn handle_create_case( args: &Cli, snapshot_cache: &SnapshotCache, ) -> Result<()> { - let mut body = String::new(); - request - .as_reader() - .take(32 * 1024) - .read_to_string(&mut body)?; + let body = match read_limited_body(&mut request, 32 * 1024) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; match apply_create_case(args, &cached_snapshot(args, snapshot_cache), &body) { Ok(response) => respond_json(request, &response), Err(err) => respond_json_status( @@ -8184,11 +9361,11 @@ fn handle_create_case( } fn handle_case_status(mut request: Request, args: &Cli, case_id: &str) -> Result<()> { - let mut body = String::new(); - request - .as_reader() - .take(32 * 1024) - .read_to_string(&mut body)?; + let body = match read_limited_body(&mut request, 32 * 1024) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; match apply_case_status(args, case_id, &body) { Ok(response) => respond_json(request, &response), Err(err) => respond_json_status( @@ -8240,6 +9417,7 @@ fn handle_case_details( fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> { if !telemetry_authorized(&request, args) { + record_ingestion_rejected(); return respond_json_status( request, StatusCode(401), @@ -8249,22 +9427,32 @@ fn handle_telemetry_ingest(mut request: Request, args: &Cli) -> Result<()> { }), ); } - let mut body = String::new(); - request - .as_reader() - .take(1024 * 1024) - .read_to_string(&mut body)?; + let telemetry_limit = args.max_request_body_bytes.min(1024 * 1024); + let body = match read_limited_body(&mut request, telemetry_limit) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => { + record_ingestion_rejected(); + return respond_payload_too_large(request); + } + Err(err) => return Err(err), + }; let response = apply_telemetry_ingest(args, &body); match response { - Ok(response) => respond_json(request, &response), - Err(err) => respond_json_status( - request, - StatusCode(400), - &json!({ - "ok": false, - "error": err.to_string() - }), - ), + Ok(response) => { + record_ingestion_accepted(); + respond_json(request, &response) + } + Err(err) => { + record_ingestion_rejected(); + respond_json_status( + request, + StatusCode(400), + &json!({ + "ok": false, + "error": err.to_string() + }), + ) + } } } @@ -8968,12 +10156,13 @@ fn handle_evidence_upload(mut request: Request, args: &Cli) -> Result<()> { .evidence_max_bytes .saturating_mul(2) .saturating_add(64 * 1024) - .min(32 * 1024 * 1024); - let mut body = String::new(); - request - .as_reader() - .take(body_limit) - .read_to_string(&mut body)?; + .min(32 * 1024 * 1024) + .min(args.max_request_body_bytes); + let body = match read_limited_body(&mut request, body_limit) { + Ok(body) => body, + Err(err) if is_payload_too_large(&err) => return respond_payload_too_large(request), + Err(err) => return Err(err), + }; match apply_evidence_upload(args, &request_actor(&request), &body) { Ok(response) => respond_json(request, &response), Err(err) => respond_text( @@ -9940,16 +11129,33 @@ fn respond_json_status( respond_text(request, status, &body, "application/json; charset=utf-8") } +#[derive(Clone, Debug)] +struct HttpRequestMetadata { + method: String, + path: String, + route: String, + module: String, + role: String, + request_id: String, + correlation_id: String, + latency_ms: u64, +} + fn respond_text( request: Request, status: StatusCode, body: &str, content_type: &str, ) -> Result<()> { + let metadata = http_request_metadata(&request); + record_http_metric(&metadata, status); + log_http_request(&metadata, status, body.len()); let response = Response::from_string(body.to_string()) .with_status_code(status) .with_header(header("Content-Type", content_type)?) - .with_header(header("Cache-Control", "no-store")?); + .with_header(header("Cache-Control", "no-store")?) + .with_header(header("X-Request-Id", &metadata.request_id)?) + .with_header(header("X-Correlation-Id", &metadata.correlation_id)?); request.respond(response).map_err(|err| anyhow!("{err}")) } @@ -9960,10 +11166,15 @@ fn respond_text_download( content_type: &str, download_name: &str, ) -> Result<()> { + let metadata = http_request_metadata(&request); + record_http_metric(&metadata, status); + log_http_request(&metadata, status, body.len()); let response = Response::from_string(body.to_string()) .with_status_code(status) .with_header(header("Content-Type", content_type)?) .with_header(header("Cache-Control", "no-store")?) + .with_header(header("X-Request-Id", &metadata.request_id)?) + .with_header(header("X-Correlation-Id", &metadata.correlation_id)?) .with_header(header( "Content-Disposition", &format!( @@ -9981,10 +11192,15 @@ fn respond_file( download_name: Option<&str>, ) -> Result<()> { let data = fs::read(path).with_context(|| format!("read {}", path.display()))?; + let metadata = http_request_metadata(&request); + record_http_metric(&metadata, StatusCode(200)); + log_http_request(&metadata, StatusCode(200), data.len()); let mut response = Response::from_data(data) .with_status_code(StatusCode(200)) .with_header(header("Content-Type", content_type)?) - .with_header(header("Cache-Control", "no-store")?); + .with_header(header("Cache-Control", "no-store")?) + .with_header(header("X-Request-Id", &metadata.request_id)?) + .with_header(header("X-Correlation-Id", &metadata.correlation_id)?); if let Some(name) = download_name.and_then(screenshot_basename) { response = response.with_header(header( "Content-Disposition", @@ -9994,6 +11210,210 @@ fn respond_file( request.respond(response).map_err(|err| anyhow!("{err}")) } +fn mark_request_started() { + REQUEST_STARTED_AT.with(|started| { + *started.borrow_mut() = Some(Instant::now()); + }); +} + +fn request_latency_ms() -> u64 { + REQUEST_STARTED_AT.with(|started| { + started + .borrow() + .as_ref() + .map(|instant| instant.elapsed().as_millis() as u64) + .unwrap_or(0) + }) +} + +fn http_request_metadata(request: &Request) -> HttpRequestMetadata { + let raw_url = request.url().to_string(); + let path = normalize_path(&raw_url); + let method = request.method().as_str().to_string(); + let route = metrics_route(&path); + let module = metrics_module(&route).to_string(); + let role = portal_role_from_request(request, &raw_url) + .as_str() + .to_string(); + let request_id = request_header(request, "X-Request-Id") + .or_else(|| request_header(request, "X-Request-ID")) + .map(sanitize_request_token) + .filter(|value| !value.is_empty()) + .unwrap_or_else(generate_request_id); + let correlation_id = request_header(request, "X-Correlation-Id") + .or_else(|| request_header(request, "X-Correlation-ID")) + .map(sanitize_request_token) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| request_id.clone()); + HttpRequestMetadata { + method, + path, + route, + module, + role, + request_id, + correlation_id, + latency_ms: request_latency_ms(), + } +} + +fn request_header(request: &Request, name: &str) -> Option { + request + .headers() + .iter() + .find(|header| header.field.to_string().eq_ignore_ascii_case(name)) + .map(|header| header.value.as_str().to_string()) +} + +fn sanitize_request_token(value: String) -> String { + value + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':')) + .take(96) + .collect() +} + +fn generate_request_id() -> String { + let seq = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0); + format!("awatch-{millis}-{seq}") +} + +fn portal_metrics() -> &'static Mutex { + PORTAL_METRICS.get_or_init(|| Mutex::new(PortalMetrics::default())) +} + +fn record_http_metric(metadata: &HttpRequestMetadata, status: StatusCode) { + if let Ok(mut metrics) = portal_metrics().lock() { + let entry = metrics + .http + .entry(HttpMetricKey { + method: metadata.method.clone(), + route: metadata.route.clone(), + status: status.0, + module: metadata.module.clone(), + }) + .or_default(); + entry.requests_total = entry.requests_total.saturating_add(1); + entry.duration_seconds_sum += metadata.latency_ms as f64 / 1_000.0; + entry.duration_seconds_count = entry.duration_seconds_count.saturating_add(1); + if status.0 == 403 { + metrics.role_denied_total = metrics.role_denied_total.saturating_add(1); + } + } +} + +fn record_report_generated() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.reports_generated_total = metrics.reports_generated_total.saturating_add(1); + } +} + +fn record_ingestion_accepted() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1); + } +} + +fn record_ingestion_rejected() { + if let Ok(mut metrics) = portal_metrics().lock() { + metrics.ingestion_rejected_total = metrics.ingestion_rejected_total.saturating_add(1); + } +} + +fn log_http_request(metadata: &HttpRequestMetadata, status: StatusCode, response_bytes: usize) { + let level = if status.0 >= 500 { + "ERROR" + } else if status.0 >= 400 { + "WARN" + } else { + "INFO" + }; + let error_code = if status.0 >= 400 { + Value::String(format!("http_{}", status.0)) + } else { + Value::Null + }; + eprintln!( + "{}", + json!({ + "timestamp": now(), + "level": level, + "request_id": &metadata.request_id, + "correlation_id": &metadata.correlation_id, + "method": &metadata.method, + "path": &metadata.path, + "route": &metadata.route, + "status": status.0, + "latency_ms": metadata.latency_ms, + "user_role": &metadata.role, + "module": &metadata.module, + "error_code": error_code, + "response_bytes": response_bytes, + }) + ); +} + +fn metrics_route(path: &str) -> String { + if parse_investigation_pack_path(path).is_some() { + return "/api/investigation-pack/{candidate_id}".to_string(); + } + if parse_case_path(path).is_some() { + return "/api/cases/{case_id}".to_string(); + } + if parse_case_status_path(path).is_some() { + return "/api/cases/{case_id}/status".to_string(); + } + if parse_evidence_screenshot_path(path).is_some() { + return "/api/dlp/evidence/{evidence_id}/asset".to_string(); + } + match path { + "/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" | "/architecture" => { + path.to_string() + } + "/healthz" | "/api/healthz" => "/healthz".to_string(), + "/readyz" | "/api/readyz" => "/readyz".to_string(), + "/version" | "/api/version" => "/version".to_string(), + "/metrics" | "/api/metrics" => "/metrics".to_string(), + _ if path.starts_with("/api/") => path.to_string(), + _ => "other".to_string(), + } +} + +fn metrics_module(route: &str) -> &'static str { + if route.starts_with("/healthz") + || route.starts_with("/readyz") + || route.starts_with("/version") + || route.starts_with("/metrics") + { + "runtime" + } else if route.contains("/workforce") || route == "/manager" { + "workforce" + } else if route.contains("/security") + || route.contains("/incidents") + || route.contains("/incident-review") + { + "security" + } else if route.contains("/forensics") + || route.contains("/investigation-pack") + || route.contains("/cases") + || route.contains("/dlp/evidence") + { + "forensics" + } else if route.contains("/ueba") { + "ueba" + } else if route.contains("/pfsense") { + "pfsense" + } else if route.contains("/reports") || route.contains("/executive") || route == "/operator" { + "reports" + } else { + "portal" + } +} + fn safe_download_stem(value: &str) -> String { let stem = value .chars() @@ -10025,6 +11445,158 @@ fn now() -> String { #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + fn test_cli(dir: &Path) -> Cli { + Cli { + bind: "127.0.0.1:8720".to_string(), + status_cmd: "true".to_string(), + check_cmd: "true".to_string(), + failed_units_cmd: "true".to_string(), + worktime_url: "http://127.0.0.1".to_string(), + one_c_url: "http://127.0.0.1".to_string(), + workforce_policy_path: dir.join("workforce-policy.json"), + ueba_policy_path: dir.join("ueba-policy.yaml"), + timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), + state_dir: dir.join("state"), + dlp_db_path: dir.join("dlp.sqlite"), + evidence_root: dir.to_path_buf(), + readiness_bundle_dir: dir.join("readiness-bundle"), + evidence_limit: 10, + evidence_max_bytes: 1024, + json_smoke: false, + evidence_only: false, + evidence_upload_token: None, + telemetry_api_key: "test-key".to_string(), + telemetry_store_path: dir.join("telemetry.jsonl"), + expected_nodes_path: dir.join("expected_nodes.json"), + security_events_backend: "disabled".to_string(), + clickhouse_url: "http://127.0.0.1:8123".to_string(), + clickhouse_database: "analytics_1c".to_string(), + clickhouse_user: "default".to_string(), + clickhouse_password: String::new(), + } + } + + fn kpi_snapshot( + worktime_ok: bool, + coverage_pct: u8, + active_seconds: i64, + apps: Vec, + ) -> Snapshot { + let rows = if active_seconds > 0 { + json!([ + {"user": "USER-1", "user_id": "EMP-1", "active_seconds": active_seconds} + ]) + } else { + json!([]) + }; + Snapshot { + generated_at_utc: "2026-06-07T10:00:00Z".to_string(), + detmir_status: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + detmir_check: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + failed_units: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + worktime: SourceStatus { + ok: worktime_ok, + status: if worktime_ok { "OK" } else { "FAIL" }.to_string(), + summary: String::new(), + error: None, + payload: worktime_ok.then(|| { + json!({ + "rows": rows, + "true_active_apps": apps + }) + }), + }, + worktime_management: SourceStatus { + ok: worktime_ok, + status: if worktime_ok { "OK" } else { "FAIL" }.to_string(), + summary: String::new(), + error: None, + payload: worktime_ok.then(|| { + json!({ + "department_rollups": [ + { + "name": "DEPT-1", + "users_count": 1, + "active_users": 1, + "portfolio_coverage_pct": coverage_pct, + "workday_total_active_seconds": active_seconds, + "calendar_total_active_seconds": active_seconds + } + ], + "trend": [ + {"report_date": "2026-06-06", "portfolio_coverage_pct": coverage_pct}, + {"report_date": "2026-06-07", "portfolio_coverage_pct": coverage_pct} + ] + }) + }), + }, + one_c: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + one_c_overview: SourceStatus { + ok: true, + status: "OK".to_string(), + summary: String::new(), + error: None, + payload: None, + }, + agent_quality: AgentQuality { + collector_source: "awatch-agent-rs".to_string(), + collector_error: None, + sessions_collected_total: if worktime_ok { 1 } else { 0 }, + active_sessions_total: if worktime_ok { 1 } else { 0 }, + rdp_sessions_total: if worktime_ok { 1 } else { 0 }, + quality_status: if worktime_ok { "OK" } else { "UNKNOWN" }.to_string(), + }, + agent_quality_history: Vec::new(), + agent_quality_history_summary: AgentQualityHistorySummary::default(), + agent_quality_nodes: Vec::new(), + agent_quality_nodes_summary: AgentQualityNodesSummary::default(), + agent_coverage_sla: AgentCoverageSla { + expected_nodes: 1, + reporting_nodes_24h: if worktime_ok { 1 } else { 0 }, + stale_nodes: 0, + missing_nodes: if worktime_ok { 0 } else { 1 }, + coverage_pct, + freshness_pct: coverage_pct, + sla_status: if coverage_pct >= 80 { "OK" } else { "CRITICAL" }.to_string(), + problem_nodes: Vec::new(), + }, + security_events_summary: SecurityEventsSummary::disabled(), + } + } #[test] fn normalizes_gateway_prefix() { @@ -10046,6 +11618,171 @@ mod tests { assert_eq!(safe_download_stem("risk:candidate/1"), "risk_candidate_1"); } + #[test] + fn production_config_validation_rejects_bad_values() { + let dir = tempfile::tempdir().unwrap(); + let args = test_cli(dir.path()); + assert!(validate_portal_config(&args).is_ok()); + + let mut invalid = args.clone(); + invalid.bind = "127.0.0.1:bad".to_string(); + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("port") + ); + + let mut invalid = args.clone(); + invalid.max_page_size = 0; + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("max_page_size") + ); + + let mut invalid = args.clone(); + invalid.max_report_date_range_days = 0; + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("max_report_date_range_days") + ); + + let mut invalid = args.clone(); + invalid.request_timeout_seconds = 0; + assert!( + validate_portal_config(&invalid) + .unwrap_err() + .to_string() + .contains("request_timeout_seconds") + ); + } + + #[test] + fn heavy_api_query_limits_reject_page_size_and_report_range() { + let dir = tempfile::tempdir().unwrap(); + let args = test_cli(dir.path()); + assert!(validate_api_query_limits("/api/reports?page_size=100", &args).is_ok()); + let page_error = + validate_api_query_limits("/api/reports?page_size=999999", &args).unwrap_err(); + assert_eq!(page_error.status.0, 400); + assert_eq!(page_error.payload["error_code"], "invalid_page_size"); + + let range_error = validate_api_query_limits( + "/api/reports?date_from=2026-01-01&date_to=2026-12-31", + &args, + ) + .unwrap_err(); + assert_eq!(range_error.status.0, 400); + assert_eq!(range_error.payload["error_code"], "report_range_too_large"); + } + + #[test] + fn workforce_kpi_explain_is_deterministic_and_role_filtered() { + let snapshot = kpi_snapshot( + true, + 95, + 8 * 3600, + vec![json!({"application": "1C", "proved_work_seconds": 6 * 3600})], + ); + let policy = json!({ + "configured": true, + "index": 82, + "matched_applications": 1 + }); + let explain = build_workforce_kpi_explain( + &snapshot, + &policy, + PortalRole::Executive, + &KpiExplainQuery::default(), + false, + ); + assert_eq!(explain["kpi_score"], 82); + assert_eq!(explain["confidence"], "high"); + let factors = explain["factors"].as_array().unwrap(); + assert_eq!(factors.len(), 8); + assert_eq!(factors[0]["name"], "productive_activity"); + assert!( + factors + .iter() + .any(|item| item["name"] == "business_app_usage") + ); + assert!( + explain["top_applications"][0]["name"] + .as_str() + .unwrap() + .contains("1C") + ); + assert!( + serde_json::to_string(&explain) + .unwrap() + .find("EMP-1") + .is_none() + ); + + let security = build_workforce_kpi_explain( + &snapshot, + &policy, + PortalRole::Security, + &KpiExplainQuery::default(), + false, + ); + assert_eq!(security["top_applications"].as_array().unwrap().len(), 0); + assert!( + security["factors"] + .as_array() + .unwrap() + .iter() + .all(|item| item["name"] != "productive_activity") + ); + } + + #[test] + fn workforce_kpi_explain_low_confidence_when_data_missing() { + let snapshot = kpi_snapshot(false, 0, 0, Vec::new()); + let explain = build_workforce_kpi_explain( + &snapshot, + &json!({"configured": false}), + PortalRole::Executive, + &KpiExplainQuery::default(), + false, + ); + assert_eq!(explain["confidence"], "low"); + assert_eq!(explain["kpi_score"], 0); + assert!( + explain["warnings"] + .as_array() + .unwrap() + .iter() + .any(|item| item.as_str().unwrap().contains("Низкое доверие")) + ); + } + + #[test] + fn workforce_kpi_markdown_section_is_rendered() { + let snapshot = kpi_snapshot( + true, + 90, + 7 * 3600, + vec![json!({"application": "ERP", "proved_work_seconds": 7 * 3600})], + ); + let explain = build_workforce_kpi_explain( + &snapshot, + &json!({"configured": true, "index": 88, "matched_applications": 1}), + PortalRole::Executive, + &KpiExplainQuery::default(), + false, + ); + let mut markdown = String::new(); + append_workforce_kpi_explain_markdown(&mut markdown, &explain); + assert!(markdown.contains("## Объяснение индекса активности")); + assert!(markdown.contains("KPI score: 88%")); + assert!(markdown.contains("Confidence: high")); + } + #[test] fn portal_architecture_page_is_informational_and_status_labeled() { assert!(portal_html_route("/architecture").is_some()); @@ -10099,6 +11836,7 @@ mod tests { "/reports", "/executive", "/workforce", + "/workforce/kpi/explain", "/security", "/forensics", "/ueba", @@ -10119,6 +11857,7 @@ mod tests { "RoleContext", "UebaResponse", "PfsenseReadinessResponse", + "WorkforceKpiExplainResponse", "CaseListResponse", "IncidentReviewRequest", "export interface DetMirPortalApi", @@ -11028,6 +12767,14 @@ mod tests { workforce_policy_path: dir.path().join("workforce-policy.json"), ueba_policy_path: dir.path().join("ueba-policy.yaml"), timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11136,6 +12883,14 @@ mod tests { workforce_policy_path: dir.path().join("workforce-policy.json"), ueba_policy_path: dir.path().join("ueba-policy.yaml"), timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11207,6 +12962,14 @@ mod tests { workforce_policy_path: dir.path().join("workforce-policy.json"), ueba_policy_path: dir.path().join("ueba-policy.yaml"), timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), state_dir: dir.path().join("state"), dlp_db_path: dir.path().join("dlp.sqlite"), evidence_root: dir.path().to_path_buf(), @@ -11626,6 +13389,13 @@ mod tests { .unwrap() .contains("## Оценка риска") ); + assert!( + report["markdown"] + .as_str() + .unwrap() + .contains("## Объяснение индекса активности") + ); + assert!(report["workforce_kpi_explain"].is_object()); assert_eq!(report["workforce_policy"]["configured"], false); assert_eq!(report["workforce"]["trend_status"], "daily_only"); assert_eq!(report["workforce"]["insights"].as_array().unwrap().len(), 1); @@ -11819,6 +13589,14 @@ mod tests { workforce_policy_path: case_dir.path().join("workforce-policy.json"), ueba_policy_path: case_dir.path().join("ueba-policy.yaml"), timeout_seconds: 1, + max_page_size: DEFAULT_MAX_PAGE_SIZE, + default_page_size: DEFAULT_PAGE_SIZE, + max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, + request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS, + max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES, + slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS, + environment: "test".to_string(), + enabled_modules: "executive,workforce,security,forensics,admin".to_string(), state_dir: case_dir.path().join("state"), dlp_db_path: case_dir.path().join("dlp.sqlite"), evidence_root: case_dir.path().to_path_buf(), diff --git a/adk-rust/crates/detmir-portal/src/static/app.css b/adk-rust/crates/detmir-portal/src/static/app.css index 16fde90..92ab227 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.css +++ b/adk-rust/crates/detmir-portal/src/static/app.css @@ -979,7 +979,8 @@ h1 { min-height: 180px; } -.index-explain-card { +.index-explain-card, +.kpi-explain-card { margin: 12px 0; } @@ -1015,6 +1016,23 @@ h1 { background: #f8fafc; } +.soft-panel { + min-width: 0; + padding: 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + +.soft-panel h4 { + margin: 0 0 8px; + font-size: 14px; +} + +.kpi-app-list { + margin-top: 10px; +} + .app-weight-list { margin-top: 8px; } diff --git a/adk-rust/crates/detmir-portal/src/static/app.js b/adk-rust/crates/detmir-portal/src/static/app.js index b75da0e..a258889 100644 --- a/adk-rust/crates/detmir-portal/src/static/app.js +++ b/adk-rust/crates/detmir-portal/src/static/app.js @@ -7,6 +7,7 @@ const state = { operatorData: null, reports: null, cases: null, + kpiExplain: null, pendingScrollSelector: null, load: { status: "LOADING", @@ -1242,6 +1243,7 @@ function renderExecutiveView(report, incidents) { return ` ${renderRiskNarrative(report)} ${renderExecutiveDashboard(report)} + ${renderKpiExplain(report?.workforce_kpi_explain)} ${renderSecurityEventsSummary(report?.security_events_summary, { compact: true })} ${renderBusinessRisk(report?.business_risk)} ${renderRiskHeatmap(report?.risk_heatmap)} @@ -1268,6 +1270,7 @@ function renderSecurityView(data, report, extras = {}) { function renderManagerView(report) { return ` ${renderExecutiveDashboard(report)} + ${renderKpiExplain(report?.workforce_kpi_explain)} ${renderDepartmentRanking(report)} ${renderDepartmentHeatMap(report)} ${renderOverviewAnalytics(report)} @@ -1465,7 +1468,7 @@ function renderOverviewAnalytics(report) { `; } -function renderManager(data, policyExplain) { +function renderManager(data, policyExplain, kpiExplain) { const workforceIndex = workforceIndexText(data.users_count, data.total_active_seconds); return `
@@ -1495,6 +1498,7 @@ function renderManager(data, policyExplain) { `).join("")}
+ ${renderKpiExplain(kpiExplain)} ${renderWorkforceIndexExplanation(policyExplain)}

Сотрудники без активности и с аномалиями

${(data.users || []).map(user => ` @@ -1538,7 +1542,7 @@ function renderDepartments(report) { `; } -function renderEmployees(data, policyExplain) { +function renderEmployees(data, policyExplain, kpiExplain) { const employees = Array.isArray(policyExplain?.employee_details) ? policyExplain.employee_details : []; const users = Array.isArray(data.users) ? data.users : []; const selected = users.slice(0, 12); @@ -1552,6 +1556,7 @@ function renderEmployees(data, policyExplain) {
${renderDailyDetailNotice()}
${selected.map(user => renderEmployeeCard(user, employees)).join("") || `

Сотрудники пока не найдены.

`}
+ ${renderKpiExplain(kpiExplain)} ${renderWorkforceIndexExplanation(policyExplain)} `; } @@ -1610,6 +1615,87 @@ function pctText(value) { return `${Math.round(n * 100)}%`; } +function renderKpiExplain(explain) { + if (!explain || explain.ok === false) return ""; + const coverage = explain.coverage || {}; + const factors = Array.isArray(explain.factors) ? explain.factors : []; + const positive = factors.filter(item => String(item.impact || "").startsWith("+")); + const negative = factors.filter(item => String(item.impact || "").startsWith("-")); + const apps = Array.isArray(explain.top_applications) ? explain.top_applications.slice(0, 6) : []; + const warnings = Array.isArray(explain.warnings) ? explain.warnings : []; + const recommendations = Array.isArray(explain.recommendations) ? explain.recommendations : []; + return ` +
+
+
+

Почему такой индекс активности?

+

Детерминированное объяснение: активность, рабочие приложения, простой, полнота и свежесть данных.

+
+ confidence ${ui(explain.confidence || "low")} +
+
+
KPI${ui(explain.kpi_score ?? 0)}%
+
Покрытие${ui(coverage.agent_coverage_percent ?? 0)}%
+
Свежесть${ui(coverage.data_freshness || "missing")}
+
Пропуски${ui((coverage.missing_sources || []).length)}
+
+
+
+

Что повышает индекс

+
${renderKpiFactorRows(positive, "Положительных факторов нет.")}
+
+
+

Что снижает индекс

+
${renderKpiFactorRows(negative, "Отрицательных факторов нет.")}
+
+
+ ${apps.length ? ` +
+ ${apps.map(app => ` +
+ ${ui(app.name || "Приложение")} + ${ui(app.category || "other")} · ${ui(app.active_minutes ?? 0)} мин. + ${ui(app.contribution || "neutral")} +
+ `).join("")} +
+ ` : ""} + ${renderKpiNotes("Предупреждения", warnings)} + ${renderKpiNotes("Рекомендации", recommendations)} +
+ `; +} + +function confidenceStatusClass(confidence) { + const value = String(confidence || "low").toLowerCase(); + if (value === "high") return "status-ok"; + if (value === "medium") return "status-warn"; + return "status-degraded"; +} + +function renderKpiFactorRows(items, emptyText) { + if (!items.length) { + return `
Нет${ui(emptyText)}
`; + } + return items.map(item => ` +
+ ${ui(item.label || item.name || "Фактор")} + ${ui(item.explanation || "")} + ${ui(item.impact || "0")} +
+ `).join(""); +} + +function renderKpiNotes(title, items) { + if (!items.length) return ""; + return ` +
+ ${ui(title)} + ${items.map(item => ui(item)).join(" · ")} +
+ `; +} + function renderWorkforceIndexExplanation(policy) { if (!policy || !policy.configured) { return ` @@ -2824,6 +2910,7 @@ function renderReports(data) {

Ключевые показатели

${renderKpiCards(data.kpis)} + ${renderKpiExplain(data.workforce_kpi_explain)} ${renderAgentQuality(data.agent_quality, data.agent_quality_explain)} ${renderAgentQualityHistory(data.agent_quality_history, data.agent_quality_history_summary)} ${renderAgentQualityNodes(data.agent_quality_nodes, data.agent_quality_nodes_summary)} @@ -2944,7 +3031,9 @@ async function loadCurrentTab() { if (state.tab === "manager") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); - return { data, policyExplain, html: renderManager(data, policyExplain) }; + const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null); + state.kpiExplain = kpiExplain; + return { data, policyExplain, kpiExplain, html: renderManager(data, policyExplain, kpiExplain) }; } if (state.tab === "departments") { const data = await loadJson("/reports"); @@ -2955,7 +3044,9 @@ async function loadCurrentTab() { if (state.tab === "employees") { const data = await loadJson("/manager"); const policyExplain = await loadJson("/workforce/policy/explain").catch(() => null); - return { data, policyExplain, html: renderEmployees(data, policyExplain) }; + const kpiExplain = await loadJson("/workforce/kpi/explain").catch(() => null); + state.kpiExplain = kpiExplain; + return { data, policyExplain, kpiExplain, html: renderEmployees(data, policyExplain, kpiExplain) }; } if (state.tab === "owner") { const data = await loadJson("/owner"); diff --git a/docs/EXPLAINABLE_KPI_RU.md b/docs/EXPLAINABLE_KPI_RU.md new file mode 100644 index 0000000..04de511 --- /dev/null +++ b/docs/EXPLAINABLE_KPI_RU.md @@ -0,0 +1,111 @@ +# Explainable Workforce KPI + +Explainable Workforce KPI отвечает на вопрос: почему получился такой индекс +активности. Слой предназначен для руководителя, ИБ и администратора, но не +является HR-оценкой сотрудника и не использует ML/LLM. + +## API + +Endpoint: + +```http +GET /api/workforce/kpi/explain +``` + +Поддерживаемые параметры: + +- `date`; +- `department`; +- `owner`; +- `role`. + +`employee_id` намеренно не добавлен: отдельная безопасная модель доступа к +персональному explainability-контракту в Pilot v1 не утверждена. + +## Модель ответа + +Ответ содержит: + +- `kpi_score`: итоговый индекс 0-100; +- `confidence`: `high`, `medium` или `low`; +- `coverage`: покрытие агента, свежесть данных, отсутствующие источники; +- `factors`: детерминированные факторы с вкладом и объяснением; +- `top_applications`: агрегированные приложения, влияющие на индекс; +- `warnings`: предупреждения о качестве KPI; +- `recommendations`: действия для проверки или улучшения данных. + +## Факторы + +Минимальный набор факторов: + +| Factor | Смысл | +| --- | --- | +| `productive_activity` | Доля активности относительно планового рабочего времени | +| `business_app_usage` | Наличие рабочих приложений и правил весов | +| `idle_time` | Простой в рабочее время | +| `afterhours_activity` | Активность вне рабочего окна | +| `remote_session_activity` | Подтверждение активности через удаленные сессии | +| `data_coverage` | Полнота агентских данных | +| `missing_data` | Пропущенные источники | +| `trend_change` | Наличие дневной/недельной/месячной истории | + +Факторы rule-based, порядок стабильный, объяснения детерминированные. + +## Confidence + +`high`: + +- хорошее покрытие; +- свежие данные; +- нет критичных пропусков. + +`medium`: + +- есть частичные пропуски; +- свежесть или покрытие требуют проверки. + +`low`: + +- нет worktime-данных; +- мало данных; +- слабое покрытие; +- источник отсутствует или недоступен. + +## Роли + +| Роль | Видимость | +| --- | --- | +| `executive` | Агрегированный KPI, без персональных деталей | +| `manager` | Workforce KPI по доступному управленческому срезу | +| `security` | Только факторы, релевантные ИБ и надежности данных | +| `forensics` | Контекст расследования: временные отклонения и пропуски данных | +| `admin` | Техническое покрытие и состояние источников | + +Security и Forensics не получают Workforce Dashboard через `/api/reports` по +умолчанию. Для explainability используется отдельный endpoint с серверной +фильтрацией. + +## UI и Markdown + +Портал показывает блок: + +```text +Почему такой индекс активности? +``` + +В Markdown-отчет добавлен раздел: + +```markdown +## Объяснение индекса активности +``` + +Раздел содержит KPI score, confidence, coverage, факторы, warnings и +рекомендации. + +## Ограничения Pilot v1 + +- Это не ML и не LLM. +- Это не predictive scoring. +- Это не дисциплинарная HR-оценка. +- Персональные выводы не формируются. +- Качество KPI зависит от свежести ActivityWatch/worktime/agent data. diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index 0c437b2..bb54c6e 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -10,6 +10,115 @@ - реальную запись в InfluxDB; - health Grafana datasource. +## Portal production hardening + +Портал AWatch-rus дополнен отдельным production-hardening слоем. Он не заменяет +`detmir-readiness`, а закрывает HTTP/API надежность портала: liveness, +readiness, version metadata, Prometheus metrics, request id/correlation id, +bounded payload/query limits и role-gate smoke. + +### HTTP endpoints + +| Endpoint | Назначение | Внешние зависимости | +| --- | --- | --- | +| `GET /healthz` | Liveness процесса; возвращает `200 OK`, если процесс отвечает. | Не проверяет | +| `GET /readyz` | Готовность приложения обслуживать запросы. | Только реально настроенные локальные зависимости | +| `GET /version` | Версия приложения, schema version, build metadata. | Не проверяет | +| `GET /metrics` | Prometheus text format. | Не проверяет | + +`/readyz` не заявляет SIEM, DLP ingestion или pfSense ingestion. pfSense +отображается как `contract_only`: контрактная готовность, не реальный +полноценный сборщик. + +### Конфигурация и лимиты + +Портал валидирует конфигурацию при старте и завершает работу с понятной +ошибкой, если значение небезопасно или некорректно. Секреты в ошибку не +попадают. + +| Параметр | Env | Назначение | +| --- | --- | --- | +| `--bind` | `DETMIR_PORTAL_BIND` | `host:port` HTTP-сервера | +| `--max-page-size` | `AWATCH_PORTAL_MAX_PAGE_SIZE` | Верхний предел `page_size`/`limit` | +| `--default-page-size` | `AWATCH_PORTAL_DEFAULT_PAGE_SIZE` | Значение по умолчанию для страниц | +| `--max-report-date-range-days` | `AWATCH_PORTAL_MAX_REPORT_DATE_RANGE_DAYS` | Максимальный диапазон отчетов | +| `--request-timeout-seconds` | `AWATCH_PORTAL_REQUEST_TIMEOUT_SECONDS` | Целевой timeout запроса/операции | +| `--max-request-body-bytes` | `AWATCH_PORTAL_MAX_REQUEST_BODY_BYTES` | Общий лимит тела запроса | +| `--slow-request-log-ms` | `AWATCH_PORTAL_SLOW_REQUEST_LOG_MS` | Порог медленного запроса для логов | +| `--environment` | `AWATCH_PORTAL_ENVIRONMENT` | Безопасное имя окружения | +| `--enabled-modules` | `AWATCH_PORTAL_ENABLED_MODULES` | Разрешенные модули портала | + +Ограничения применяются к тяжелым API: + +- `/api/reports`; +- `/api/executive`; +- `/api/workforce`; +- `/api/security`; +- `/api/forensics`; +- `/api/ueba`; +- `/api/pfsense`; +- `/api/workforce/kpi/explain`. + +Поведение: + +- слишком большой `page_size` или `limit` возвращает `400`; +- слишком широкий диапазон `date_from/date_to`, `from/to`, `start/end` + возвращает `400`; +- слишком большое тело запроса возвращает `413`; +- role gate возвращает `403`. + +### Request ID, logs и metrics + +Портал принимает `X-Request-Id` и `X-Correlation-Id`. Если заголовки не +переданы, `X-Request-Id` генерируется сервером, а `X-Correlation-Id` получает +то же значение. Оба заголовка возвращаются в ответе. + +HTTP-ответы пишутся в stderr как JSON-строки с полями: + +- `timestamp`; +- `level`; +- `request_id`; +- `correlation_id`; +- `method`; +- `path` без query params; +- `route`; +- `status`; +- `latency_ms`; +- `user_role`; +- `module`; +- `error_code`; +- `response_bytes`. + +В логах не должно быть токенов, тел запросов, IP-адресов клиента, +`employee_id`, сырых query params или персональных данных. + +`GET /metrics` возвращает: + +- `awatch_http_requests_total`; +- `awatch_http_request_duration_seconds`; +- `awatch_reports_generated_total`; +- `awatch_ingestion_records_total`; +- `awatch_ingestion_rejected_total`; +- `awatch_role_denied_total`; +- `awatch_readyz_status`. + +Labels ограничены низкой кардинальностью: `method`, `route`, `status`, +`module`. Запрещены high-cardinality labels: `user_id`, `employee_id`, IP, raw +URL, query params. + +### Portal smoke + +Минимальный smoke: + +```bash +AWATCH_PORTAL_SMOKE_URL=http://127.0.0.1:8720 \ + node scripts/awatch-production-hardening-smoke.mjs +``` + +Smoke проверяет `/healthz`, `/readyz`, `/version`, `/metrics`, возврат +`X-Request-Id`, reject слишком большого `page_size`, reject слишком широкого +report range, role gates и `/api/workforce/kpi/explain`. + ## Базовый запуск На AW server: diff --git a/scripts/awatch-production-hardening-smoke.mjs b/scripts/awatch-production-hardening-smoke.mjs new file mode 100644 index 0000000..2ffc3f5 --- /dev/null +++ b/scripts/awatch-production-hardening-smoke.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +const baseUrl = (process.env.AWATCH_PORTAL_SMOKE_URL || "http://127.0.0.1:8720").replace(/\/+$/, ""); + +async function request(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + "X-AWatch-Role": "executive", + "X-Request-Id": "smoke-production-hardening", + ...(options.headers || {}), + }, + }); + const text = await response.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + return { response, text, json }; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +async function main() { + const health = await request("/healthz"); + assert(health.response.status === 200, "/healthz must return 200"); + assert(health.json?.status === "ok", "/healthz must return status=ok"); + assert( + health.response.headers.get("x-request-id") === "smoke-production-hardening", + "X-Request-Id must be returned", + ); + assert( + health.response.headers.get("x-correlation-id") === "smoke-production-hardening", + "X-Correlation-Id must be returned", + ); + + const ready = await request("/readyz"); + assert([200, 503].includes(ready.response.status), "/readyz must return controlled status"); + assert(ready.json?.checks && typeof ready.json.checks === "object", "/readyz must return checks JSON"); + + const version = await request("/version"); + assert(version.response.status === 200, "/version must return 200"); + assert(version.json?.app_version, "/version must include app_version"); + assert(version.json?.schema_version === "pilot-v1", "/version must include schema_version=pilot-v1"); + + const metrics = await request("/metrics"); + assert(metrics.response.status === 200, "/metrics must return 200"); + assert(metrics.text.includes("awatch_http_requests_total"), "/metrics must include HTTP metric"); + assert(metrics.text.includes("awatch_readyz_status"), "/metrics must include readyz gauge"); + + const pageTooLarge = await request("/api/reports?page_size=999999&role=executive"); + assert(pageTooLarge.response.status === 400, "too large page_size must be rejected"); + assert(pageTooLarge.json?.error_code === "invalid_page_size", "page_size reject must explain error_code"); + + const rangeTooLarge = await request("/api/reports?date_from=2026-01-01&date_to=2026-12-31&role=executive"); + assert(rangeTooLarge.response.status === 400, "too wide report range must be rejected"); + assert(rangeTooLarge.json?.error_code === "report_range_too_large", "range reject must explain error_code"); + + const roleDenied = await request("/api/security?role=manager", { + headers: { "X-AWatch-Role": "manager" }, + }); + assert(roleDenied.response.status === 403, "manager must not access security scope"); + + const kpiExplain = await request("/api/workforce/kpi/explain?role=executive"); + assert(kpiExplain.response.status === 200, "KPI explain must return 200"); + assert(typeof kpiExplain.json?.kpi_score === "number", "KPI explain must include kpi_score"); + assert(Array.isArray(kpiExplain.json?.factors), "KPI explain must include factors"); + assert(kpiExplain.json.factors.some((item) => item.name === "productive_activity"), "KPI explain factors must be deterministic"); + + console.log(JSON.stringify({ + ok: true, + baseUrl, + checked: [ + "/healthz", + "/readyz", + "/version", + "/metrics", + "query limits", + "role gates", + "/api/workforce/kpi/explain", + ], + }, null, 2)); +} + +main().catch((error) => { + console.error(JSON.stringify({ ok: false, baseUrl, error: error.message }, null, 2)); + process.exit(1); +});