Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76fa5a5c2 | ||
|
|
68c0fd1a37 | ||
|
|
d19b3d478f | ||
|
|
0cd6e4f856 | ||
|
|
acf767360f | ||
|
|
9ad5b2fc34 |
@@ -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,14 +23,23 @@ use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
|
||||
mod command_runner;
|
||||
mod executive_actions;
|
||||
mod path_query;
|
||||
mod portal_roles;
|
||||
mod production;
|
||||
mod risk_narrative;
|
||||
mod workforce_kpi_explain;
|
||||
|
||||
use command_runner::run_in_dir;
|
||||
use executive_actions::{
|
||||
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::{
|
||||
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,
|
||||
@@ -75,76 +84,6 @@ unsafe extern "C" {
|
||||
|
||||
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)]
|
||||
struct CachedSnapshot {
|
||||
created: Instant,
|
||||
@@ -1721,16 +1660,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,
|
||||
@@ -1878,42 +1807,6 @@ fn read_json_file(path: &Path) -> Result<Value> {
|
||||
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 {
|
||||
query_param(url, "role")
|
||||
.as_deref()
|
||||
@@ -1953,28 +1846,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 {
|
||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||
if let Some(cached) = guard.as_ref() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user