Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
598 changes: 591 additions & 7 deletions arcane/home/honeypot-dashboard/backend-service/Cargo.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions arcane/home/honeypot-dashboard/backend-service/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ thiserror = "2"
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"
tokio-stream = { version = "0.1", features = ["sync"] }
# Payload Workbench (#1612 phase 3b): idempotency-key hashing and recipe-id
# generation. Both need to agree with the Go tier's algorithm (SHA-256 of
# the same material; crypto/rand-strength ids) while both write
# dashboard-workbench-runs-v1/-recipes-v1 during the cutover window.
sha2 = "0.10"
rand = "0.9"
# PDF report composer (#1612 follow-up): only the core Op/graphics/
# builtin-font API is used (no HTML layout, no raster image decoding — the
# two embedded emblems are hand-registered XObject::External stencil masks,
# see report_pdf.rs), so every optional feature (html/text_layout/images/
# svg/...) is disabled to skip printpdf's azul-layout+taffy+hyphenation
# dependency chain entirely.
printpdf = { version = "0.12.6", default-features = false }

[profile.release]
lto = "thin"
Expand Down
Binary file not shown.
Binary file not shown.
129 changes: 129 additions & 0 deletions arcane/home/honeypot-dashboard/backend-service/src/audit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Append-only audit log for settings mutations, ported from
//! settings_audit.go: actor, action, dotted field names, revision, and
//! result; values are never written to the log. JSONL with a single
//! rotated generation (older events are not retained past one rotation —
//! matches the Go tier exactly).

use axum::extract::{Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use crate::AppState;

const AUDIT_MAX_BYTES: u64 = 8 << 20;

#[derive(Serialize, Default, Clone, Debug)]
pub struct AuditEvent {
#[serde(skip_serializing_if = "String::is_empty")]
pub time: String,
pub actor_subject: String,
pub actor_username: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub request_id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub client_ip: String,
pub action: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<String>,
pub revision: i64,
/// success | conflict | invalid | error
pub result: String,
}

pub struct AuditLogger {
path: PathBuf,
lock: Mutex<()>,
}

impl AuditLogger {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into(), lock: Mutex::new(()) }
}

/// Append one event. Audit failures never block the mutation that
/// produced the event — best-effort, like the Go tier's logger.
pub fn log(&self, mut event: AuditEvent) {
let _guard = self.lock.lock().unwrap_or_else(|poison| poison.into_inner());
if event.time.is_empty() {
event.time = chrono::Utc::now().to_rfc3339();
}
if let Some(parent) = self.path.parent() {
if std::fs::create_dir_all(parent).is_err() {
return;
}
}
rotate_if_oversized(&self.path, AUDIT_MAX_BYTES);
let Ok(line) = serde_json::to_string(&event) else { return };
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&self.path) {
let _ = writeln!(file, "{line}");
}
}

/// Newest events first, bounded to limit. Only the live generation is
/// read (matches settings_audit.go's read, which never consults the
/// rotated .1 file).
pub fn read(&self, limit: usize) -> Vec<Value> {
if limit == 0 {
return Vec::new();
}
let _guard = self.lock.lock().unwrap_or_else(|poison| poison.into_inner());
let Ok(raw) = std::fs::read_to_string(&self.path) else { return Vec::new() };
let mut events = Vec::with_capacity(limit.min(256));
for line in raw.lines().rev() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(value) = serde_json::from_str::<Value>(line) {
events.push(value);
if events.len() >= limit {
break;
}
}
}
events
}
}

pub fn rotate_if_oversized(path: &Path, max_bytes: u64) {
let Ok(meta) = std::fs::metadata(path) else { return };
if meta.len() <= max_bytes {
return;
}
let rotated = rotated_path(path);
let _ = std::fs::remove_file(&rotated);
let _ = std::fs::rename(path, &rotated);
}

pub fn rotated_path(path: &Path) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(".1");
PathBuf::from(name)
}

#[derive(Deserialize)]
pub struct AuditQuery {
limit: Option<usize>,
action: Option<String>,
}

/// GET /api/v1/audit?limit=&action= — newest first, optional action
/// filter, limit clamped to [1, 500] (default 100), ported from
/// serveSettingsAudit.
pub async fn list(State(state): State<AppState>, Query(query): Query<AuditQuery>) -> Json<Value> {
let limit = query.limit.unwrap_or(100).clamp(1, 500);
let events = state.audit.read(500);
let filtered: Vec<Value> = events
.into_iter()
.filter(|event| match query.action.as_deref() {
Some(action) => event["action"].as_str() == Some(action),
None => true,
})
.take(limit)
.collect();
Json(json!({"events": filtered}))
}
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,39 @@ pub async fn create(
}))
}

/// GET /api/v1/canarytokens — every created token's history record, newest
/// first, for the Settings pane's history table and credentials'
/// link-token id validation. auth_token is the platform's own management
/// credential for that token (equivalent to a password) and must never
/// reach a browser — see create()'s CreatedToken response for the same
/// redaction, this list applies it too rather than encoding the ES
/// document straight through.
pub async fn list(State(state): State<AppState>) -> Result<Json<Value>, (StatusCode, String)> {
let result = state
.es
.search_index(&["dashboard-canarytokens-v1"], json!({"size": 1000}))
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, error.to_string()))?;
let mut records: Vec<Value> = result["hits"]["hits"]
.as_array()
.into_iter()
.flatten()
.map(|hit| {
let mut record = hit["_source"].clone();
if let Some(object) = record.as_object_mut() {
object.remove("auth_token");
}
record
})
.collect();
records.sort_by(|a, b| {
let a = a["created_at"].as_str().unwrap_or_default();
let b = b["created_at"].as_str().unwrap_or_default();
b.cmp(a)
});
Ok(Json(json!({"tokens": records})))
}

#[derive(Deserialize)]
pub struct DownloadQuery {
#[serde(default)]
Expand Down
121 changes: 120 additions & 1 deletion arcane/home/honeypot-dashboard/backend-service/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@
//! + behavior), driving branding text across the frontend.
//! - PUT /api/v1/config/presentation — replace the presentation block
//! (revision+1); the BFF enforces the admin role before calling.
//! - GET /api/v1/config/history, POST /api/v1/config/rollback (#1612) —
//! revision history and restore, working at the JSON `Value` level like
//! everything else here rather than porting settings_admin_api.go's full
//! typed behavior/honeypot patch + pinned-field + impact-classification
//! machinery, which nothing in this Rust tier writes yet.
//! - GET /api/v1/users — the known-operators roster (subjects, roles,
//! seen timestamps; per-user preference blobs stay out of the list).

use axum::{extract::State, http::StatusCode, Json};
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};

use crate::audit::AuditEvent;
use crate::config_history::HistoryEntry;
use crate::AppState;

const CONFIG_INDEX: &str = "dashboard-config-v1";
Expand Down Expand Up @@ -39,8 +51,17 @@ pub async fn get_config(State(state): State<AppState>) -> Result<Json<Value>, (S
Ok(Json(doc))
}

#[derive(Deserialize)]
pub struct ActorQuery {
#[serde(default)]
actor_subject: String,
#[serde(default)]
actor_username: String,
}

pub async fn put_presentation(
State(state): State<AppState>,
Query(actor): Query<ActorQuery>,
Json(presentation): Json<Value>,
) -> Result<Json<Value>, (StatusCode, String)> {
if !presentation.is_object() {
Expand All @@ -58,6 +79,104 @@ pub async fn put_presentation(
.index_doc(CONFIG_INDEX, CONFIG_ID, doc.clone())
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, error.to_string()))?;
let revision = doc["revision"].as_i64().unwrap_or(0);
let fields = vec!["presentation".to_string()];
state.config_history.append(HistoryEntry {
revision,
time: String::new(),
actor_subject: actor.actor_subject.clone(),
actor_username: actor.actor_username.clone(),
action: "update".into(),
fields: fields.clone(),
payload: doc["payload"].clone(),
});
state.audit.log(AuditEvent {
actor_subject: actor.actor_subject,
actor_username: actor.actor_username,
action: "config.update".into(),
fields,
revision,
result: "success".into(),
..Default::default()
});
Ok(Json(doc))
}

/// configHistoryView-equivalent: everything needed for review and rollback
/// selection, without the retained payload snapshot itself.
pub async fn history(State(state): State<AppState>) -> Json<Value> {
let entries: Vec<Value> = state
.config_history
.read(crate::config_history::HISTORY_READ_LIMIT)
.into_iter()
.map(|entry| {
json!({
"revision": entry["revision"],
"time": entry["time"],
"actor_subject": entry["actor_subject"],
"actor_username": entry["actor_username"],
"action": entry["action"],
"fields": entry["fields"],
})
})
.collect();
Json(json!({"entries": entries}))
}

#[derive(Deserialize)]
pub struct RollbackBody {
revision: i64,
#[serde(default)]
actor_subject: String,
#[serde(default)]
actor_username: String,
}

/// Restores one retained revision's full payload as a NEW revision —
/// history is append-only, rollback never rewrites the past.
pub async fn rollback(
State(state): State<AppState>,
Json(body): Json<RollbackBody>,
) -> Result<Json<Value>, (StatusCode, String)> {
if body.revision < 0 {
return Err((StatusCode::BAD_REQUEST, "a non-negative revision is required".into()));
}
let Some(entry) = state.config_history.find(body.revision) else {
return Err((StatusCode::NOT_FOUND, "revision is no longer retained".into()));
};
let restored_payload = entry["payload"].clone();
let mut doc = load_config(&state)
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, error.to_string()))?
.unwrap_or_else(|| json!({"schema_version": 4, "revision": 0, "payload": {}}));
doc["payload"] = restored_payload.clone();
doc["revision"] = json!(doc["revision"].as_u64().unwrap_or(0) + 1);
doc["updated"] = json!(chrono::Utc::now().to_rfc3339());
state
.es
.index_doc(CONFIG_INDEX, CONFIG_ID, doc.clone())
.await
.map_err(|error| (StatusCode::BAD_GATEWAY, error.to_string()))?;
let revision = doc["revision"].as_i64().unwrap_or(0);
let fields = vec!["*".to_string()];
state.config_history.append(HistoryEntry {
revision,
time: String::new(),
actor_subject: body.actor_subject.clone(),
actor_username: body.actor_username.clone(),
action: "rollback".into(),
fields: fields.clone(),
payload: restored_payload,
});
state.audit.log(AuditEvent {
actor_subject: body.actor_subject,
actor_username: body.actor_username,
action: "config.rollback".into(),
fields,
revision,
result: "success".into(),
..Default::default()
});
Ok(Json(doc))
}

Expand Down
Loading
Loading