Compare commits

..
Author SHA1 Message Date
igor04091968 58535fa5c6 refactor(portal): move static assets into module 2026-06-15 07:01:13 +03:00
IgorRachkovandGitHub 7f1abdb9a7 Merge pull request #34 from igor04091968/refactor/portal-snapshot-cache
refactor(portal): move snapshot cache helpers into module
2026-06-15 01:43:58 +03:00
igor04091968 5ca325034f refactor(portal): move snapshot cache helpers into module 2026-06-15 01:36:23 +03:00
IgorRachkovandGitHub 5312da175d Merge pull request #33 from igor04091968/refactor/portal-role-access
refactor(portal): move role access helpers into module
2026-06-15 00:43:06 +03:00
igor04091968 fa1ddf64b4 refactor(portal): wire role access module 2026-06-15 00:03:44 +03:00
igor04091968 643d5d2d69 refactor(portal): move role access helpers into module 2026-06-15 00:00:04 +03:00
IgorRachkovandGitHub b629879958 Merge pull request #32 from igor04091968/refactor/portal-readiness-api
refactor(portal): move readiness API helpers into module
2026-06-14 23:56:40 +03:00
igor04091968 78a560dc3e refactor(portal): move readiness API helpers into module 2026-06-14 23:50:25 +03:00
IgorRachkovandGitHub 803c3169d7 Merge pull request #31 from igor04091968/refactor/portal-api-contracts
refactor(portal): move API contract summary into module
2026-06-14 23:25:49 +03:00
5 changed files with 255 additions and 192 deletions
+15 -192
View File
@@ -3,7 +3,6 @@ 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::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
@@ -29,11 +28,14 @@ mod executive_actions;
mod path_query;
mod portal_roles;
mod production;
mod readiness_api;
mod risk_narrative;
mod role_access;
mod snapshot_cache;
mod static_assets;
mod workforce_kpi_explain;
use api_contracts::api_contract_summary;
use command_runner::run_in_dir;
use executive_actions::{
actions_from_center, build_action_center_from_report, filter_actions_for_role,
};
@@ -48,19 +50,20 @@ use production::{
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
validate_api_query_limits, validate_portal_config,
};
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
use risk_narrative::{
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
};
use role_access::{portal_role_from_request, respond_forbidden, role_envelope};
use snapshot_cache::{
SnapshotCache, build_fast_health, cached_snapshot, clone_snapshot_cache, new_snapshot_cache,
};
use static_assets::{
API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML,
};
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
const INDEX_HTML: &str = include_str!("static/index.html");
const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
const APP_CSS: &str = include_str!("static/app.css");
const APP_JS: &str = include_str!("static/app.js");
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
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";
@@ -84,14 +87,6 @@ unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
#[derive(Clone, Debug)]
struct CachedSnapshot {
created: Instant,
snapshot: Snapshot,
}
#[derive(Clone, Debug, Parser)]
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
struct Cli {
@@ -1324,11 +1319,11 @@ fn run() -> Result<i32> {
}
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
let snapshot_cache: SnapshotCache = Arc::new(Mutex::new(None));
let snapshot_cache: SnapshotCache = new_snapshot_cache();
eprintln!("detmir-portal listening on http://{}", args.bind);
for request in server.incoming_requests() {
let args = args.clone();
let snapshot_cache = Arc::clone(&snapshot_cache);
let snapshot_cache = clone_snapshot_cache(&snapshot_cache);
thread::spawn(move || {
let result = if args.evidence_only {
handle_evidence_only_request(request, &args)
@@ -1662,178 +1657,6 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
)
}
fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
.readiness_bundle_dir
.join("detmir-readiness-latest.json"),
)
.unwrap_or_else(|err| {
json!({
"ok": false,
"generated_at_utc": now(),
"error": err.to_string(),
})
})
}
fn readiness_bundle(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
json!({
"ok": false,
"error": err.to_string(),
})
});
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
.unwrap_or_default()
.trim()
.to_string();
let artifacts = [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.metadata().ok().map(|meta| {
json!({
"name": name,
"bytes": meta.len(),
"available": true,
})
})
})
.collect::<Vec<_>>();
json!({
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
"generated_at_utc": now(),
"bundle_dir": dir.display().to_string(),
"latest_archive_dir": latest_dir,
"status": status,
"artifacts": artifacts,
})
}
fn readiness_verify(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let checksum = run_in_dir(
dir,
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
);
let sig_path = dir.join("sha256sums.txt.sig");
let pub_path = dir.join("public-key.pem");
let signature = if sig_path.is_file() && pub_path.is_file() {
run_in_dir(
dir,
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg("public-key.pem")
.arg("-signature")
.arg("sha256sums.txt.sig")
.arg("sha256sums.txt"),
)
} else {
Err("signature files are not available".to_string())
};
json!({
"ok": checksum.is_ok() && signature.is_ok(),
"generated_at_utc": now(),
"checksum_verified": checksum.is_ok(),
"signature_verified": signature.is_ok(),
"checksum_error": checksum.err(),
"signature_error": signature.err(),
})
}
fn read_json_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
query_param(url, "role")
.as_deref()
.and_then(PortalRole::parse)
.or_else(|| {
request
.headers()
.iter()
.find(|header| header.field.equiv("X-AWatch-Role"))
.and_then(|header| PortalRole::parse(header.value.as_str()))
})
.unwrap_or(PortalRole::Executive)
}
fn role_envelope(role: PortalRole, scope: &str) -> Value {
json!({
"role": role.as_str(),
"role_label": role.label_ru(),
"scope": scope,
"allowed_scopes": role.allowed_scopes(),
"server_enforced": true,
})
}
fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
respond_json_status(
request,
StatusCode(403),
&json!({
"ok": false,
"error": "forbidden",
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
"role": role.as_str(),
"scope": scope,
"server_enforced": true,
}),
)
}
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
if let Some(cached) = guard.as_ref() {
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
return cached.snapshot.clone();
}
}
let snapshot = build_snapshot(args);
*guard = Some(CachedSnapshot {
created: Instant::now(),
snapshot: snapshot.clone(),
});
snapshot
}
fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
match cache.try_lock() {
Ok(guard) => guard
.as_ref()
.map(|cached| build_health(&cached.snapshot))
.unwrap_or_else(lightweight_health),
Err(_) => lightweight_health(),
}
}
fn lightweight_health() -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("portal".to_string(), true);
HealthResponse {
ok: true,
generated_at_utc: now(),
version: env!("CARGO_PKG_VERSION").to_string(),
sources,
}
}
fn build_snapshot(args: &Cli) -> Snapshot {
let timeout = Duration::from_secs(args.timeout_seconds);
let security_events_config = SecurityEventsConfig {
@@ -10714,7 +10537,7 @@ fn header(name: &str, value: &str) -> Result<Header> {
.map_err(|_| anyhow!("invalid header {name}: {value}"))
}
fn now() -> String {
pub(crate) fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
@@ -0,0 +1,112 @@
//! Readiness API payload helpers for the portal.
//!
//! CONTRACT: these helpers expose existing readiness bundle/status/verify
//! payloads. Keep file names, JSON fields and verification commands stable
//! unless the customer readiness contract is updated in the same PR.
use std::fs;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{Value, json};
use crate::command_runner::run_in_dir;
use crate::{Cli, now};
pub(crate) fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
.readiness_bundle_dir
.join("detmir-readiness-latest.json"),
)
.unwrap_or_else(|err| {
json!({
"ok": false,
"generated_at_utc": now(),
"error": err.to_string(),
})
})
}
pub(crate) fn readiness_bundle(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
json!({
"ok": false,
"error": err.to_string(),
})
});
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
.unwrap_or_default()
.trim()
.to_string();
let artifacts = [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.metadata().ok().map(|meta| {
json!({
"name": name,
"bytes": meta.len(),
"available": true,
})
})
})
.collect::<Vec<_>>();
json!({
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
"generated_at_utc": now(),
"bundle_dir": dir.display().to_string(),
"latest_archive_dir": latest_dir,
"status": status,
"artifacts": artifacts,
})
}
pub(crate) fn readiness_verify(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let checksum = run_in_dir(
dir,
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
);
let sig_path = dir.join("sha256sums.txt.sig");
let pub_path = dir.join("public-key.pem");
let signature = if sig_path.is_file() && pub_path.is_file() {
run_in_dir(
dir,
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg("public-key.pem")
.arg("-signature")
.arg("sha256sums.txt.sig")
.arg("sha256sums.txt"),
)
} else {
Err("signature files are not available".to_string())
};
json!({
"ok": checksum.is_ok() && signature.is_ok(),
"generated_at_utc": now(),
"checksum_verified": checksum.is_ok(),
"signature_verified": signature.is_ok(),
"checksum_error": checksum.err(),
"signature_error": signature.err(),
})
}
fn read_json_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
@@ -0,0 +1,52 @@
//! Portal role extraction and access-denial helpers.
//!
//! CONTRACT: role aliases, role envelope fields and forbidden response shape
//! are part of the portal security boundary. Keep changes explicit and covered
//! by role-gate tests.
use anyhow::Result;
use serde_json::{Value, json};
use tiny_http::{Request, StatusCode};
use crate::path_query::query_param;
use crate::portal_roles::PortalRole;
use crate::respond_json_status;
pub(crate) fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
query_param(url, "role")
.as_deref()
.and_then(PortalRole::parse)
.or_else(|| {
request
.headers()
.iter()
.find(|header| header.field.equiv("X-AWatch-Role"))
.and_then(|header| PortalRole::parse(header.value.as_str()))
})
.unwrap_or(PortalRole::Executive)
}
pub(crate) fn role_envelope(role: PortalRole, scope: &str) -> Value {
json!({
"role": role.as_str(),
"role_label": role.label_ru(),
"scope": scope,
"allowed_scopes": role.allowed_scopes(),
"server_enforced": true,
})
}
pub(crate) fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
respond_json_status(
request,
StatusCode(403),
&json!({
"ok": false,
"error": "forbidden",
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
"role": role.as_str(),
"scope": scope,
"server_enforced": true,
}),
)
}
@@ -0,0 +1,65 @@
//! Snapshot cache helpers for the portal request path.
//!
//! CONTRACT: this module only owns short-lived in-process cache behavior.
//! It must not change snapshot payloads, source collection, API routes or
//! business calculations.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::{Cli, HealthResponse, Snapshot, build_health, build_snapshot, now};
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
pub(crate) type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
#[derive(Clone, Debug)]
pub(crate) struct CachedSnapshot {
created: Instant,
snapshot: Snapshot,
}
pub(crate) fn new_snapshot_cache() -> SnapshotCache {
Arc::new(Mutex::new(None))
}
pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
Arc::clone(cache)
}
pub(crate) fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
if let Some(cached) = guard.as_ref() {
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
return cached.snapshot.clone();
}
}
let snapshot = build_snapshot(args);
*guard = Some(CachedSnapshot {
created: Instant::now(),
snapshot: snapshot.clone(),
});
snapshot
}
pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
match cache.try_lock() {
Ok(guard) => guard
.as_ref()
.map(|cached| build_health(&cached.snapshot))
.unwrap_or_else(lightweight_health),
Err(_) => lightweight_health(),
}
}
fn lightweight_health() -> HealthResponse {
let mut sources = BTreeMap::new();
sources.insert("portal".to_string(), true);
HealthResponse {
ok: true,
generated_at_utc: now(),
version: env!("CARGO_PKG_VERSION").to_string(),
sources,
}
}
@@ -0,0 +1,11 @@
//! Static portal assets and generated API contract text.
//!
//! CONTRACT: this module only exposes embedded static files. Do not change
//! file contents, MIME handling, routes or API contracts from here.
pub(crate) const INDEX_HTML: &str = include_str!("static/index.html");
pub(crate) const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
pub(crate) const APP_CSS: &str = include_str!("static/app.css");
pub(crate) const APP_JS: &str = include_str!("static/app.js");
pub(crate) const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
pub(crate) const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");