diff --git a/adk-rust/crates/detmir-dpd-portal/src/main.rs b/adk-rust/crates/detmir-dpd-portal/src/main.rs index 72db12c..02a82e7 100644 --- a/adk-rust/crates/detmir-dpd-portal/src/main.rs +++ b/adk-rust/crates/detmir-dpd-portal/src/main.rs @@ -3,7 +3,9 @@ use std::time::Duration; use anyhow::{Context, Result, anyhow}; use clap::Parser; use reqwest::blocking::Client; -use reqwest::header::{ACCEPT, CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderValue}; +use reqwest::header::{ + CONNECTION, CONTENT_DISPOSITION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, LOCATION, +}; use serde_json::json; use tiny_http::{Header, Request, Response, Server, StatusCode}; @@ -12,7 +14,7 @@ const APP_CSS: &str = include_str!("static/app.css"); const APP_JS: &str = include_str!("static/app.js"); #[derive(Clone, Debug, Parser)] -#[command(about = "DetMir DPD/Dioxus pilot portal")] +#[command(about = "DetMir DPD parallel portal gateway")] struct Cli { #[arg(long, default_value = "127.0.0.1:8722", env = "DETMIR_DPD_BIND")] bind: String, @@ -91,8 +93,7 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> { fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()> { let method = reqwest::Method::from_bytes(request.method().as_str().as_bytes()) .map_err(|err| anyhow!("unsupported method {}: {err}", request.method()))?; - let content_type = request_header(&request, "Content-Type"); - let accept = request_header(&request, "Accept"); + let forwarded_headers = forwarded_request_headers(&request)?; let mut body = Vec::new(); request .as_reader() @@ -110,41 +111,93 @@ fn proxy_to_upstream(mut request: Request, args: &Cli, path: &str) -> Result<()> .request(method, &url) .header(CONNECTION, HeaderValue::from_static("close")) .body(body); - if let Some(value) = content_type { - upstream = upstream.header(CONTENT_TYPE, value); - } - if let Some(value) = accept { - upstream = upstream.header(ACCEPT, value); + for (name, value) in forwarded_headers { + upstream = upstream.header(name, value); } let upstream_response = upstream .send() .with_context(|| format!("proxy upstream {url}"))?; let status = StatusCode(upstream_response.status().as_u16()); - let content_type = upstream_response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string); - let content_disposition = upstream_response - .headers() - .get(CONTENT_DISPOSITION) - .and_then(|value| value.to_str().ok()) - .map(ToString::to_string); + let response_headers = mirrored_response_headers(upstream_response.headers())?; let mut bytes = upstream_response .bytes() .context("upstream response body")? .to_vec(); - rewrite_mirrored_body(&mut bytes, content_type.as_deref()); - respond_bytes(request, status, bytes, content_type, content_disposition) + let content_type = response_headers + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("Content-Type")) + .map(|(_, value)| value.as_str()); + rewrite_mirrored_body(&mut bytes, content_type); + respond_bytes(request, status, bytes, response_headers) } -fn request_header(request: &Request, name: &'static str) -> Option { - request - .headers() - .iter() - .find(|header| header.field.equiv(name)) - .map(|header| header.value.as_str().to_string()) +fn forwarded_request_headers(request: &Request) -> Result> { + let mut headers = Vec::new(); + for header in request.headers() { + let name = header.field.as_str(); + let lower = name.to_string().to_ascii_lowercase(); + if is_hop_by_hop_header(&lower) { + continue; + } + if should_forward_header(&lower) { + let name = HeaderName::from_bytes(name.as_bytes()) + .with_context(|| format!("invalid request header name {name}"))?; + headers.push((name, header.value.as_str().to_string())); + } + } + Ok(headers) +} + +fn should_forward_header(lower_name: &str) -> bool { + matches!( + lower_name, + "accept" + | "accept-language" + | "authorization" + | "content-type" + | "cookie" + | "origin" + | "referer" + | "user-agent" + | "x-forwarded-for" + | "x-forwarded-host" + | "x-forwarded-proto" + | "x-gateway-user" + | "x-real-ip" + | "x-remote-user" + ) +} + +fn is_hop_by_hop_header(lower_name: &str) -> bool { + matches!( + lower_name, + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn mirrored_response_headers(headers: &HeaderMap) -> Result> { + let mut out = Vec::new(); + for name in [CONTENT_TYPE, CONTENT_DISPOSITION, LOCATION] { + if let Some(value) = headers.get(&name) { + let mut value = value + .to_str() + .with_context(|| format!("invalid upstream {name} header"))? + .to_string(); + if name == LOCATION { + value = rewrite_mirrored_text(&value); + } + out.push((name.as_str().to_string(), value)); + } + } + Ok(out) } fn normalize_path_with_query(url: &str) -> String { @@ -221,8 +274,7 @@ fn respond_bytes( request: Request, status: StatusCode, body: Vec, - content_type: Option, - content_disposition: Option, + response_headers: Vec<(String, String)>, ) -> Result<()> { let mut response = Response::from_data(body) .with_status_code(status) @@ -230,16 +282,10 @@ fn respond_bytes( Header::from_bytes("Cache-Control", "no-store") .map_err(|_| anyhow!("invalid Cache-Control header"))?, ); - if let Some(value) = content_type { + for (name, value) in response_headers { response = response.with_header( - Header::from_bytes("Content-Type", value) - .map_err(|_| anyhow!("invalid upstream Content-Type header"))?, - ); - } - if let Some(value) = content_disposition { - response = response.with_header( - Header::from_bytes("Content-Disposition", value) - .map_err(|_| anyhow!("invalid upstream Content-Disposition header"))?, + Header::from_bytes(name, value) + .map_err(|_| anyhow!("invalid mirrored response header"))?, ); } request.respond(response)?; @@ -259,7 +305,11 @@ fn rewrite_mirrored_body(body: &mut Vec, content_type: Option<&str>) { if !text.contains("/portal") { return; } - *body = text.replace("/portal", "/dpd").into_bytes(); + *body = rewrite_mirrored_text(text).into_bytes(); +} + +fn rewrite_mirrored_text(text: &str) -> String { + text.replace("/portal", "/dpd") } #[cfg(test)] @@ -293,4 +343,24 @@ mod tests { assert!(text.contains("/dpd/api/cases/c1")); assert!(!text.contains("/portal/api")); } + + #[test] + fn forwards_operator_identity_headers_but_not_hop_by_hop_headers() { + assert!(should_forward_header("x-remote-user")); + assert!(should_forward_header("x-gateway-user")); + assert!(should_forward_header("authorization")); + assert!(should_forward_header("cookie")); + assert!(!should_forward_header("x-debug-private")); + assert!(is_hop_by_hop_header("connection")); + assert!(is_hop_by_hop_header("transfer-encoding")); + assert!(!is_hop_by_hop_header("x-remote-user")); + } + + #[test] + fn rewrites_portal_locations_for_dpd_mirror() { + assert_eq!( + rewrite_mirrored_text("/portal/reports?format=markdown"), + "/dpd/reports?format=markdown" + ); + } } diff --git a/adk-rust/crates/detmir-dpd-portal/src/static/index.html b/adk-rust/crates/detmir-dpd-portal/src/static/index.html index 490692a..7374619 100644 --- a/adk-rust/crates/detmir-dpd-portal/src/static/index.html +++ b/adk-rust/crates/detmir-dpd-portal/src/static/index.html @@ -12,7 +12,7 @@

DetMir DPD

Пилотный портал

-

Параллельный read-only интерфейс для оценки будущего Dioxus-подхода.

+

Параллельный read-only интерфейс для оценки будущего React/Tauri-подхода.

diff --git a/adk-rust/crates/detmir-portal/src/contracts/openapi.json b/adk-rust/crates/detmir-portal/src/contracts/openapi.json new file mode 100644 index 0000000..40ee54b --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/contracts/openapi.json @@ -0,0 +1,584 @@ +{ + "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." + }, + "servers": [ + { + "url": "/api", + "description": "Gateway-relative API base" + } + ], + "tags": [ + { "name": "contracts" }, + { "name": "portal" }, + { "name": "reports" }, + { "name": "incidents" }, + { "name": "cases" }, + { "name": "readiness" }, + { "name": "telemetry" } + ], + "paths": { + "/contracts": { + "get": { + "tags": ["contracts"], + "summary": "Contract index", + "responses": { + "200": { + "description": "Contract index", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ContractIndex" } + } + } + } + } + } + }, + "/contracts/openapi.json": { + "get": { + "tags": ["contracts"], + "summary": "OpenAPI document", + "responses": { + "200": { + "description": "OpenAPI 3.1 document", + "content": { + "application/json": { + "schema": { "type": "object", "additionalProperties": true } + } + } + } + } + } + }, + "/contracts/typescript.d.ts": { + "get": { + "tags": ["contracts"], + "summary": "TypeScript declarations", + "responses": { + "200": { + "description": "TypeScript declaration file", + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": ["portal"], + "summary": "Light service health", + "responses": { + "200": { + "description": "Health payload", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/operator": { + "get": { + "tags": ["portal"], + "summary": "Operator overview payload", + "responses": { + "200": { + "description": "Operator payload used by the HTML portal", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/manager": { + "get": { + "tags": ["portal"], + "summary": "Manager payload", + "responses": { + "200": { + "description": "Manager payload", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/owner": { + "get": { + "tags": ["portal"], + "summary": "Owner/security payload", + "responses": { + "200": { + "description": "Owner payload", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/reports": { + "get": { + "tags": ["reports"], + "summary": "Management report payload", + "parameters": [ + { + "name": "anonymize", + "in": "query", + "required": false, + "schema": { "type": "boolean" }, + "description": "Return anonymized values when supported" + } + ], + "responses": { + "200": { + "description": "Report payload", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ReportPayload" } + } + } + } + } + } + }, + "/workforce/policy/explain": { + "get": { + "tags": ["reports"], + "summary": "Workforce scoring policy explanation", + "parameters": [ + { + "name": "anonymize", + "in": "query", + "required": false, + "schema": { "type": "boolean" } + } + ], + "responses": { + "200": { + "description": "Policy explanation", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/incidents": { + "get": { + "tags": ["incidents"], + "summary": "Incident list and current state", + "responses": { + "200": { + "description": "Incident payload", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/incident-review": { + "post": { + "tags": ["incidents"], + "summary": "Set manual review status for an incident candidate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/IncidentReviewRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated review state", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/investigation-pack/{candidate_id}": { + "get": { + "tags": ["incidents"], + "summary": "Export investigation pack for a candidate", + "parameters": [ + { + "name": "candidate_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { "type": "string", "enum": ["json", "markdown"] } + } + ], + "responses": { + "200": { + "description": "Investigation pack", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + }, + "text/markdown": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/cases": { + "get": { + "tags": ["cases"], + "summary": "Case list", + "responses": { + "200": { + "description": "Case list", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CaseListResponse" } + } + } + } + } + }, + "post": { + "tags": ["cases"], + "summary": "Create a manual case from a confirmed candidate", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateCaseRequest" } + } + } + }, + "responses": { + "200": { + "description": "Created case", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/cases/{case_id}": { + "get": { + "tags": ["cases"], + "summary": "Case details", + "parameters": [ + { + "name": "case_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { "type": "string", "enum": ["json", "markdown"] } + } + ], + "responses": { + "200": { + "description": "Case details", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + }, + "text/markdown": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/cases/{case_id}/status": { + "post": { + "tags": ["cases"], + "summary": "Set manual case status", + "parameters": [ + { + "name": "case_id", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CaseStatusRequest" } + } + } + }, + "responses": { + "200": { + "description": "Updated case", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/dlp/evidence": { + "get": { + "tags": ["incidents"], + "summary": "DLP evidence list", + "responses": { + "200": { + "description": "Evidence list", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/readiness/latest": { + "get": { + "tags": ["readiness"], + "summary": "Latest readiness bundle status", + "responses": { + "200": { + "description": "Readiness status", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/readiness/bundle": { + "get": { + "tags": ["readiness"], + "summary": "Readiness bundle artifact list", + "responses": { + "200": { + "description": "Readiness bundle", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/readiness/verify": { + "get": { + "tags": ["readiness"], + "summary": "Verify readiness checksums and signature", + "responses": { + "200": { + "description": "Verification result", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/links": { + "get": { + "tags": ["portal"], + "summary": "Gateway-relative portal links", + "responses": { + "200": { + "description": "Link map", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + } + } + } + }, + "/telemetry": { + "post": { + "tags": ["telemetry"], + "summary": "Agent telemetry ingest", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + }, + "responses": { + "200": { + "description": "Accepted telemetry", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/JsonObject" } + } + } + }, + "401": { + "description": "Missing or invalid telemetry API key" + } + } + } + } + }, + "components": { + "schemas": { + "JsonObject": { + "type": "object", + "additionalProperties": true + }, + "ContractIndex": { + "type": "object", + "required": ["ok", "contract_version", "api_base", "artifacts", "stable_endpoints"], + "properties": { + "ok": { "type": "boolean" }, + "contract_version": { "type": "string" }, + "generated_by": { "type": "string" }, + "api_base": { "type": "string" }, + "compatibility": { "$ref": "#/components/schemas/JsonObject" }, + "targets": { "type": "array", "items": { "type": "string" } }, + "artifacts": { "$ref": "#/components/schemas/JsonObject" }, + "stable_endpoints": { + "type": "array", + "items": { "$ref": "#/components/schemas/EndpointDescriptor" } + } + }, + "additionalProperties": true + }, + "EndpointDescriptor": { + "type": "object", + "required": ["method", "path"], + "properties": { + "method": { "type": "string" }, + "path": { "type": "string" }, + "purpose": { "type": "string" } + }, + "additionalProperties": true + }, + "ReportPayload": { + "type": "object", + "required": ["ok"], + "properties": { + "ok": { "type": "boolean" }, + "generated_at_utc": { "type": "string" }, + "executive_points": { + "type": "array", + "items": { "type": "string" } + }, + "executive_dashboard": { "$ref": "#/components/schemas/JsonObject" }, + "risk_narrative": { "$ref": "#/components/schemas/JsonObject" }, + "agent_quality": { "$ref": "#/components/schemas/JsonObject" }, + "agent_coverage_sla": { "$ref": "#/components/schemas/JsonObject" }, + "business_risk": { + "type": "array", + "items": { "$ref": "#/components/schemas/JsonObject" } + }, + "risk_incident_candidates": { + "type": "array", + "items": { "$ref": "#/components/schemas/JsonObject" } + }, + "cases": { + "type": "array", + "items": { "$ref": "#/components/schemas/JsonObject" } + } + }, + "additionalProperties": true + }, + "IncidentReviewRequest": { + "type": "object", + "required": ["candidate_id", "status"], + "properties": { + "candidate_id": { "type": "string" }, + "status": { + "type": "string", + "enum": ["NEW", "IN_REVIEW", "CONFIRMED", "FALSE_POSITIVE", "POSTPONED"] + }, + "reviewer": { "type": "string" }, + "comment": { "type": "string" } + }, + "additionalProperties": true + }, + "CreateCaseRequest": { + "type": "object", + "required": ["candidate_id"], + "properties": { + "candidate_id": { "type": "string" }, + "title": { "type": "string" }, + "owner": { "type": "string" }, + "summary": { "type": "string" }, + "decision": { "type": "string" } + }, + "additionalProperties": true + }, + "CaseStatusRequest": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": ["OPEN", "IN_PROGRESS", "RESOLVED", "REJECTED", "ARCHIVED"] + }, + "decision": { "type": "string" } + }, + "additionalProperties": true + }, + "CaseListResponse": { + "type": "object", + "required": ["ok", "cases"], + "properties": { + "ok": { "type": "boolean" }, + "cases": { + "type": "array", + "items": { "$ref": "#/components/schemas/JsonObject" } + } + }, + "additionalProperties": true + } + } + } +} diff --git a/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts new file mode 100644 index 0000000..4adfd52 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/contracts/typescript.d.ts @@ -0,0 +1,188 @@ +export type ISODateTime = string; + +export type RiskLevel = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL" | "UNKNOWN"; +export type ReviewStatus = "NEW" | "IN_REVIEW" | "CONFIRMED" | "FALSE_POSITIVE" | "POSTPONED"; +export type CaseStatus = "OPEN" | "IN_PROGRESS" | "RESOLVED" | "REJECTED" | "ARCHIVED"; + +export interface JsonObject { + [key: string]: unknown; +} + +export interface EndpointDescriptor { + method: string; + path: string; + purpose?: string; + [key: string]: unknown; +} + +export interface ContractIndex { + ok: boolean; + contract_version: string; + generated_by?: string; + api_base: "/api" | string; + compatibility?: JsonObject; + targets?: string[]; + artifacts: { + openapi: string; + typescript: string; + [key: string]: unknown; + }; + stable_endpoints: EndpointDescriptor[]; + [key: string]: unknown; +} + +export interface ExecutiveDashboard { + trust_kpi_score?: number; + agent_coverage_pct?: number; + high_risk_departments?: number; + critical_candidates?: number; + open_cases?: number; + resolved_cases_30d?: number; + forensics_readiness?: string; + [key: string]: unknown; +} + +export interface RiskNarrative { + status?: "NORMAL" | "ATTENTION" | "HIGH_RISK" | "CRITICAL" | string; + title?: string; + summary?: string; + main_reason?: string; + recommendation?: string; + supporting_layers?: JsonObject[]; + [key: string]: unknown; +} + +export interface AgentQuality { + collector_source?: string; + collector_error?: string | null; + sessions_collected_total?: number; + active_sessions_total?: number; + rdp_sessions_total?: number; + quality_status?: string; + [key: string]: unknown; +} + +export interface AgentCoverageSla { + expected_nodes?: number; + reporting_nodes_24h?: number; + stale_nodes?: number; + missing_nodes?: number; + coverage_pct?: number; + freshness_pct?: number; + sla_status?: "OK" | "WARNING" | "CRITICAL" | "UNKNOWN" | string; + [key: string]: unknown; +} + +export interface BusinessRiskItem { + department?: string; + trust_score?: number; + activity_score?: number; + trend?: string; + risk_level?: RiskLevel; + reasons?: string[]; + recommendation?: string; + problem_nodes_count?: number; + missing_nodes_count?: number; + stale_nodes_count?: number; + [key: string]: unknown; +} + +export interface IncidentCandidate { + id?: string; + department?: string; + owner?: string; + hostname?: string; + risk_level?: RiskLevel; + reason?: string; + evidence?: unknown; + first_seen_utc?: ISODateTime; + last_seen_utc?: ISODateTime; + recommendation?: string; + review?: IncidentReview; + [key: string]: unknown; +} + +export interface IncidentReview { + candidate_id: string; + status: ReviewStatus; + reviewer?: string; + comment?: string; + updated_at?: ISODateTime; + [key: string]: unknown; +} + +export interface IncidentReviewRequest { + candidate_id: string; + status: ReviewStatus; + reviewer?: string; + comment?: string; + [key: string]: unknown; +} + +export interface CaseItem { + case_id?: string; + candidate_id?: string; + title?: string; + status?: CaseStatus; + owner?: string; + created_at_utc?: ISODateTime; + updated_at_utc?: ISODateTime; + summary?: string; + decision?: string; + [key: string]: unknown; +} + +export interface CreateCaseRequest { + candidate_id: string; + title?: string; + owner?: string; + summary?: string; + decision?: string; + [key: string]: unknown; +} + +export interface CaseStatusRequest { + status: CaseStatus; + decision?: string; + [key: string]: unknown; +} + +export interface ReportsResponse { + ok: boolean; + generated_at_utc?: ISODateTime; + executive_points?: string[]; + executive_dashboard?: ExecutiveDashboard; + risk_narrative?: RiskNarrative; + agent_quality?: AgentQuality; + agent_coverage_sla?: AgentCoverageSla; + business_risk?: BusinessRiskItem[]; + risk_incident_candidates?: IncidentCandidate[]; + cases?: CaseItem[]; + [key: string]: unknown; +} + +export interface CaseListResponse { + ok: boolean; + cases: CaseItem[]; + [key: string]: unknown; +} + +export interface DetMirPortalApi { + getContracts(): Promise; + getHealth(): Promise; + getOperator(): Promise; + getManager(): Promise; + getOwner(): Promise; + getReports(options?: { anonymize?: boolean }): Promise; + getIncidents(): Promise; + getCases(): Promise; + createCase(request: CreateCaseRequest): Promise; + setCaseStatus(caseId: string, request: CaseStatusRequest): Promise; + setIncidentReview(request: IncidentReviewRequest): Promise; + getInvestigationPack(candidateId: string, options?: { format?: "json" | "markdown" }): Promise; + getDlpEvidence(): Promise; + getReadinessLatest(): Promise; + getReadinessBundle(): Promise; + verifyReadiness(): Promise; + getWorkforcePolicyExplain(options?: { anonymize?: boolean }): Promise; +} diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index f412dbc..79b91dd 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -26,6 +26,8 @@ use tiny_http::{Header, Method, Request, Response, Server, StatusCode}; const INDEX_HTML: &str = include_str!("static/index.html"); const APP_CSS: &str = include_str!("static/app.css"); const APP_JS: &str = include_str!("static/app.js"); +const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json"); +const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts"); const UEBA_BASELINE_MIN_SAMPLES: usize = 3; const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(5); const DEFAULT_DEPARTMENT_LABEL: &str = "Без подразделения"; @@ -1293,6 +1295,19 @@ fn handle_request(request: Request, args: &Cli, snapshot_cache: &SnapshotCache) "application/javascript; charset=utf-8", ), "/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"), + "/api/contracts" => respond_json(request, &api_contract_summary()), + "/api/contracts/openapi.json" => respond_text( + request, + StatusCode(200), + API_CONTRACT_OPENAPI, + "application/json; charset=utf-8", + ), + "/api/contracts/typescript.d.ts" => respond_text( + request, + StatusCode(200), + API_CONTRACT_TYPESCRIPT, + "text/plain; charset=utf-8", + ), "/api/health" => respond_json( request, &build_health(&cached_snapshot(args, snapshot_cache)), @@ -1421,6 +1436,42 @@ fn normalize_path(url: &str) -> String { } } +fn api_contract_summary() -> Value { + json!({ + "ok": true, + "contract_version": "2026-06-05.v1", + "generated_by": "detmir-portal", + "api_base": "/api", + "compatibility": { + "policy": "additive", + "existing_html_portal": "unchanged", + "unknown_fields": "clients must ignore unknown fields", + "nullable_fields": "clients must tolerate null and missing optional fields" + }, + "targets": ["current-html", "future-react", "future-tauri"], + "artifacts": { + "openapi": "/api/contracts/openapi.json", + "typescript": "/api/contracts/typescript.d.ts" + }, + "stable_endpoints": [ + {"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/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"} + ] + }) +} + fn readiness_latest(args: &Cli) -> Value { read_json_file( &args @@ -9157,6 +9208,31 @@ mod tests { assert_eq!(safe_download_stem("risk:candidate/1"), "risk_candidate_1"); } + #[test] + fn api_contract_artifacts_are_valid_and_future_ui_ready() { + let openapi: Value = + serde_json::from_str(API_CONTRACT_OPENAPI).expect("OpenAPI contract must be JSON"); + assert_eq!(openapi["openapi"], "3.1.0"); + assert!(openapi["paths"]["/reports"].is_object()); + assert!(openapi["paths"]["/incident-review"].is_object()); + assert!(openapi["paths"]["/cases"].is_object()); + assert!(API_CONTRACT_TYPESCRIPT.contains("export interface DetMirPortalApi")); + assert!(API_CONTRACT_TYPESCRIPT.contains("ReportsResponse")); + assert!(API_CONTRACT_TYPESCRIPT.contains("IncidentReviewRequest")); + + let summary = api_contract_summary(); + assert_eq!(summary["ok"], true); + assert_eq!(summary["api_base"], "/api"); + assert_eq!( + summary["artifacts"]["openapi"], + "/api/contracts/openapi.json" + ); + assert_eq!( + summary["artifacts"]["typescript"], + "/api/contracts/typescript.d.ts" + ); + } + #[test] fn links_are_gateway_relative() { let links = links(); diff --git a/docs/DPD_PORTAL_RU.md b/docs/DPD_PORTAL_RU.md index 9c7165f..8fc6a9a 100644 --- a/docs/DPD_PORTAL_RU.md +++ b/docs/DPD_PORTAL_RU.md @@ -1,13 +1,19 @@ -# DPD/Dioxus Pilot Portal +# DPD Parallel Portal -`detmir-dpd-portal` - параллельный DPD-портал DetMir. Основной режим работает -как полный mirror текущего `detmir-portal`, а экспериментальный компактный экран -оставлен отдельно как preview. +`detmir-dpd-portal` - параллельный gateway-портал DetMir. Он не заменяет +текущий `detmir-portal`, а повторяет его функциональность через отдельный +маршрут `/dpd/`. ## Назначение -Портал не заменяет текущий `detmir-portal`. Он работает рядом с ним и -проксирует все функции текущего портала через отдельный маршрут `/dpd/`: +DPD нужен для безопасной эволюции интерфейса: + +- основной HTML-портал `/portal/` остаётся стабильным; +- `/dpd/` работает как полный mirror текущего портала; +- будущий React/Tauri UI сможет использовать те же API-контракты; +- новые UI-решения проверяются без cutover и без дублирования бизнес-логики. + +DPD проксирует: - вкладки; - API; @@ -17,22 +23,27 @@ - markdown/download endpoints; - материалы проверки. -Компактный experimental preview доступен отдельно и показывает: +DPD gateway сохраняет операторский контекст для симметрии с основным порталом: +`X-Remote-User`, `X-Gateway-User`, `X-Forwarded-*`, `User-Agent`, `Referer`, +`Origin`, `Cookie` и `Authorization` передаются в upstream, hop-by-hop +заголовки не передаются. Это нужно, чтобы audit/review/case-действия через +`/dpd/` фиксировались так же, как через `/portal/`. -- главный вывод; -- события безопасности; -- полнота данных; -- риски подразделений; -- очередь проверки; -- связь рисков и активности. +## Архитектурное решение -## Почему отдельный бинарник +Выбран зрелый путь: -Dioxus 0.7 fullstack использует разделение client/server и отдельные web/server -feature flags. Для безопасного первого этапа создан параллельный Rust shell, -который не меняет боевой портал, но уже повторяет его функциональность через -mirror-proxy. Это оставляет место для постепенного переноса UI на -Dioxus-компоненты без риска разъезда данных и API. +```text +detmir-portal Rust backend + | + | stable /api/contracts + v +current HTML UI + DPD mirror + future React/Tauri UI +``` + +Не используется отдельный экспериментальный UI-фреймворк в production path. +Сначала фиксируются API-контракты, тестируется совместимость и только затем +добавляется новый frontend. ## Запуск @@ -53,11 +64,57 @@ detmir-dpd-portal \ - `/dpd/` - полный параллельный mirror текущего портала. - `/dpd/_dpd/health` - health самого DPD gateway. -- `/dpd/preview/` - компактный preview-экран для будущего Dioxus-подхода. +- `/dpd/preview/` - компактный read-only preview-экран. -## Ограничения v0.1 +## API-контракты -- DPD gateway не добавляет новые бизнес-сущности; -- без замены текущего портала; -- данные берутся из существующего портала/API; -- preview-экран остается read-only. +Контракты публикует основной `detmir-portal`, а DPD зеркалирует их: + +- `/api/contracts`; +- `/api/contracts/openapi.json`; +- `/api/contracts/typescript.d.ts`; +- `/dpd/api/contracts`; +- `/dpd/api/contracts/openapi.json`; +- `/dpd/api/contracts/typescript.d.ts`. + +Правило совместимости: изменения API должны быть additive. Клиенты React/Tauri +обязаны игнорировать неизвестные поля и корректно обрабатывать отсутствующие +optional-поля. + +## Проверка симметрии + +Минимальная проверка на сервере: + +```bash +systemctl is-active detmir-dpd-portal detmir-portal nginx --no-pager +curl -sS http://127.0.0.1:8722/_dpd/health +curl -sS -o /dev/null -w 'dpd_index=%{http_code}\n' http://127.0.0.1:8722/ +curl -sS -o /dev/null -w 'dpd_reports=%{http_code}\n' http://127.0.0.1:8722/api/reports +curl -sS -o /dev/null -w 'dpd_contracts=%{http_code}\n' http://127.0.0.1:8722/api/contracts +``` + +Браузерная проверка с ноутбука: + +```bash +ssh -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=3 \ + -N -L 18720:127.0.0.1:8720 + +detmir-dpd-portal \ + --bind 127.0.0.1:18722 \ + --upstream-base http://127.0.0.1:18720 + +DETMIR_PORTAL_SMOKE_URL=http://127.0.0.1:18722/ \ +DETMIR_PORTAL_SMOKE_TIMEOUT_MS=70000 \ +node scripts/detmir-portal-tabs-smoke.mjs +``` + +Ожидаемый результат: `ok=true`, все вкладки работают, нет JS/API ошибок, +абсолютные `/portal/...` ссылки в HTML/JS переписаны в `/dpd/...`. + +## Ограничения + +- DPD gateway не добавляет новые бизнес-сущности. +- DPD gateway не заменяет текущий портал. +- Данные берутся из существующего портала/API. +- Новый React/Tauri UI должен появляться поверх контрактов, а не через + копирование backend-логики. diff --git a/docs/PORTAL_API_CONTRACTS_RU.md b/docs/PORTAL_API_CONTRACTS_RU.md new file mode 100644 index 0000000..b68e97c --- /dev/null +++ b/docs/PORTAL_API_CONTRACTS_RU.md @@ -0,0 +1,71 @@ +# API-контракты портала DetMir + +## Цель + +Закрепить стабильный слой API для текущего HTML-портала и будущего +React/Tauri-интерфейса без переписывания backend-логики. + +Текущий HTML-портал остаётся рабочим и основным. Новый UI должен подключаться к +тем же endpoint-ам через documented contract. + +## Контрактные endpoint-ы + +Основной портал: + +- `GET /api/contracts` +- `GET /api/contracts/openapi.json` +- `GET /api/contracts/typescript.d.ts` + +DPD mirror: + +- `GET /dpd/api/contracts` +- `GET /dpd/api/contracts/openapi.json` +- `GET /dpd/api/contracts/typescript.d.ts` + +## Правила совместимости + +- Изменения API должны быть additive. +- Нельзя удалять существующие поля без отдельной migration window. +- Клиенты обязаны игнорировать неизвестные поля. +- Клиенты обязаны корректно обрабатывать отсутствующие optional-поля. +- Поля с `null` не должны ломать UI. +- Ошибки API должны отображаться пользователю на русском языке. + +## Минимальный набор для будущего React/Tauri UI + +Для первого production-grade клиента достаточно: + +- `GET /api/contracts` - discovery и версия контракта; +- `GET /api/reports` - главный управленческий payload; +- `GET /api/operator` - обзор портала; +- `GET /api/incidents` - проверки и события; +- `GET /api/cases` - расследования; +- `POST /api/incident-review` - ручное решение по кандидату; +- `POST /api/cases` - ручное создание дела; +- `GET /api/investigation-pack/{candidate_id}` - пакет расследования; +- `GET /api/readiness/latest` - готовность системы; +- `GET /api/workforce/policy/explain` - объяснение расчёта показателей. + +## Что не меняется + +- HTML-портал не удаляется. +- Маршрут `/portal/` остаётся стабильным. +- DPD `/dpd/` остаётся параллельным mirror. +- Backend-расчёты, JSON-хранилища и workflow не дублируются во frontend. + +## Проверка + +```bash +curl -sS http://127.0.0.1:8720/api/contracts | jq . +curl -sS http://127.0.0.1:8720/api/contracts/openapi.json | jq .openapi +curl -sS http://127.0.0.1:8720/api/contracts/typescript.d.ts | head +curl -sS http://127.0.0.1:8722/api/contracts | jq . +``` + +Ожидаемый результат: + +- `ok=true`; +- `contract_version` заполнен; +- OpenAPI JSON валиден; +- TypeScript declarations доступны; +- DPD отдаёт те же контрактные endpoints через mirror. diff --git a/docs/UI_ARCHITECTURE_BASELINE_RU.md b/docs/UI_ARCHITECTURE_BASELINE_RU.md new file mode 100644 index 0000000..d6d8e83 --- /dev/null +++ b/docs/UI_ARCHITECTURE_BASELINE_RU.md @@ -0,0 +1,52 @@ +# Архитектурный baseline UI AWatch-rus + +Дата фиксации: 2026-06-05 + +## Baseline + +```text +Backend/API: Rust +Agent: Rust +Current Portal: Rust server-rendered HTML + HTMX-ready static UI +Future Enterprise UI: React + TypeScript +Future Desktop Forensics: Tauri + React + Rust core +Dioxus: out of scope / не рассматривается +``` + +## Правила + +- Текущий HTML/HTMX-ready портал остаётся основным pilot/production + интерфейсом. +- JSON API является контрактным слоем для будущих UI. +- Бизнес-логика не должна зависеть от HTML. +- Будущий React/Tauri UI не должен ломать текущий портал. +- Agent и backend остаются Rust-first. +- Новые UI-фреймворки не добавлять без отдельного architecture decision. +- Dioxus не добавлять и не рассматривать. + +## Практическое следствие + +Развитие интерфейса выполняется в таком порядке: + +1. Укрепить текущий Rust web portal. +2. Зафиксировать стабильные JSON API-контракты. +3. Поддерживать DPD `/dpd/` как параллельный mirror для проверки совместимости. +4. Готовить будущий React/Tauri UI только поверх опубликованных контрактов. + +## Контрактный слой + +Основные endpoints: + +- `/api/contracts`; +- `/api/contracts/openapi.json`; +- `/api/contracts/typescript.d.ts`. + +DPD mirror endpoints: + +- `/dpd/api/contracts`; +- `/dpd/api/contracts/openapi.json`; +- `/dpd/api/contracts/typescript.d.ts`. + +Правило совместимости: изменения API должны быть additive. Клиенты обязаны +игнорировать неизвестные поля и корректно обрабатывать отсутствующие optional +поля.