refactor(portal): split hardening and kpi modules

This commit is contained in:
igor04091968
2026-06-07 14:36:42 +03:00
parent 2fb3271558
commit 0b0bab61bb
10 changed files with 1553 additions and 1300 deletions
@@ -0,0 +1,10 @@
use serde_json::{Value, json};
use crate::now;
pub(crate) fn build_healthz() -> Value {
json!({
"status": "ok",
"generated_at_utc": now(),
})
}
@@ -0,0 +1,297 @@
use std::collections::BTreeSet;
use anyhow::{Result, anyhow};
use chrono::NaiveDate;
use serde_json::{Value, json};
use tiny_http::StatusCode;
use crate::{
Cli, MAX_ALLOWED_PAGE_SIZE, MAX_ALLOWED_REPORT_DATE_RANGE_DAYS, MAX_ALLOWED_REQUEST_BODY_BYTES,
MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS, query_param,
};
#[derive(Debug)]
pub(crate) struct ApiLimitError {
pub(crate) status: StatusCode,
pub(crate) payload: Value,
}
pub(crate) fn validate_portal_config(args: &Cli) -> Result<()> {
let (host, port) = args
.bind
.rsplit_once(':')
.ok_or_else(|| anyhow!("invalid config bind: expected host:port"))?;
if host.trim().is_empty() {
return Err(anyhow!("invalid config host: value is empty"));
}
let port = port
.parse::<u16>()
.map_err(|_| anyhow!("invalid config port: expected 1..65535"))?;
if port == 0 {
return Err(anyhow!("invalid config port: expected 1..65535"));
}
if args.max_page_size == 0 || args.max_page_size > MAX_ALLOWED_PAGE_SIZE {
return Err(anyhow!(
"invalid config max_page_size: expected 1..={MAX_ALLOWED_PAGE_SIZE}"
));
}
if args.default_page_size == 0 || args.default_page_size > args.max_page_size {
return Err(anyhow!(
"invalid config default_page_size: expected 1..=max_page_size"
));
}
if args.max_report_date_range_days <= 0
|| args.max_report_date_range_days > MAX_ALLOWED_REPORT_DATE_RANGE_DAYS
{
return Err(anyhow!(
"invalid config max_report_date_range_days: expected 1..={MAX_ALLOWED_REPORT_DATE_RANGE_DAYS}"
));
}
if args.request_timeout_seconds == 0
|| args.request_timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS
{
return Err(anyhow!(
"invalid config request_timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
));
}
if args.timeout_seconds == 0 || args.timeout_seconds > MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS {
return Err(anyhow!(
"invalid config timeout_seconds: expected 1..={MAX_ALLOWED_REQUEST_TIMEOUT_SECONDS}"
));
}
if args.max_request_body_bytes < 1024
|| args.max_request_body_bytes > MAX_ALLOWED_REQUEST_BODY_BYTES
{
return Err(anyhow!(
"invalid config max_request_body_bytes: expected 1024..={MAX_ALLOWED_REQUEST_BODY_BYTES}"
));
}
if !is_safe_environment_name(&args.environment) {
return Err(anyhow!(
"invalid config environment: use 1..32 chars from A-Z, a-z, 0-9, _, -"
));
}
let modules = enabled_modules(&args.enabled_modules);
if modules.is_empty() {
return Err(anyhow!(
"invalid config enabled_modules: no modules enabled"
));
}
let allowed = [
"executive",
"workforce",
"security",
"forensics",
"admin",
"ueba",
"pfsense",
"reports",
];
for module in modules {
if !allowed.contains(&module.as_str()) {
return Err(anyhow!(
"invalid config enabled_modules: unsupported module {module}"
));
}
}
Ok(())
}
fn is_safe_environment_name(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& value.len() <= 32
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'))
}
fn enabled_modules(value: &str) -> BTreeSet<String> {
value
.split(',')
.map(|item| item.trim().to_ascii_lowercase())
.filter(|item| !item.is_empty())
.collect()
}
pub(crate) fn is_limited_api_route(path: &str) -> bool {
matches!(
path,
"/api/reports"
| "/api/executive"
| "/api/workforce"
| "/api/security"
| "/api/forensics"
| "/api/ueba"
| "/api/pfsense"
| "/api/workforce/kpi/explain"
)
}
pub(crate) fn validate_api_query_limits(
url: &str,
args: &Cli,
) -> std::result::Result<(), ApiLimitError> {
for key in ["page_size", "limit"] {
if let Some(value) = query_param(url, key) {
let parsed = value.parse::<u32>().ok();
if parsed.is_none_or(|page_size| page_size == 0 || page_size > args.max_page_size) {
return Err(api_limit_error(
StatusCode(400),
"invalid_page_size",
&format!("{key} must be between 1 and {}", args.max_page_size),
));
}
}
}
for (from_key, to_key) in [("date_from", "date_to"), ("from", "to"), ("start", "end")] {
let Some(from) = query_param(url, from_key).and_then(|value| parse_query_date(&value))
else {
continue;
};
let Some(to) = query_param(url, to_key).and_then(|value| parse_query_date(&value)) else {
continue;
};
let days = (to - from).num_days().abs() + 1;
if days > args.max_report_date_range_days {
return Err(api_limit_error(
StatusCode(400),
"report_range_too_large",
&format!(
"report date range must be <= {} days",
args.max_report_date_range_days
),
));
}
}
Ok(())
}
pub(crate) fn parse_query_date(value: &str) -> Option<NaiveDate> {
let date = value.split('T').next().unwrap_or(value);
NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()
}
fn api_limit_error(status: StatusCode, code: &str, message: &str) -> ApiLimitError {
ApiLimitError {
status,
payload: json!({
"ok": false,
"error_code": code,
"message": message,
}),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::{
DEFAULT_MAX_PAGE_SIZE, DEFAULT_MAX_REPORT_DATE_RANGE_DAYS, DEFAULT_MAX_REQUEST_BODY_BYTES,
DEFAULT_PAGE_SIZE, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_SLOW_REQUEST_LOG_MS,
};
fn test_cli(dir: &Path) -> Cli {
Cli {
bind: "127.0.0.1:8720".to_string(),
status_cmd: "true".to_string(),
check_cmd: "true".to_string(),
failed_units_cmd: "true".to_string(),
worktime_url: "http://127.0.0.1".to_string(),
one_c_url: "http://127.0.0.1".to_string(),
workforce_policy_path: dir.join("workforce-policy.json"),
ueba_policy_path: dir.join("ueba-policy.yaml"),
timeout_seconds: 1,
max_page_size: DEFAULT_MAX_PAGE_SIZE,
default_page_size: DEFAULT_PAGE_SIZE,
max_report_date_range_days: DEFAULT_MAX_REPORT_DATE_RANGE_DAYS,
request_timeout_seconds: DEFAULT_REQUEST_TIMEOUT_SECONDS,
max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
slow_request_log_ms: DEFAULT_SLOW_REQUEST_LOG_MS,
environment: "test".to_string(),
enabled_modules: "executive,workforce,security,forensics,admin".to_string(),
state_dir: dir.join("state"),
dlp_db_path: dir.join("dlp.sqlite"),
evidence_root: dir.to_path_buf(),
readiness_bundle_dir: dir.join("readiness-bundle"),
evidence_limit: 10,
evidence_max_bytes: 1024,
json_smoke: false,
evidence_only: false,
evidence_upload_token: None,
telemetry_api_key: "test-key".to_string(),
telemetry_store_path: dir.join("telemetry.jsonl"),
expected_nodes_path: dir.join("expected_nodes.json"),
security_events_backend: "disabled".to_string(),
clickhouse_url: "http://127.0.0.1:8123".to_string(),
clickhouse_database: "analytics_1c".to_string(),
clickhouse_user: "default".to_string(),
clickhouse_password: String::new(),
}
}
#[test]
fn config_validation_rejects_bad_values() {
let dir = tempfile::tempdir().unwrap();
let args = test_cli(dir.path());
assert!(validate_portal_config(&args).is_ok());
let mut invalid = args.clone();
invalid.bind = "127.0.0.1:bad".to_string();
assert!(
validate_portal_config(&invalid)
.unwrap_err()
.to_string()
.contains("port")
);
let mut invalid = args.clone();
invalid.max_page_size = 0;
assert!(
validate_portal_config(&invalid)
.unwrap_err()
.to_string()
.contains("max_page_size")
);
let mut invalid = args.clone();
invalid.max_report_date_range_days = 0;
assert!(
validate_portal_config(&invalid)
.unwrap_err()
.to_string()
.contains("max_report_date_range_days")
);
let mut invalid = args.clone();
invalid.request_timeout_seconds = 0;
assert!(
validate_portal_config(&invalid)
.unwrap_err()
.to_string()
.contains("request_timeout_seconds")
);
}
#[test]
fn query_limits_reject_page_size_and_report_range() {
let dir = tempfile::tempdir().unwrap();
let args = test_cli(dir.path());
assert!(validate_api_query_limits("/api/reports?page_size=100", &args).is_ok());
let page_error =
validate_api_query_limits("/api/reports?page_size=999999", &args).unwrap_err();
assert_eq!(page_error.status.0, 400);
assert_eq!(page_error.payload["error_code"], "invalid_page_size");
let range_error = validate_api_query_limits(
"/api/reports?date_from=2026-01-01&date_to=2026-12-31",
&args,
)
.unwrap_err();
assert_eq!(range_error.status.0, 400);
assert_eq!(range_error.payload["error_code"], "report_range_too_large");
}
}
@@ -0,0 +1,42 @@
use serde_json::{Value, json};
use tiny_http::StatusCode;
use super::request_context::HttpRequestMetadata;
use crate::now;
pub(crate) fn log_http_request(
metadata: &HttpRequestMetadata,
status: StatusCode,
response_bytes: usize,
) {
let level = if status.0 >= 500 {
"ERROR"
} else if status.0 >= 400 {
"WARN"
} else {
"INFO"
};
let error_code = if status.0 >= 400 {
Value::String(format!("http_{}", status.0))
} else {
Value::Null
};
eprintln!(
"{}",
json!({
"timestamp": now(),
"level": level,
"request_id": &metadata.request_id,
"correlation_id": &metadata.correlation_id,
"method": &metadata.method,
"path": &metadata.path,
"route": &metadata.route,
"status": status.0,
"latency_ms": metadata.latency_ms,
"user_role": &metadata.role,
"module": &metadata.module,
"error_code": error_code,
"response_bytes": response_bytes,
})
);
}
@@ -0,0 +1,183 @@
use std::collections::BTreeMap;
use std::fmt::Write as FmtWrite;
use std::sync::{Mutex, OnceLock};
use tiny_http::StatusCode;
use super::readiness::build_readyz;
use super::request_context::HttpRequestMetadata;
use crate::Cli;
static PORTAL_METRICS: OnceLock<Mutex<PortalMetrics>> = OnceLock::new();
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
struct HttpMetricKey {
method: String,
route: String,
status: u16,
module: String,
}
#[derive(Clone, Debug, Default)]
struct HttpMetricValue {
requests_total: u64,
duration_seconds_sum: f64,
duration_seconds_count: u64,
}
#[derive(Clone, Debug, Default)]
struct PortalMetrics {
http: BTreeMap<HttpMetricKey, HttpMetricValue>,
reports_generated_total: u64,
ingestion_records_total: u64,
ingestion_rejected_total: u64,
role_denied_total: u64,
}
fn portal_metrics() -> &'static Mutex<PortalMetrics> {
PORTAL_METRICS.get_or_init(|| Mutex::new(PortalMetrics::default()))
}
pub(crate) fn record_http_metric(metadata: &HttpRequestMetadata, status: StatusCode) {
if let Ok(mut metrics) = portal_metrics().lock() {
let entry = metrics
.http
.entry(HttpMetricKey {
method: metadata.method.clone(),
route: metadata.route.clone(),
status: status.0,
module: metadata.module.clone(),
})
.or_default();
entry.requests_total = entry.requests_total.saturating_add(1);
entry.duration_seconds_sum += metadata.latency_ms as f64 / 1_000.0;
entry.duration_seconds_count = entry.duration_seconds_count.saturating_add(1);
if status.0 == 403 {
metrics.role_denied_total = metrics.role_denied_total.saturating_add(1);
}
}
}
pub(crate) fn record_report_generated() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.reports_generated_total = metrics.reports_generated_total.saturating_add(1);
}
}
pub(crate) fn record_ingestion_accepted() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.ingestion_records_total = metrics.ingestion_records_total.saturating_add(1);
}
}
pub(crate) fn record_ingestion_rejected() {
if let Ok(mut metrics) = portal_metrics().lock() {
metrics.ingestion_rejected_total = metrics.ingestion_rejected_total.saturating_add(1);
}
}
pub(crate) fn render_prometheus_metrics(args: &Cli) -> String {
let mut text = String::new();
let ready_value = if build_readyz(args)
.get("status")
.and_then(serde_json::Value::as_str)
.unwrap_or("not_ready")
== "ready"
{
1
} else {
0
};
writeln!(
&mut text,
"# HELP awatch_http_requests_total HTTP requests handled by AWatch-rus portal"
)
.ok();
writeln!(&mut text, "# TYPE awatch_http_requests_total counter").ok();
let metrics = portal_metrics()
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
for (key, value) in &metrics.http {
writeln!(
&mut text,
"awatch_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.requests_total
)
.ok();
}
writeln!(
&mut text,
"# HELP awatch_http_request_duration_seconds HTTP request duration in seconds"
)
.ok();
writeln!(
&mut text,
"# TYPE awatch_http_request_duration_seconds summary"
)
.ok();
for (key, value) in &metrics.http {
writeln!(
&mut text,
"awatch_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {:.6}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.duration_seconds_sum
)
.ok();
writeln!(
&mut text,
"awatch_http_request_duration_seconds_count{{method=\"{}\",route=\"{}\",status=\"{}\",module=\"{}\"}} {}",
prom_escape(&key.method),
prom_escape(&key.route),
key.status,
prom_escape(&key.module),
value.duration_seconds_count
)
.ok();
}
for (name, help, value) in [
(
"awatch_reports_generated_total",
"Reports generated by the portal",
metrics.reports_generated_total,
),
(
"awatch_ingestion_records_total",
"Telemetry ingestion records accepted",
metrics.ingestion_records_total,
),
(
"awatch_ingestion_rejected_total",
"Telemetry ingestion records rejected",
metrics.ingestion_rejected_total,
),
(
"awatch_role_denied_total",
"Requests denied by role gates",
metrics.role_denied_total,
),
] {
writeln!(&mut text, "# HELP {name} {help}").ok();
writeln!(&mut text, "# TYPE {name} counter").ok();
writeln!(&mut text, "{name} {value}").ok();
}
writeln!(
&mut text,
"# HELP awatch_readyz_status Portal readiness status, 1=ready, 0=not_ready"
)
.ok();
writeln!(&mut text, "# TYPE awatch_readyz_status gauge").ok();
writeln!(&mut text, "awatch_readyz_status {ready_value}").ok();
text
}
fn prom_escape(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
@@ -0,0 +1,18 @@
pub(crate) mod health;
pub(crate) mod limits;
pub(crate) mod logging;
pub(crate) mod metrics;
pub(crate) mod readiness;
pub(crate) mod request_context;
pub(crate) mod version;
pub(crate) use health::build_healthz;
pub(crate) use limits::{is_limited_api_route, validate_api_query_limits, validate_portal_config};
pub(crate) use logging::log_http_request;
pub(crate) use metrics::{
record_http_metric, record_ingestion_accepted, record_ingestion_rejected,
record_report_generated, render_prometheus_metrics,
};
pub(crate) use readiness::build_readyz;
pub(crate) use request_context::{http_request_metadata, mark_request_started};
pub(crate) use version::build_version;
@@ -0,0 +1,58 @@
use std::path::Path;
use serde_json::{Value, json};
use super::limits::validate_portal_config;
use crate::{Cli, now};
pub(crate) fn build_readyz(args: &Cli) -> Value {
let config_ok = validate_portal_config(args).is_ok();
let storage_status = storage_readiness_status(&args.state_dir);
let telemetry_status = storage_parent_readiness_status(&args.telemetry_store_path);
let evidence_status = storage_readiness_status(&args.evidence_root);
let ready = config_ok && !matches!(storage_status.as_str(), "error");
json!({
"status": if ready { "ready" } else { "not_ready" },
"generated_at_utc": now(),
"checks": {
"config": if config_ok { "ok" } else { "error" },
"storage": storage_status,
"telemetry_store": telemetry_status,
"evidence_storage": evidence_status,
"pfsense": "contract_only",
"security_events": security_events_readiness_status(args),
"clickhouse": if args.security_events_backend == "clickhouse" { "configured" } else { "not_required" }
}
})
}
fn storage_readiness_status(path: &Path) -> String {
if path.exists() {
if path.is_dir() {
"ok".to_string()
} else {
"error".to_string()
}
} else {
"not_configured".to_string()
}
}
fn storage_parent_readiness_status(path: &Path) -> String {
path.parent()
.map(storage_readiness_status)
.unwrap_or_else(|| "not_configured".to_string())
}
fn security_events_readiness_status(args: &Cli) -> &'static str {
match args
.security_events_backend
.trim()
.to_ascii_lowercase()
.as_str()
{
"disabled" | "" => "disabled",
"clickhouse" => "configured",
_ => "configured",
}
}
@@ -0,0 +1,191 @@
use std::cell::RefCell;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use tiny_http::Request;
use crate::{
normalize_path, parse_case_path, parse_case_status_path, parse_evidence_screenshot_path,
parse_investigation_pack_path, portal_role_from_request,
};
static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
thread_local! {
static REQUEST_STARTED_AT: RefCell<Option<Instant>> = const { RefCell::new(None) };
}
#[derive(Clone, Debug)]
pub(crate) struct HttpRequestMetadata {
pub(crate) method: String,
pub(crate) path: String,
pub(crate) route: String,
pub(crate) module: String,
pub(crate) role: String,
pub(crate) request_id: String,
pub(crate) correlation_id: String,
pub(crate) latency_ms: u64,
}
pub(crate) fn mark_request_started() {
REQUEST_STARTED_AT.with(|started| {
*started.borrow_mut() = Some(Instant::now());
});
}
fn request_latency_ms() -> u64 {
REQUEST_STARTED_AT.with(|started| {
started
.borrow()
.as_ref()
.map(|instant| instant.elapsed().as_millis() as u64)
.unwrap_or(0)
})
}
pub(crate) fn http_request_metadata(request: &Request) -> HttpRequestMetadata {
let raw_url = request.url().to_string();
let path = normalize_path(&raw_url);
let method = request.method().as_str().to_string();
let route = metrics_route(&path);
let module = metrics_module(&route).to_string();
let role = portal_role_from_request(request, &raw_url)
.as_str()
.to_string();
let request_id =
request_header(request, "X-Request-Id").or_else(|| request_header(request, "X-Request-ID"));
let correlation_id = request_header(request, "X-Correlation-Id")
.or_else(|| request_header(request, "X-Correlation-ID"));
let (request_id, correlation_id) = resolve_request_ids(request_id, correlation_id);
HttpRequestMetadata {
method,
path,
route,
module,
role,
request_id,
correlation_id,
latency_ms: request_latency_ms(),
}
}
fn request_header(request: &Request, name: &str) -> Option<String> {
request
.headers()
.iter()
.find(|header| header.field.to_string().eq_ignore_ascii_case(name))
.map(|header| header.value.as_str().to_string())
}
fn resolve_request_ids(
request_id: Option<String>,
correlation_id: Option<String>,
) -> (String, String) {
let request_id = request_id
.map(sanitize_request_token)
.filter(|value| !value.is_empty())
.unwrap_or_else(generate_request_id);
let correlation_id = correlation_id
.map(sanitize_request_token)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| request_id.clone());
(request_id, correlation_id)
}
fn sanitize_request_token(value: String) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':'))
.take(96)
.collect()
}
fn generate_request_id() -> String {
let seq = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("awatch-{millis}-{seq}")
}
fn metrics_route(path: &str) -> String {
if parse_investigation_pack_path(path).is_some() {
return "/api/investigation-pack/{candidate_id}".to_string();
}
if parse_case_path(path).is_some() {
return "/api/cases/{case_id}".to_string();
}
if parse_case_status_path(path).is_some() {
return "/api/cases/{case_id}/status".to_string();
}
if parse_evidence_screenshot_path(path).is_some() {
return "/api/dlp/evidence/{evidence_id}/asset".to_string();
}
match path {
"/" | "/operator" | "/manager" | "/owner" | "/incidents" | "/reports" | "/architecture" => {
path.to_string()
}
"/healthz" | "/api/healthz" => "/healthz".to_string(),
"/readyz" | "/api/readyz" => "/readyz".to_string(),
"/version" | "/api/version" => "/version".to_string(),
"/metrics" | "/api/metrics" => "/metrics".to_string(),
_ if path.starts_with("/api/") => path.to_string(),
_ => "other".to_string(),
}
}
fn metrics_module(route: &str) -> &'static str {
if route.starts_with("/healthz")
|| route.starts_with("/readyz")
|| route.starts_with("/version")
|| route.starts_with("/metrics")
{
"runtime"
} else if route.contains("/workforce") || route == "/manager" {
"workforce"
} else if route.contains("/security")
|| route.contains("/incidents")
|| route.contains("/incident-review")
{
"security"
} else if route.contains("/forensics")
|| route.contains("/investigation-pack")
|| route.contains("/cases")
|| route.contains("/dlp/evidence")
{
"forensics"
} else if route.contains("/ueba") {
"ueba"
} else if route.contains("/pfsense") {
"pfsense"
} else if route.contains("/reports") || route.contains("/executive") || route == "/operator" {
"reports"
} else {
"portal"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_id_and_correlation_id_are_sanitized_and_linked() {
let (request_id, correlation_id) = resolve_request_ids(
Some(" req-123\nbad ".to_string()),
Some("corr-456/ignored".to_string()),
);
assert_eq!(request_id, "req-123bad");
assert_eq!(correlation_id, "corr-456ignored");
let (request_id, correlation_id) =
resolve_request_ids(Some("rid_1".to_string()), Some(" \n ".to_string()));
assert_eq!(request_id, "rid_1");
assert_eq!(correlation_id, "rid_1");
let (generated, generated_correlation) = resolve_request_ids(None, None);
assert!(generated.starts_with("awatch-"));
assert_eq!(generated, generated_correlation);
}
}
@@ -0,0 +1,13 @@
use serde_json::{Value, json};
use crate::{Cli, PORTAL_SCHEMA_VERSION};
pub(crate) fn build_version(args: &Cli) -> Value {
json!({
"app_version": env!("CARGO_PKG_VERSION"),
"git_commit": option_env!("GIT_COMMIT").unwrap_or("unknown"),
"build_time": option_env!("BUILD_TIME").unwrap_or("unknown"),
"schema_version": PORTAL_SCHEMA_VERSION,
"environment": args.environment,
})
}