Compare commits

..
Author SHA1 Message Date
igor04091968 03f10435ce refactor(portal): move API contract summary into module 2026-06-14 23:14:30 +03:00
IgorRachkovandGitHub 2f8193e7b3 Merge pull request #30 from igor04091968/refactor/portal-path-query-helpers
refactor(portal): move path and query helpers into module
2026-06-14 23:05:35 +03:00
igor04091968 e76fa5a5c2 refactor(portal): move path and query helpers into module 2026-06-14 22:17:12 +03:00
IgorRachkovandGitHub 68c0fd1a37 Merge pull request #29 from igor04091968/refactor/portal-command-runner
refactor(portal): move command runner into module
2026-06-14 22:11:39 +03:00
igor04091968 d19b3d478f refactor(portal): move command runner into module 2026-06-14 22:00:44 +03:00
IgorRachkovandGitHub 0cd6e4f856 Merge pull request #28 from igor04091968/refactor/portal-roles-module
refactor(portal): move role model into module
2026-06-14 21:43:48 +03:00
igor04091968 acf767360f refactor(portal): move role model into module 2026-06-14 21:25:30 +03:00
5 changed files with 225 additions and 188 deletions
@@ -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())
}
}
+11 -188
View File
@@ -23,14 +23,25 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
mod api_contracts;
mod command_runner;
mod executive_actions;
mod path_query;
mod portal_roles;
mod production;
mod risk_narrative;
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,
};
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 +86,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,66 +1662,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
@@ -1878,42 +1759,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 +1798,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)
}
}