Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78a560dc3e | ||
|
|
803c3169d7 | ||
|
|
03f10435ce | ||
|
|
2f8193e7b3 | ||
|
|
e76fa5a5c2 | ||
|
|
68c0fd1a37 | ||
|
|
d19b3d478f | ||
|
|
0cd6e4f856 | ||
|
|
acf767360f | ||
|
|
9ad5b2fc34 |
@@ -0,0 +1,57 @@
|
|||||||
|
//! Portal API contract summary payload.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: this module describes stable public API routes exposed by the
|
||||||
|
//! current Rust HTML/HTMX portal and future clients. Keep changes additive
|
||||||
|
//! unless the OpenAPI/TypeScript contracts are updated in the same PR.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub(crate) fn api_contract_summary() -> Value {
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"contract_version": "2026-06-06.pilot-v1",
|
||||||
|
"generated_by": "detmir-portal",
|
||||||
|
"api_base": "/api",
|
||||||
|
"compatibility": {
|
||||||
|
"policy": "additive",
|
||||||
|
"main_ui": "rust-server-rendered-html-htmx-compatible",
|
||||||
|
"unknown_fields": "clients must ignore unknown fields",
|
||||||
|
"nullable_fields": "clients must tolerate null and missing optional fields",
|
||||||
|
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
|
||||||
|
},
|
||||||
|
"targets": ["rust-html", "htmx-compatible"],
|
||||||
|
"artifacts": {
|
||||||
|
"openapi": "/api/contracts/openapi.json",
|
||||||
|
"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"},
|
||||||
|
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
||||||
|
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
||||||
|
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
||||||
|
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
|
||||||
|
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
|
||||||
|
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
|
||||||
|
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
|
||||||
|
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
|
||||||
|
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
|
||||||
|
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
||||||
|
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
||||||
|
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
||||||
|
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
|
||||||
|
{"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/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
||||||
|
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
|
||||||
|
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! External command execution helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: these helpers are intentionally small and side-effect explicit.
|
||||||
|
//! They preserve stdout/stderr error text because readiness verification APIs
|
||||||
|
//! expose command failure diagnostics to operators.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub(crate) fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
|
||||||
|
let output = command
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,20 +23,32 @@ use serde_json::{Value, json};
|
|||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||||
|
|
||||||
|
mod api_contracts;
|
||||||
|
mod command_runner;
|
||||||
mod executive_actions;
|
mod executive_actions;
|
||||||
|
mod path_query;
|
||||||
|
mod portal_roles;
|
||||||
mod production;
|
mod production;
|
||||||
|
mod readiness_api;
|
||||||
mod risk_narrative;
|
mod risk_narrative;
|
||||||
mod workforce_kpi_explain;
|
mod workforce_kpi_explain;
|
||||||
|
|
||||||
|
use api_contracts::api_contract_summary;
|
||||||
use executive_actions::{
|
use executive_actions::{
|
||||||
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
||||||
};
|
};
|
||||||
|
use path_query::{
|
||||||
|
normalize_path, parse_case_path, parse_case_status_path, parse_investigation_pack_path,
|
||||||
|
query_flag, query_param,
|
||||||
|
};
|
||||||
|
use portal_roles::PortalRole;
|
||||||
use production::{
|
use production::{
|
||||||
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
||||||
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
||||||
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
||||||
validate_api_query_limits, validate_portal_config,
|
validate_api_query_limits, validate_portal_config,
|
||||||
};
|
};
|
||||||
|
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
|
||||||
use risk_narrative::{
|
use risk_narrative::{
|
||||||
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
||||||
};
|
};
|
||||||
@@ -75,76 +87,6 @@ unsafe extern "C" {
|
|||||||
|
|
||||||
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
enum PortalRole {
|
|
||||||
Executive,
|
|
||||||
Manager,
|
|
||||||
Security,
|
|
||||||
Forensics,
|
|
||||||
Admin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PortalRole {
|
|
||||||
fn parse(value: &str) -> Option<Self> {
|
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"executive" | "owner" | "rukovoditel" | "руководитель" => {
|
|
||||||
Some(Self::Executive)
|
|
||||||
}
|
|
||||||
"manager" | "workforce" | "руководитель_подразделения" => {
|
|
||||||
Some(Self::Manager)
|
|
||||||
}
|
|
||||||
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
|
|
||||||
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
|
|
||||||
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Executive => "executive",
|
|
||||||
Self::Manager => "manager",
|
|
||||||
Self::Security => "security",
|
|
||||||
Self::Forensics => "forensics",
|
|
||||||
Self::Admin => "admin",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label_ru(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Executive => "Руководитель",
|
|
||||||
Self::Manager => "Руководитель подразделения",
|
|
||||||
Self::Security => "Безопасность",
|
|
||||||
Self::Forensics => "Расследования",
|
|
||||||
Self::Admin => "Администратор",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allowed_scopes(self) -> &'static [&'static str] {
|
|
||||||
match self {
|
|
||||||
Self::Executive => &["executive", "workforce"],
|
|
||||||
Self::Manager => &["executive", "workforce"],
|
|
||||||
Self::Security => &["security", "incidents", "ueba", "pfsense"],
|
|
||||||
Self::Forensics => &["forensics", "incidents", "ueba"],
|
|
||||||
Self::Admin => &[
|
|
||||||
"executive",
|
|
||||||
"workforce",
|
|
||||||
"security",
|
|
||||||
"forensics",
|
|
||||||
"incidents",
|
|
||||||
"ueba",
|
|
||||||
"pfsense",
|
|
||||||
"admin",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn can_access(self, scope: &str) -> bool {
|
|
||||||
self.allowed_scopes().contains(&scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct CachedSnapshot {
|
struct CachedSnapshot {
|
||||||
created: Instant,
|
created: Instant,
|
||||||
@@ -1721,199 +1663,6 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(url: &str) -> String {
|
|
||||||
let path = url.split('?').next().unwrap_or("/");
|
|
||||||
let path = path.strip_prefix("/portal").unwrap_or(path);
|
|
||||||
if path.is_empty() {
|
|
||||||
"/".to_string()
|
|
||||||
} else {
|
|
||||||
path.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_contract_summary() -> Value {
|
|
||||||
json!({
|
|
||||||
"ok": true,
|
|
||||||
"contract_version": "2026-06-06.pilot-v1",
|
|
||||||
"generated_by": "detmir-portal",
|
|
||||||
"api_base": "/api",
|
|
||||||
"compatibility": {
|
|
||||||
"policy": "additive",
|
|
||||||
"main_ui": "rust-server-rendered-html-htmx-compatible",
|
|
||||||
"unknown_fields": "clients must ignore unknown fields",
|
|
||||||
"nullable_fields": "clients must tolerate null and missing optional fields",
|
|
||||||
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
|
|
||||||
},
|
|
||||||
"targets": ["rust-html", "htmx-compatible"],
|
|
||||||
"artifacts": {
|
|
||||||
"openapi": "/api/contracts/openapi.json",
|
|
||||||
"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"},
|
|
||||||
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
|
||||||
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
|
||||||
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
|
||||||
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
|
|
||||||
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
|
|
||||||
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
|
|
||||||
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
|
|
||||||
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
|
|
||||||
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
|
|
||||||
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
|
||||||
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
|
||||||
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
|
||||||
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
|
|
||||||
{"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/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
|
||||||
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
|
|
||||||
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
|
|
||||||
let output = command
|
|
||||||
.current_dir(dir)
|
|
||||||
.output()
|
|
||||||
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
|
|
||||||
if output.status.success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!(
|
|
||||||
"{}{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_flag(url: &str, key: &str) -> bool {
|
|
||||||
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
query.split('&').any(|pair| {
|
|
||||||
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
|
|
||||||
name == key && matches!(value, "1" | "true" | "yes" | "on")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_param(url: &str, key: &str) -> Option<String> {
|
|
||||||
let query = url.split_once('?').map(|(_, query)| query)?;
|
|
||||||
query.split('&').find_map(|pair| {
|
|
||||||
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
|
|
||||||
(name == key && !value.is_empty()).then(|| value.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
|
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
|
||||||
query_param(url, "role")
|
query_param(url, "role")
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1953,28 +1702,6 @@ fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/investigation-pack/")
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_case_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/cases/")
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_case_status_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/cases/")
|
|
||||||
.and_then(|value| value.strip_suffix("/status"))
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
||||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||||
if let Some(cached) = guard.as_ref() {
|
if let Some(cached) = guard.as_ref() {
|
||||||
@@ -10891,7 +10618,7 @@ fn header(name: &str, value: &str) -> Result<Header> {
|
|||||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now() -> String {
|
pub(crate) fn now() -> String {
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! URL path and query parsing helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: these helpers are routing glue. Keep accepted URL shapes stable
|
||||||
|
//! because API handlers and the HTML portal depend on them.
|
||||||
|
|
||||||
|
pub(crate) fn normalize_path(url: &str) -> String {
|
||||||
|
let path = url.split('?').next().unwrap_or("/");
|
||||||
|
let path = path.strip_prefix("/portal").unwrap_or(path);
|
||||||
|
if path.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_flag(url: &str, key: &str) -> bool {
|
||||||
|
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
query.split('&').any(|pair| {
|
||||||
|
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
|
||||||
|
name == key && matches!(value, "1" | "true" | "yes" | "on")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_param(url: &str, key: &str) -> Option<String> {
|
||||||
|
let query = url.split_once('?').map(|(_, query)| query)?;
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||||
|
(name == key && !value.is_empty()).then(|| value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/investigation-pack/")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_case_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_case_status_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.and_then(|value| value.strip_suffix("/status"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Portal role model and access-scope contract.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: role aliases, serialized values and allowed scopes are part of
|
||||||
|
//! the portal API/security boundary. Keep changes explicit and covered by
|
||||||
|
//! existing role-gate tests in `main.rs`.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum PortalRole {
|
||||||
|
Executive,
|
||||||
|
Manager,
|
||||||
|
Security,
|
||||||
|
Forensics,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortalRole {
|
||||||
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"executive" | "owner" | "rukovoditel" | "руководитель" => {
|
||||||
|
Some(Self::Executive)
|
||||||
|
}
|
||||||
|
"manager" | "workforce" | "руководитель_подразделения" => {
|
||||||
|
Some(Self::Manager)
|
||||||
|
}
|
||||||
|
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
|
||||||
|
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
|
||||||
|
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "executive",
|
||||||
|
Self::Manager => "manager",
|
||||||
|
Self::Security => "security",
|
||||||
|
Self::Forensics => "forensics",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn label_ru(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "Руководитель",
|
||||||
|
Self::Manager => "Руководитель подразделения",
|
||||||
|
Self::Security => "Безопасность",
|
||||||
|
Self::Forensics => "Расследования",
|
||||||
|
Self::Admin => "Администратор",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn allowed_scopes(self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
Self::Executive => &["executive", "workforce"],
|
||||||
|
Self::Manager => &["executive", "workforce"],
|
||||||
|
Self::Security => &["security", "incidents", "ueba", "pfsense"],
|
||||||
|
Self::Forensics => &["forensics", "incidents", "ueba"],
|
||||||
|
Self::Admin => &[
|
||||||
|
"executive",
|
||||||
|
"workforce",
|
||||||
|
"security",
|
||||||
|
"forensics",
|
||||||
|
"incidents",
|
||||||
|
"ueba",
|
||||||
|
"pfsense",
|
||||||
|
"admin",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn can_access(self, scope: &str) -> bool {
|
||||||
|
self.allowed_scopes().contains(&scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user