feat(portal): harden pilot v1 role contracts

This commit is contained in:
igor04091968
2026-06-06 12:54:00 +03:00
parent 9c57d6d2ce
commit 067ad0939c
24 changed files with 1991 additions and 208 deletions
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "AWatch-rus DetMir Portal API",
"version": "2026-06-05.v1",
"description": "Stable additive API contract for the current HTML portal and future React/Tauri clients. Clients must ignore unknown fields and tolerate missing optional fields."
"version": "2026-06-06.pilot-v1",
"description": "Stable additive API contract for the Rust server-rendered HTML portal and HTMX-compatible role slices. Dioxus, React, Tauri and Electron are not part of this Pilot v1 contract. Clients must ignore unknown fields and tolerate missing optional fields."
},
"servers": [
{
@@ -21,6 +21,24 @@
{
"name": "reports"
},
{
"name": "roles"
},
{
"name": "workforce"
},
{
"name": "security"
},
{
"name": "forensics"
},
{
"name": "ueba"
},
{
"name": "pfsense"
},
{
"name": "incidents"
},
@@ -191,6 +209,15 @@
"type": "boolean"
},
"description": "Return anonymized values when supported"
},
{
"name": "role",
"in": "query",
"required": false,
"schema": {
"$ref": "#/components/schemas/PortalRole"
},
"description": "Role slice. The X-AWatch-Role header is also accepted and is preferred by the portal UI."
}
],
"responses": {
@@ -207,6 +234,144 @@
}
}
},
"/executive": {
"get": {
"tags": [
"roles"
],
"summary": "Executive role slice",
"responses": {
"200": {
"description": "Executive dashboard and Workforce summary without security detail",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReportPayload"
}
}
}
},
"403": {
"description": "Role is not allowed to access executive scope"
}
}
}
},
"/workforce": {
"get": {
"tags": [
"workforce"
],
"summary": "Workforce role slice",
"responses": {
"200": {
"description": "Workforce analytics, department comparison, owners, trends and markdown report",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReportPayload"
}
}
}
},
"403": {
"description": "Role is not allowed to access workforce scope"
}
}
}
},
"/security": {
"get": {
"tags": [
"security"
],
"summary": "Security role slice",
"responses": {
"200": {
"description": "Security risks, events, candidates and UEBA score without Workforce dashboard",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReportPayload"
}
}
}
},
"403": {
"description": "Role is not allowed to access security scope"
}
}
}
},
"/forensics": {
"get": {
"tags": [
"forensics"
],
"summary": "Forensics role slice",
"responses": {
"200": {
"description": "Investigations, timeline contract and export links",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReportPayload"
}
}
}
},
"403": {
"description": "Role is not allowed to access forensics scope"
}
}
}
},
"/ueba": {
"get": {
"tags": [
"ueba"
],
"summary": "UEBA score v1 rule-based contract",
"responses": {
"200": {
"description": "Transparent rule-based UEBA score",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UebaPayload"
}
}
}
},
"403": {
"description": "Role is not allowed to access UEBA scope"
}
}
}
},
"/pfsense": {
"get": {
"tags": [
"pfsense"
],
"summary": "pfSense integration readiness contract",
"responses": {
"200": {
"description": "Contract-only pfSense readiness payload with demo fixtures",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PfsenseReadiness"
}
}
}
},
"403": {
"description": "Role is not allowed to access pfSense scope"
}
}
}
},
"/workforce/policy/explain": {
"get": {
"tags": [
@@ -605,6 +770,45 @@
},
"components": {
"schemas": {
"PortalRole": {
"type": "string",
"enum": [
"executive",
"manager",
"security",
"forensics",
"admin"
]
},
"RoleContext": {
"type": "object",
"required": [
"role",
"scope",
"server_enforced"
],
"properties": {
"role": {
"$ref": "#/components/schemas/PortalRole"
},
"role_label": {
"type": "string"
},
"scope": {
"type": "string"
},
"allowed_scopes": {
"type": "array",
"items": {
"type": "string"
}
},
"server_enforced": {
"type": "boolean"
}
},
"additionalProperties": true
},
"JsonObject": {
"type": "object",
"additionalProperties": true
@@ -713,6 +917,9 @@
"$ref": "#/components/schemas/JsonObject"
}
},
"role_context": {
"$ref": "#/components/schemas/RoleContext"
},
"cases": {
"type": "array",
"items": {
@@ -722,6 +929,156 @@
},
"additionalProperties": true
},
"UebaPayload": {
"type": "object",
"required": [
"ok",
"score",
"severity",
"score_components",
"reason_codes",
"model"
],
"properties": {
"ok": {
"type": "boolean"
},
"role_context": {
"$ref": "#/components/schemas/RoleContext"
},
"score": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 100
},
"severity": {
"type": "string",
"enum": [
"normal",
"low",
"medium",
"high",
"critical"
]
},
"score_components": {
"type": "object",
"required": [
"activity_anomaly",
"time_anomaly",
"application_anomaly",
"network_anomaly",
"history_anomaly"
],
"properties": {
"activity_anomaly": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"time_anomaly": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"application_anomaly": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"network_anomaly": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"history_anomaly": {
"type": "integer",
"minimum": 0,
"maximum": 100
}
},
"additionalProperties": false
},
"reason_codes": {
"type": "array",
"items": {
"type": "string"
}
},
"explanation": {
"type": "string"
},
"model": {
"$ref": "#/components/schemas/JsonObject"
},
"risk": {
"$ref": "#/components/schemas/JsonObject"
}
},
"additionalProperties": true
},
"PfsenseReadiness": {
"type": "object",
"required": [
"ok",
"contract_version",
"status",
"ingestion_available",
"firewall_events",
"vpn_events",
"traffic_summary",
"top_destinations"
],
"properties": {
"ok": {
"type": "boolean"
},
"role_context": {
"$ref": "#/components/schemas/RoleContext"
},
"contract_version": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"contract_only",
"available"
]
},
"siem": {
"type": "boolean"
},
"ingestion_available": {
"type": "boolean"
},
"firewall_events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JsonObject"
}
},
"vpn_events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JsonObject"
}
},
"traffic_summary": {
"$ref": "#/components/schemas/JsonObject"
},
"top_destinations": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JsonObject"
}
}
},
"additionalProperties": true
},
"IncidentReviewRequest": {
"type": "object",
"required": [
+71 -1
View File
@@ -1,5 +1,6 @@
export type ISODateTime = string;
export type PortalRole = "executive" | "manager" | "security" | "forensics" | "admin";
export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN";
export type ReviewStatus =
| "NEW"
@@ -36,6 +37,15 @@ export interface ContractIndex {
[key: string]: unknown;
}
export interface RoleContext {
role: PortalRole;
role_label?: string;
scope: string;
allowed_scopes?: string[];
server_enforced: boolean;
[key: string]: unknown;
}
export interface ExecutiveDashboard {
trust_kpi_score?: number;
agent_coverage_pct?: number;
@@ -154,6 +164,7 @@ export interface CaseStatusRequest {
export interface ReportsResponse {
ok: boolean;
role_context?: RoleContext;
generated_at_utc?: ISODateTime;
executive_points?: string[];
executive_dashboard?: ExecutiveDashboard;
@@ -166,6 +177,59 @@ export interface ReportsResponse {
[key: string]: unknown;
}
export interface UebaResponse {
ok: boolean;
role_context?: RoleContext;
score: number | null;
severity: "normal" | "low" | "medium" | "high" | "critical" | string;
status?: string;
score_components: {
activity_anomaly: number;
time_anomaly: number;
application_anomaly: number;
network_anomaly: number;
history_anomaly: number;
};
reason_codes: string[];
explanation: string;
model: JsonObject;
risk: JsonObject;
[key: string]: unknown;
}
export interface PfsenseFirewallEvent {
timestamp: ISODateTime;
source_host: string;
destination: string;
action: string;
rule_id?: string;
protocol?: string;
[key: string]: unknown;
}
export interface PfsenseVpnEvent {
timestamp: ISODateTime;
source_host: string;
user_ref?: string;
action: string;
tunnel?: string;
[key: string]: unknown;
}
export interface PfsenseReadinessResponse {
ok: boolean;
role_context?: RoleContext;
contract_version: string;
status: "contract_only" | "available" | string;
siem: boolean;
ingestion_available: boolean;
firewall_events: PfsenseFirewallEvent[];
vpn_events: PfsenseVpnEvent[];
traffic_summary: JsonObject;
top_destinations: JsonObject[];
[key: string]: unknown;
}
export interface CaseListResponse {
ok: boolean;
cases: CaseItem[];
@@ -178,7 +242,13 @@ export interface DetMirPortalApi {
getOperator(): Promise<JsonObject>;
getManager(): Promise<JsonObject>;
getOwner(): Promise<JsonObject>;
getReports(options?: { anonymize?: boolean }): Promise<ReportsResponse>;
getReports(options?: { anonymize?: boolean; role?: PortalRole }): Promise<ReportsResponse>;
getExecutive(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getWorkforce(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getSecurity(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getForensics(options?: { role?: PortalRole }): Promise<ReportsResponse>;
getUeba(options?: { role?: PortalRole }): Promise<UebaResponse>;
getPfsense(options?: { role?: PortalRole }): Promise<PfsenseReadinessResponse>;
getIncidents(): Promise<JsonObject>;
getCases(): Promise<CaseListResponse>;
createCase(request: CreateCaseRequest): Promise<JsonObject>;
+793 -36
View File
@@ -43,6 +43,76 @@ 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,
@@ -1248,17 +1318,30 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
let url = request.url().to_string();
let path = normalize_path(&url);
let anonymize = query_flag(&url, "anonymize");
let role = portal_role_from_request(&request, &url);
if method == Method::Post && path == "/api/incidents/action" {
if !role.can_access("incidents") {
return respond_forbidden(request, role, "incidents");
}
return handle_incident_action(request, args);
}
if method == Method::Post && path == "/api/incident-review" {
if !role.can_access("security") {
return respond_forbidden(request, role, "security");
}
return handle_incident_review(request, args);
}
if method == Method::Post && path == "/api/cases" {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
return handle_create_case(request, args, snapshot_cache);
}
if method == Method::Post {
if let Some(case_id) = parse_case_status_path(&path) {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
return handle_case_status(request, args, &case_id);
}
}
@@ -1269,12 +1352,21 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
return respond_text(request, StatusCode(405), "Method Not Allowed", "text/plain");
}
if path == "/api/dlp/evidence" {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
return respond_json(request, &build_dlp_evidence_response(args));
}
if let Some(candidate_id) = parse_investigation_pack_path(&path) {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
return handle_investigation_pack(request, args, snapshot_cache, &url, &candidate_id);
}
if let Some(case_id) = parse_case_path(&path) {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
return handle_case_details(request, args, snapshot_cache, &url, &case_id);
}
if let Some((evidence_id, download)) = parse_evidence_screenshot_path(&path) {
@@ -1322,10 +1414,16 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
respond_json(request, &build_operator(&snapshot, &incident_state))
}
"/api/manager" => {
if !role.can_access("workforce") {
return respond_forbidden(request, role, "workforce");
}
let snapshot = cached_snapshot(args, snapshot_cache);
respond_json(request, &build_manager(&snapshot))
}
"/api/workforce/policy/explain" => {
if !role.can_access("workforce") {
return respond_forbidden(request, role, "workforce");
}
let snapshot = cached_snapshot(args, snapshot_cache);
respond_json(
request,
@@ -1333,41 +1431,71 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache)
)
}
"/api/owner" => {
if !role.can_access("security") {
return respond_forbidden(request, role, "security");
}
let snapshot = cached_snapshot(args, snapshot_cache);
respond_json(request, &build_owner(&snapshot))
}
"/api/reports" => {
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);
let incident_review_audit = load_incident_review_audit_best_effort(args);
let cases = load_cases_best_effort(args);
let evidence = build_dlp_evidence_response(args);
let ueba_baseline_path = ueba_baseline_state_path(args);
respond_json(
request,
&build_reports(
&snapshot,
ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &incident_reviews,
incident_review_audit: &incident_review_audit,
cases: &cases,
evidence: &evidence,
},
&args.workforce_policy_path,
&args.ueba_policy_path,
&ueba_baseline_path,
anonymize,
),
)
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &role_filtered_report(report, role))
}
"/api/executive" => {
if !role.can_access("executive") {
return respond_forbidden(request, role, "executive");
}
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &build_role_api_payload(report, role, "executive"))
}
"/api/workforce" => {
if !role.can_access("workforce") {
return respond_forbidden(request, role, "workforce");
}
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &build_role_api_payload(report, role, "workforce"))
}
"/api/security" => {
if !role.can_access("security") {
return respond_forbidden(request, role, "security");
}
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &build_role_api_payload(report, role, "security"))
}
"/api/forensics" => {
if !role.can_access("forensics") {
return respond_forbidden(request, role, "forensics");
}
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &build_role_api_payload(report, role, "forensics"))
}
"/api/ueba" => {
if !role.can_access("ueba") {
return respond_forbidden(request, role, "ueba");
}
let report = build_report_payload(args, snapshot_cache, anonymize);
respond_json(request, &build_ueba_api_payload(&report, role))
}
"/api/pfsense" => {
if !role.can_access("pfsense") {
return respond_forbidden(request, role, "pfsense");
}
respond_json(request, &build_pfsense_readiness_payload(role))
}
"/api/incidents" => {
if !role.can_access("incidents") {
return respond_forbidden(request, role, "incidents");
}
let snapshot = cached_snapshot(args, snapshot_cache);
let incident_state = load_incident_state_best_effort(args);
respond_json(request, &build_incidents(&snapshot, &incident_state))
}
"/api/cases" => respond_json(request, &build_case_list(args)),
"/api/cases" => {
if !role.can_access("forensics") && !role.can_access("incidents") {
return respond_forbidden(request, role, "forensics");
}
respond_json(request, &build_case_list(args))
}
"/api/links" => respond_json(request, &links()),
_ => respond_text(
request,
@@ -1436,16 +1564,17 @@ fn normalize_path(url: &str) -> String {
fn api_contract_summary() -> Value {
json!({
"ok": true,
"contract_version": "2026-06-05.v1",
"contract_version": "2026-06-06.pilot-v1",
"generated_by": "detmir-portal",
"api_base": "/api",
"compatibility": {
"policy": "additive",
"existing_html_portal": "unchanged",
"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"
"nullable_fields": "clients must tolerate null and missing optional fields",
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
},
"targets": ["current-html", "future-react", "future-tauri"],
"targets": ["rust-html", "htmx-compatible"],
"artifacts": {
"openapi": "/api/contracts/openapi.json",
"typescript": "/api/contracts/typescript.d.ts"
@@ -1457,6 +1586,12 @@ fn api_contract_summary() -> Value {
{"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"},
@@ -1602,6 +1737,45 @@ fn query_param(url: &str, key: &str) -> Option<String> {
})
}
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 parse_investigation_pack_path(path: &str) -> Option<String> {
path.strip_prefix("/api/investigation-pack/")
.map(str::trim)
@@ -3185,6 +3359,380 @@ fn build_reports(
})
}
fn build_report_payload(args: &Cli, snapshot_cache: &SnapshotCache, anonymize: bool) -> Value {
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);
let incident_review_audit = load_incident_review_audit_best_effort(args);
let cases = load_cases_best_effort(args);
let evidence = build_dlp_evidence_response(args);
let ueba_baseline_path = ueba_baseline_state_path(args);
build_reports(
&snapshot,
ReportRuntimeInputs {
incident_state: &incident_state,
incident_reviews: &incident_reviews,
incident_review_audit: &incident_review_audit,
cases: &cases,
evidence: &evidence,
},
&args.workforce_policy_path,
&args.ueba_policy_path,
&ueba_baseline_path,
anonymize,
)
}
fn role_filtered_report(report: Value, role: PortalRole) -> Value {
if role == PortalRole::Admin {
let mut full = report;
if let Some(object) = full.as_object_mut() {
object.insert("ok".to_string(), Value::Bool(true));
object.insert("role_context".to_string(), role_envelope(role, "admin"));
}
return full;
}
let mut out = serde_json::Map::new();
let Some(object) = report.as_object() else {
out.insert("ok".to_string(), Value::Bool(false));
out.insert("role_context".to_string(), role_envelope(role, "unknown"));
return Value::Object(out);
};
let base_keys = [
"generated_at_utc",
"period",
"anonymized",
"severity",
"operator_ok",
"headline",
"links",
];
for key in base_keys {
copy_json_key(object, &mut out, key);
}
match role {
PortalRole::Executive => {
for key in [
"executive_points",
"executive_dashboard",
"kpis",
"business_risk",
"business_risk_history_summary",
"risk_heatmap",
"workforce",
"markdown",
] {
copy_json_key(object, &mut out, key);
}
out.insert(
"scope_note".to_string(),
json!("Руководитель видит управленческий вывод и Workforce без ИБ-детализации."),
);
out.insert("role_context".to_string(), role_envelope(role, "executive"));
}
PortalRole::Manager => {
for key in [
"executive_dashboard",
"kpis",
"business_risk",
"risk_heatmap",
"workforce",
"workforce_policy",
"markdown",
] {
copy_json_key(object, &mut out, key);
}
out.insert(
"scope_note".to_string(),
json!("Руководитель подразделения видит Workforce и Executive Dashboard без очереди ИБ."),
);
out.insert("role_context".to_string(), role_envelope(role, "workforce"));
}
PortalRole::Security => {
for key in [
"ueba_risk",
"ueba_baseline",
"security_events_summary",
"security_correlation",
"risk_incident_candidates",
"incident_review_audit_summary",
"business_risk",
"risk_heatmap",
"markdown",
] {
copy_json_key(object, &mut out, key);
}
out.insert(
"scope_note".to_string(),
json!("Безопасность видит риски, события и кандидатов без управленческого Workforce Dashboard."),
);
out.insert("role_context".to_string(), role_envelope(role, "security"));
}
PortalRole::Forensics => {
for key in [
"risk_incident_candidates",
"incident_review_audit_summary",
"security_events_summary",
"ueba_risk",
"markdown",
] {
copy_json_key(object, &mut out, key);
}
out.insert(
"forensics".to_string(),
build_forensics_contract_payload(&report),
);
out.insert(
"scope_note".to_string(),
json!("Расследования видят карточки, timeline и экспорт материалов без управленческого Workforce Dashboard."),
);
out.insert("role_context".to_string(), role_envelope(role, "forensics"));
}
PortalRole::Admin => {}
}
out.insert("ok".to_string(), Value::Bool(true));
Value::Object(out)
}
fn copy_json_key(
source: &serde_json::Map<String, Value>,
target: &mut serde_json::Map<String, Value>,
key: &str,
) {
if let Some(value) = source.get(key) {
target.insert(key.to_string(), value.clone());
}
}
fn build_role_api_payload(report: Value, role: PortalRole, scope: &str) -> Value {
let mut payload = role_filtered_report(report, role);
if let Some(object) = payload.as_object_mut() {
object.insert("role_context".to_string(), role_envelope(role, scope));
}
payload
}
fn build_ueba_api_payload(report: &Value, role: PortalRole) -> Value {
json!({
"ok": true,
"role_context": role_envelope(role, "ueba"),
"score": report.pointer("/ueba_risk/score").cloned().unwrap_or(Value::Null),
"severity": report.pointer("/ueba_risk/level").cloned().unwrap_or_else(|| json!("normal")),
"status": report.pointer("/ueba_risk/status").cloned().unwrap_or_else(|| json!("OK")),
"score_components": report.pointer("/ueba_risk/score_components").cloned().unwrap_or_else(|| json!({
"activity_anomaly": 0,
"time_anomaly": 0,
"application_anomaly": 0,
"network_anomaly": 0,
"history_anomaly": 0,
})),
"reason_codes": report.pointer("/ueba_risk/reason_codes").cloned().or_else(|| {
report
.pointer("/ueba_risk/reasons")
.and_then(Value::as_array)
.map(|items| {
Value::Array(
items
.iter()
.filter_map(|item| item.get("code").and_then(Value::as_str).map(Value::from))
.collect::<Vec<_>>(),
)
})
}).unwrap_or_else(|| json!([])),
"explanation": report.pointer("/ueba_risk/human_explanation")
.or_else(|| report.pointer("/ueba_risk/summary"))
.cloned()
.unwrap_or_else(|| json!("Оценка риска по правилам v1.")),
"model": {
"version": "ueba-score-v1",
"type": "rule_based",
"formula": "activity anomaly + time anomaly + application anomaly + network anomaly + history anomaly",
"ml_used": false,
"llm_used": false
},
"risk": report.get("ueba_risk").cloned().unwrap_or_else(|| json!({})),
})
}
fn build_forensics_contract_payload(report: &Value) -> Value {
let candidates = report
.get("risk_incident_candidates")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let investigations = candidates
.iter()
.take(20)
.map(|candidate| {
let id = candidate
.get("id")
.and_then(Value::as_str)
.unwrap_or("candidate-unknown");
let department = candidate
.get("department")
.and_then(Value::as_str)
.unwrap_or(DEFAULT_DEPARTMENT_LABEL);
let host = candidate
.get("hostname")
.and_then(Value::as_str)
.unwrap_or("host-demo");
json!({
"investigation_id": format!("case-{id}"),
"candidate_id": id,
"title": format!("Проверка кандидата {id}"),
"status": candidate.pointer("/incident_review/status").and_then(Value::as_str).unwrap_or("NEW"),
"department": department,
"risk_level": candidate.get("risk_level").cloned().unwrap_or_else(|| json!("UNKNOWN")),
"summary": candidate.get("reason").cloned().unwrap_or_else(|| json!("требуется проверка")),
"timeline": forensics_timeline_for_candidate(candidate),
"links": {
"markdown": format!("/portal/api/investigation-pack/{id}?format=markdown"),
"json": format!("/portal/api/investigation-pack/{id}")
},
"entities": {
"user": candidate.get("owner").cloned().unwrap_or_else(|| json!("employee-demo")),
"host": host,
"app": "activity-source",
"network_event": "not_available"
}
})
})
.collect::<Vec<_>>();
json!({
"contract_version": "forensics-v1",
"investigations": investigations,
"timeline_schema": ["timestamp", "kind", "entity", "summary", "source"],
"demo_privacy": "В demo-режиме использовать только обезличенные user/host/app/network identifiers.",
})
}
fn forensics_timeline_for_candidate(candidate: &Value) -> Vec<Value> {
let first_seen = candidate
.get("first_seen_utc")
.and_then(Value::as_str)
.unwrap_or("2026-06-01T09:00:00Z");
let last_seen = candidate
.get("last_seen_utc")
.and_then(Value::as_str)
.unwrap_or(first_seen);
let id = candidate
.get("id")
.and_then(Value::as_str)
.unwrap_or("candidate-unknown");
vec![
json!({
"timestamp": first_seen,
"kind": "candidate_created",
"entity": id,
"summary": candidate.get("reason").cloned().unwrap_or_else(|| json!("кандидат требует проверки")),
"source": "risk_rules"
}),
json!({
"timestamp": last_seen,
"kind": "evidence_snapshot",
"entity": candidate.get("hostname").and_then(Value::as_str).unwrap_or("host-demo"),
"summary": "Связка user / host / app / network event подготовлена для ручного расследования.",
"source": "portal_contract"
}),
]
}
#[derive(Clone, Debug, Serialize)]
struct PfsenseFirewallEvent {
timestamp: String,
source_host: String,
destination: String,
action: String,
rule_id: String,
protocol: String,
}
#[derive(Clone, Debug, Serialize)]
struct PfsenseVpnEvent {
timestamp: String,
source_host: String,
user_ref: String,
action: String,
tunnel: String,
}
#[derive(Clone, Debug, Serialize)]
struct PfsenseTopDestination {
destination: String,
bytes: u64,
connections: u64,
}
fn build_pfsense_readiness_payload(role: PortalRole) -> Value {
let firewall_events = vec![
PfsenseFirewallEvent {
timestamp: "2026-06-01T10:00:00Z".to_string(),
source_host: "host-demo-01".to_string(),
destination: "203.0.113.10:443".to_string(),
action: "pass".to_string(),
rule_id: "demo-fw-allow-web".to_string(),
protocol: "tcp".to_string(),
},
PfsenseFirewallEvent {
timestamp: "2026-06-01T10:05:00Z".to_string(),
source_host: "host-demo-02".to_string(),
destination: "198.51.100.25:22".to_string(),
action: "block".to_string(),
rule_id: "demo-fw-block-admin".to_string(),
protocol: "tcp".to_string(),
},
];
let vpn_events = vec![PfsenseVpnEvent {
timestamp: "2026-06-01T08:30:00Z".to_string(),
source_host: "198.51.100.77".to_string(),
user_ref: "employee-demo-001".to_string(),
action: "connect".to_string(),
tunnel: "vpn-demo".to_string(),
}];
let top_destinations = vec![
PfsenseTopDestination {
destination: "203.0.113.10".to_string(),
bytes: 42_000,
connections: 12,
},
PfsenseTopDestination {
destination: "198.51.100.25".to_string(),
bytes: 8_000,
connections: 3,
},
];
json!({
"ok": true,
"role_context": role_envelope(role, "pfsense"),
"contract_version": "pfsense-readiness-v1",
"status": "contract_only",
"siem": false,
"ingestion_available": false,
"ingestion_note": "Реальный ingestion не заявлен: подготовлены только контракт, fixtures и API-заготовка.",
"schemas": {
"firewall_event": ["timestamp", "source_host", "destination", "action", "rule_id", "protocol"],
"vpn_event": ["timestamp", "source_host", "user_ref", "action", "tunnel"],
"traffic_summary": ["timestamp", "source_host", "destination", "bytes", "connections"],
"top_destination": ["destination", "bytes", "connections"]
},
"firewall_events": firewall_events,
"vpn_events": vpn_events,
"traffic_summary": {
"timestamp": "2026-06-01T10:10:00Z",
"source_host": "host-demo-01",
"destination": "203.0.113.10",
"action": "summary",
"bytes": 42000,
"connections": 12
},
"top_destinations": top_destinations,
"demo_data_policy": "Используются только RFC 5737 documentation IP ranges и обезличенные identifiers.",
})
}
fn workforce_rollup_items(snapshot: &Snapshot, key: &str) -> Vec<Value> {
snapshot
.worktime_management
@@ -5781,6 +6329,12 @@ fn build_ueba_risk(
);
}
let confidence = ueba_confidence(metrics, workforce_policy, snapshot, &policy);
let risk_sources = risk_sources(&reasons);
let score = score.min(policy.score_cap.max(1));
let (level, status) = ueba_risk_level(score);
let score_components = ueba_score_components(&reasons, score);
let reason_codes = ueba_reason_codes(&reasons);
let calculated_from = ueba_calculated_from(
metrics,
workforce_policy,
@@ -5789,16 +6343,17 @@ fn build_ueba_risk(
policy_configured,
policy_error.as_deref(),
);
let confidence = ueba_confidence(metrics, workforce_policy, snapshot, &policy);
let risk_sources = risk_sources(&reasons);
let score = score.min(policy.score_cap.max(1));
let (level, status) = ueba_risk_level(score);
json!({
"score": score,
"level": level,
"severity": level,
"status": status,
"summary": format!("{} risk, {} reason(s)", level, reasons.len()),
"formula": format!("sum(reason_points) capped at {}", policy.score_cap.max(1)),
"summary": format!("Уровень риска: {level}; факторов: {}", reasons.len()),
"human_explanation": ueba_human_explanation(level, &score_components, reasons.len()),
"formula": "activity anomaly + time anomaly + application anomaly + network anomaly + history anomaly",
"score_cap": policy.score_cap.max(1),
"score_components": score_components,
"reason_codes": reason_codes,
"confidence": confidence,
"risk_sources": risk_sources,
"baseline_status": ueba_baseline
@@ -5842,8 +6397,119 @@ fn push_risk_reason(
}));
}
fn ueba_reason_codes(reasons: &[Value]) -> Vec<String> {
reasons
.iter()
.filter_map(|reason| reason.get("code").and_then(Value::as_str))
.map(ToString::to_string)
.collect()
}
fn ueba_component_for_reason(code: &str, source: &str) -> &'static str {
if matches!(code, "night_activity" | "weekend_activity") {
"time_anomaly"
} else if matches!(
code,
"dlp_fail"
| "dlp_warn"
| "application_classification_gap"
| "application_classification_gap_large"
) {
"application_anomaly"
} else if matches!(code, "open_incidents" | "baseline_deviation") {
"history_anomaly"
} else if source.contains("network")
|| source.contains("pfsense")
|| source.contains("firewall")
|| source.contains("vpn")
|| code.contains("network")
|| code.contains("firewall")
|| code.contains("vpn")
{
"network_anomaly"
} else {
"activity_anomaly"
}
}
fn ueba_score_components(reasons: &[Value], target_score: u64) -> BTreeMap<String, u64> {
let keys = [
"activity_anomaly",
"time_anomaly",
"application_anomaly",
"network_anomaly",
"history_anomaly",
];
let mut raw = keys
.iter()
.map(|key| ((*key).to_string(), 0_u64))
.collect::<BTreeMap<_, _>>();
for reason in reasons {
let code = reason
.get("code")
.and_then(Value::as_str)
.unwrap_or_default();
let source = reason
.get("source")
.and_then(Value::as_str)
.unwrap_or_default();
let points = reason.get("points").and_then(Value::as_u64).unwrap_or(0);
let component = ueba_component_for_reason(code, source).to_string();
*raw.entry(component).or_insert(0) += points;
}
let raw_total = raw.values().sum::<u64>();
if raw_total == 0 || target_score >= raw_total {
return raw;
}
let mut scaled = raw
.iter()
.map(|(key, value)| {
let product = value.saturating_mul(target_score);
let base = product / raw_total;
let remainder = product % raw_total;
(key.clone(), base, remainder)
})
.collect::<Vec<_>>();
let mut assigned = scaled.iter().map(|(_, base, _)| *base).sum::<u64>();
scaled.sort_by(|left, right| right.2.cmp(&left.2).then_with(|| left.0.cmp(&right.0)));
for (_, base, _) in scaled.iter_mut() {
if assigned >= target_score {
break;
}
*base += 1;
assigned += 1;
}
scaled
.into_iter()
.map(|(key, value, _)| (key, value))
.collect::<BTreeMap<_, _>>()
}
fn ueba_human_explanation(
level: &str,
components: &BTreeMap<String, u64>,
reason_count: usize,
) -> String {
let top_component = components
.iter()
.max_by_key(|(_, points)| *points)
.filter(|(_, points)| **points > 0)
.map(|(component, points)| format!("{component}: {points}"));
match top_component {
Some(component) => format!(
"UEBA v1 по правилам: уровень {level}, факторов {reason_count}, основной вклад {component}."
),
None => format!("UEBA v1 по правилам: уровень {level}, значимых факторов нет."),
}
}
fn ueba_risk_level(score: u64) -> (&'static str, &'static str) {
if score >= 70 {
if score >= 85 {
("critical", "FAIL")
} else if score >= 70 {
("high", "FAIL")
} else if score >= 40 {
("medium", "WARN")
@@ -9323,6 +9989,12 @@ mod tests {
for path in [
"/contracts",
"/reports",
"/executive",
"/workforce",
"/security",
"/forensics",
"/ueba",
"/pfsense",
"/incidents",
"/cases",
"/readiness/latest",
@@ -9336,6 +10008,9 @@ mod tests {
for required_type in [
"ContractIndex",
"ReportsResponse",
"RoleContext",
"UebaResponse",
"PfsenseReadinessResponse",
"CaseListResponse",
"IncidentReviewRequest",
"export interface DetMirPortalApi",
@@ -9368,6 +10043,88 @@ mod tests {
);
}
#[test]
fn portal_roles_parse_and_enforce_scopes() {
assert_eq!(PortalRole::parse("executive"), Some(PortalRole::Executive));
assert_eq!(PortalRole::parse("operations"), Some(PortalRole::Admin));
assert!(PortalRole::Executive.can_access("workforce"));
assert!(!PortalRole::Executive.can_access("security"));
assert!(PortalRole::Security.can_access("incidents"));
assert!(!PortalRole::Security.can_access("workforce"));
assert!(PortalRole::Forensics.can_access("forensics"));
assert!(PortalRole::Admin.can_access("pfsense"));
}
#[test]
fn role_filtered_reports_do_not_cross_default_scopes() {
let report = json!({
"generated_at_utc": "2026-06-06T00:00:00Z",
"headline": "demo",
"executive_dashboard": {"summary": {"main_risk": "demo"}},
"workforce": {"department_comparison": []},
"workforce_policy": {"configured": false},
"security_events_summary": {"events_24h": 1},
"security_correlation": [],
"risk_incident_candidates": [{"id": "candidate-demo"}],
"ueba_risk": {"score": 10, "level": "low", "status": "WARN", "reasons": [{"code": "activity_anomaly"}]},
"incident_review_audit_summary": {"total_changes": 0}
});
let executive = role_filtered_report(report.clone(), PortalRole::Executive);
assert!(executive.get("workforce").is_some());
assert!(executive.get("executive_dashboard").is_some());
assert!(executive.get("risk_incident_candidates").is_none());
assert!(executive.get("security_correlation").is_none());
let security = role_filtered_report(report.clone(), PortalRole::Security);
assert!(security.get("ueba_risk").is_some());
assert!(security.get("risk_incident_candidates").is_some());
assert!(security.get("workforce").is_none());
assert!(security.get("workforce_policy").is_none());
let forensics = role_filtered_report(report, PortalRole::Forensics);
assert!(forensics.get("forensics").is_some());
assert!(forensics.get("workforce").is_none());
}
#[test]
fn ueba_and_pfsense_contracts_are_stable_demo_safe() {
let report = json!({
"ueba_risk": {
"score": 55,
"level": "medium",
"status": "WARN",
"summary": "medium risk, 1 reason(s)",
"score_components": {
"activity_anomaly": 15,
"time_anomaly": 0,
"application_anomaly": 20,
"network_anomaly": 0,
"history_anomaly": 20
},
"reasons": [{"code": "activity_anomaly"}]
}
});
let ueba = build_ueba_api_payload(&report, PortalRole::Security);
assert_eq!(ueba["score"], 55);
assert_eq!(ueba["severity"], "medium");
assert_eq!(ueba["score_components"]["activity_anomaly"], 15);
assert_eq!(ueba["score_components"]["application_anomaly"], 20);
assert_eq!(ueba["reason_codes"][0], "activity_anomaly");
assert_eq!(ueba["model"]["ml_used"], false);
assert_eq!(ueba["model"]["llm_used"], false);
let pfsense = build_pfsense_readiness_payload(PortalRole::Security);
assert_eq!(pfsense["status"], "contract_only");
assert_eq!(pfsense["siem"], false);
assert_eq!(pfsense["ingestion_available"], false);
let text = pfsense.to_string();
assert!(text.contains("203.0.113."));
assert!(text.contains("198.51.100."));
assert!(!text.contains("10.10."));
assert!(!text.contains("192.168."));
}
#[test]
fn links_are_gateway_relative() {
let links = links();
+105 -7
View File
@@ -25,6 +25,13 @@ const VIEW_MODES = {
description: "Главный вывод, сводка руководителя, риски подразделений и карта рисков.",
stage: "Формирование главного вывода",
},
manager: {
label: "Менеджер",
title: "Роль менеджера",
heading: "Представление менеджера",
description: "Workforce, сравнение подразделений, ответственные, тренды и отчет.",
stage: "Расчет Workforce и подразделений",
},
security: {
label: "Безопасность",
title: "Роль безопасности",
@@ -32,6 +39,20 @@ const VIEW_MODES = {
description: "Очередь проверки, расследования, аудит решений и пакеты расследований.",
stage: "Подготовка разделов проверки и расследований",
},
forensics: {
label: "Расследования",
title: "Роль расследований",
heading: "Представление расследований",
description: "Карточки расследований, timeline, связка user / host / app / network event и экспорт.",
stage: "Подготовка timeline расследований",
},
admin: {
label: "Администратор",
title: "Роль администратора",
heading: "Представление администратора",
description: "Настройки, качество данных, источники и эксплуатационные ошибки.",
stage: "Проверка настроек и источников",
},
operations: {
label: "Эксплуатация",
title: "Роль эксплуатации",
@@ -44,7 +65,7 @@ const VIEW_MODES = {
function initialViewMode() {
try {
const stored = window.localStorage?.getItem("detmir.portal.viewMode");
return ["executive", "security", "operations"].includes(stored) ? stored : "executive";
return ["executive", "manager", "security", "forensics", "admin", "operations"].includes(stored) ? stored : "executive";
} catch {
return "executive";
}
@@ -55,8 +76,27 @@ function apiBase() {
return path.startsWith("/portal") ? "/portal/api" : "/api";
}
function apiRole() {
if (state.tab === "employees" || state.tab === "departments") return "manager";
if (state.tab === "owner" || state.tab === "perimeter") return "security";
if (state.tab === "incidents") return "forensics";
if (state.tab === "settings") return "admin";
const mode = currentViewMode();
if (mode === "operations") return "admin";
return ["executive", "manager", "security", "forensics", "admin"].includes(mode) ? mode : "executive";
}
function roleHeaders() {
return { "X-AWatch-Role": apiRole() };
}
function withRole(path, role = apiRole()) {
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}role=${encodeURIComponent(role)}`;
}
async function loadJson(path) {
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store" });
const response = await fetch(`${apiBase()}${path}`, { cache: "no-store", headers: roleHeaders() });
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
return response.json();
}
@@ -64,7 +104,7 @@ async function loadJson(path) {
async function postJson(path, payload) {
const response = await fetch(`${apiBase()}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...roleHeaders() },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
@@ -1202,7 +1242,10 @@ function renderRoleViewSummary(meta) {
}
function renderOperatorRoleContent(mode, data, report, extras = {}) {
if (mode === "manager") return renderManagerView(report);
if (mode === "security") return renderSecurityView(data, report, extras);
if (mode === "forensics") return renderForensicsView(data, report, extras);
if (mode === "admin") return renderOperationsView(data, report);
if (mode === "operations") return renderOperationsView(data, report);
return renderExecutiveView(report, extras.incidents || []);
}
@@ -1234,6 +1277,30 @@ function renderSecurityView(data, report, extras = {}) {
`;
}
function renderManagerView(report) {
return `
${renderExecutiveDashboard(report)}
${renderDepartmentRanking(report)}
${renderDepartmentHeatMap(report)}
${renderOverviewAnalytics(report)}
<section class="dashboard-band">
<div class="band-head"><h3>Markdown-отчет</h3><span class="muted">экспорт управленческого среза Workforce</span></div>
<pre class="markdown-preview">${ui((report?.markdown || "").slice(0, 2000))}</pre>
</section>
`;
}
function renderForensicsView(data, report, extras = {}) {
const cases = Array.isArray(extras.cases?.cases) ? extras.cases.cases : [];
return `
${renderRiskIncidentCandidates(report?.risk_incident_candidates)}
${renderCases(cases)}
${renderInvestigationTimeline(report)}
${renderInvestigationPacks(report?.risk_incident_candidates)}
${renderIncidentReviewAuditSummary(report)}
`;
}
function renderOperationsView(data, report) {
return `
${renderAgentCoverageSla(report?.agent_coverage_sla)}
@@ -1344,7 +1411,7 @@ function renderInvestigationPacks(candidates) {
<div class="row compact-row">
<strong>${ui(item.department || "Без подразделения")}</strong>
<span class="muted">${ui(item.reason || "требуется проверка")} · ${ui(item.hostname || "-")}</span>
<a class="small-button" href="${apiBase()}/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown" download>Скачать</a>
<a class="small-button" href="${apiBase()}${withRole(`/investigation-pack/${encodeURIComponent(item.id || "")}?format=markdown`, "forensics")}" download>Скачать</a>
</div>
`).join("") : `
<div class="row compact-row">
@@ -1357,6 +1424,37 @@ function renderInvestigationPacks(candidates) {
`;
}
function renderInvestigationTimeline(report) {
const investigations = Array.isArray(report?.forensics?.investigations) ? report.forensics.investigations : [];
const timelines = investigations.flatMap(item => Array.isArray(item.timeline)
? item.timeline.map(event => ({ ...event, investigation_id: item.investigation_id }))
: []);
return `
<section class="card investigation-timeline-card">
<div class="section-head">
<div>
<h3>Timeline событий</h3>
<p class="muted">Связка user / host / app / network event для ручного расследования.</p>
</div>
<span class="badge ${statusClass(timelines.length ? "INFO" : "UNKNOWN")}">${timelines.length}</span>
</div>
<div class="list compact-list">${timelines.length ? timelines.slice(0, 20).map(event => `
<div class="row compact-row">
<strong>${ui(event.timestamp || "-")}</strong>
<span class="muted">${ui(event.kind || "event")} · ${ui(event.entity || "-")} · ${ui(event.summary || "")}</span>
<span class="badge status-ok">${ui(event.source || "portal")}</span>
</div>
`).join("") : `
<div class="row compact-row">
<strong>Timeline пуст</strong>
<span class="muted">Нет кандидатов для расследования в текущем срезе.</span>
<span class="badge status-ok">в норме</span>
</div>
`}</div>
</section>
`;
}
function renderOverviewAnalytics(report) {
const workforce = findSection(report, "Работа");
const insights = findSection(report, "Выводы по активности") || findSection(report, "Выводы Workforce");
@@ -1885,7 +1983,7 @@ function renderCases(cases) {
function renderCaseActions(item) {
const id = item?.case_id || "";
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown`;
const packUrl = `/portal/api/cases/${encodeURIComponent(id)}?format=markdown&role=forensics`;
return `
<div class="button-row compact-actions">
<button class="small-button" data-case-status="IN_PROGRESS" data-case-id="${escapeHtml(id)}">В работу</button>
@@ -2652,7 +2750,7 @@ function renderCandidateReviewActions(item) {
["FALSE_POSITIVE", "Ложный"],
["POSTPONED", "Отложить"],
];
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown`;
const packUrl = `/portal/api/investigation-pack/${encodeURIComponent(id)}?format=markdown&role=forensics`;
const createCase = reviewStatus === "CONFIRMED"
? `<button class="small-button investigation-pack-button primary" data-create-case="true" data-candidate-id="${escapeHtml(id)}">Создать дело</button>`
: "";
@@ -2843,7 +2941,7 @@ async function loadCurrentTab() {
const data = await loadJson("/operator");
state.operatorData = data;
state.reports = await loadJson("/reports").catch(() => state.reports);
if (currentViewMode() === "security") {
if (currentViewMode() === "security" || currentViewMode() === "forensics") {
state.cases = await loadJson("/cases").catch(error => ({ ok: false, error: error.message, cases: [] }));
}
updateFilters(state.reports);
@@ -50,10 +50,12 @@
</label>
</div>
<div class="view-switch" aria-label="Ролевое представление">
<span>Представление</span>
<span>Роль</span>
<button class="view-button is-active" data-view-mode="executive" type="button">Руководитель</button>
<button class="view-button" data-view-mode="manager" type="button">Менеджер</button>
<button class="view-button" data-view-mode="security" type="button">Безопасность</button>
<button class="view-button" data-view-mode="operations" type="button">Эксплуатация</button>
<button class="view-button" data-view-mode="forensics" type="button">Расследования</button>
<button class="view-button" data-view-mode="admin" type="button">Админ</button>
</div>
<div class="header-actions">
<button class="small-button primary" data-open-reports="true">Сформировать отчет</button>