commit fb8bf4344e8a1d7395182dc1f97e2d21a82440f7
parent 67408a968d91d5ebc38540fe3ebdbd199291b790
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 01:55:47 +0000
audit: add runtime operation audit store
- add a repo-local JSONL operation audit model under audit_dir
- persist typed operation records with connection, request, relay, and outcome fields
- expose runtime helpers for writing and reading operation audit records
- cover append and query behavior with unit tests
Diffstat:
| M | src/app/runtime.rs | | | 58 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | src/audit.rs | | | 225 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/error.rs | | | 46 | +++++++++++++++++++++++++++++++++++++++++++++- |
| M | src/lib.rs | | | 5 | +++++ |
4 files changed, 333 insertions(+), 1 deletion(-)
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -2,6 +2,7 @@ use std::fs;
use std::future::Future;
use std::path::{Path, PathBuf};
+use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore};
use crate::config::MycConfig;
use crate::error::MycError;
use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot};
@@ -42,6 +43,7 @@ pub struct MycSignerContext {
signer_identity: RadrootsIdentity,
user_identity: RadrootsIdentity,
signer_state_path: PathBuf,
+ audit_dir: PathBuf,
connection_approval_requirement: RadrootsNostrSignerApprovalRequirement,
}
@@ -108,6 +110,14 @@ impl MycRuntime {
self.transport.as_ref()
}
+ pub fn operation_audit_store(&self) -> MycOperationAuditStore {
+ self.signer.operation_audit_store()
+ }
+
+ pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) {
+ self.signer.record_operation_audit(record);
+ }
+
pub(crate) fn signer_context(&self) -> MycSignerContext {
self.signer.clone()
}
@@ -216,6 +226,27 @@ impl MycSignerContext {
Self::load_signer_manager_from_path(&self.signer_state_path)
}
+ pub fn operation_audit_store(&self) -> MycOperationAuditStore {
+ MycOperationAuditStore::new(&self.audit_dir)
+ }
+
+ pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) {
+ emit_operation_audit_trace(record);
+ if let Err(error) = self.operation_audit_store().append(record) {
+ tracing::error!(
+ operation = ?record.operation,
+ outcome = ?record.outcome,
+ connection_id = record.connection_id.as_deref().unwrap_or(""),
+ request_id = record.request_id.as_deref().unwrap_or(""),
+ relay_count = record.relay_count,
+ acknowledged_relay_count = record.acknowledged_relay_count,
+ relay_outcome_summary = %record.relay_outcome_summary,
+ error = %error,
+ "failed to persist myc operation audit record"
+ );
+ }
+ }
+
pub fn connection_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement {
self.connection_approval_requirement
}
@@ -246,6 +277,7 @@ impl MycSignerContext {
signer_identity,
user_identity,
signer_state_path: paths.signer_state_path.clone(),
+ audit_dir: paths.audit_dir.clone(),
connection_approval_requirement,
})
}
@@ -257,6 +289,32 @@ impl MycSignerContext {
}
}
+fn emit_operation_audit_trace(record: &MycOperationAuditRecord) {
+ match record.outcome {
+ crate::audit::MycOperationAuditOutcome::Succeeded => tracing::info!(
+ operation = ?record.operation,
+ outcome = ?record.outcome,
+ connection_id = record.connection_id.as_deref().unwrap_or(""),
+ request_id = record.request_id.as_deref().unwrap_or(""),
+ relay_count = record.relay_count,
+ acknowledged_relay_count = record.acknowledged_relay_count,
+ relay_outcome_summary = %record.relay_outcome_summary,
+ "recorded myc operation audit"
+ ),
+ crate::audit::MycOperationAuditOutcome::Rejected
+ | crate::audit::MycOperationAuditOutcome::Restored => tracing::warn!(
+ operation = ?record.operation,
+ outcome = ?record.outcome,
+ connection_id = record.connection_id.as_deref().unwrap_or(""),
+ request_id = record.request_id.as_deref().unwrap_or(""),
+ relay_count = record.relay_count,
+ acknowledged_relay_count = record.acknowledged_relay_count,
+ relay_outcome_summary = %record.relay_outcome_summary,
+ "recorded myc operation audit"
+ ),
+ }
+}
+
#[cfg(test)]
mod tests {
use std::path::PathBuf;
diff --git a/src/audit.rs b/src/audit.rs
@@ -0,0 +1,225 @@
+use std::fs::{self, OpenOptions};
+use std::io::{BufRead, BufReader, Write};
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
+use serde::{Deserialize, Serialize};
+
+use crate::error::MycError;
+
+const MYC_OPERATION_AUDIT_FILE_NAME: &str = "operations.jsonl";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycOperationAuditKind {
+ ListenerResponsePublish,
+ ConnectAcceptPublish,
+ AuthReplayPublish,
+ AuthReplayRestore,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MycOperationAuditOutcome {
+ Succeeded,
+ Rejected,
+ Restored,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MycOperationAuditRecord {
+ pub recorded_at_unix: u64,
+ pub operation: MycOperationAuditKind,
+ pub outcome: MycOperationAuditOutcome,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub connection_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub request_id: Option<String>,
+ pub relay_count: usize,
+ pub acknowledged_relay_count: usize,
+ pub relay_outcome_summary: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct MycOperationAuditStore {
+ path: PathBuf,
+}
+
+impl MycOperationAuditRecord {
+ pub fn new(
+ operation: MycOperationAuditKind,
+ outcome: MycOperationAuditOutcome,
+ connection_id: Option<&RadrootsNostrSignerConnectionId>,
+ request_id: Option<&str>,
+ relay_count: usize,
+ acknowledged_relay_count: usize,
+ relay_outcome_summary: impl Into<String>,
+ ) -> Self {
+ Self {
+ recorded_at_unix: now_unix_secs(),
+ operation,
+ outcome,
+ connection_id: connection_id.map(ToString::to_string),
+ request_id: request_id.map(ToOwned::to_owned),
+ relay_count,
+ acknowledged_relay_count,
+ relay_outcome_summary: relay_outcome_summary.into(),
+ }
+ }
+}
+
+impl MycOperationAuditStore {
+ pub fn new(audit_dir: impl AsRef<Path>) -> Self {
+ Self {
+ path: audit_dir.as_ref().join(MYC_OPERATION_AUDIT_FILE_NAME),
+ }
+ }
+
+ pub fn path(&self) -> &Path {
+ self.path.as_path()
+ }
+
+ pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> {
+ let mut file = OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&self.path)
+ .map_err(|source| MycError::AuditIo {
+ path: self.path.clone(),
+ source,
+ })?;
+ serde_json::to_writer(&mut file, record).map_err(|source| MycError::AuditSerialize {
+ path: self.path.clone(),
+ source,
+ })?;
+ file.write_all(b"\n").map_err(|source| MycError::AuditIo {
+ path: self.path.clone(),
+ source,
+ })?;
+ Ok(())
+ }
+
+ pub fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> {
+ self.list_matching(|_| true)
+ }
+
+ pub fn list_for_connection(
+ &self,
+ connection_id: &RadrootsNostrSignerConnectionId,
+ ) -> Result<Vec<MycOperationAuditRecord>, MycError> {
+ self.list_matching(|record| record.connection_id.as_deref() == Some(connection_id.as_str()))
+ }
+
+ fn list_matching<F>(&self, predicate: F) -> Result<Vec<MycOperationAuditRecord>, MycError>
+ where
+ F: Fn(&MycOperationAuditRecord) -> bool,
+ {
+ if !self.path.exists() {
+ return Ok(Vec::new());
+ }
+
+ let file = fs::File::open(&self.path).map_err(|source| MycError::AuditIo {
+ path: self.path.clone(),
+ source,
+ })?;
+ let reader = BufReader::new(file);
+ let mut records = Vec::new();
+
+ for (line_number, line) in reader.lines().enumerate() {
+ let line = line.map_err(|source| MycError::AuditIo {
+ path: self.path.clone(),
+ source,
+ })?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let record =
+ serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| {
+ MycError::AuditParse {
+ path: self.path.clone(),
+ line_number: line_number + 1,
+ source,
+ }
+ })?;
+ if predicate(&record) {
+ records.push(record);
+ }
+ }
+
+ Ok(records)
+ }
+}
+
+fn now_unix_secs() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("system clock is before unix epoch")
+ .as_secs()
+}
+
+#[cfg(test)]
+mod tests {
+ use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
+
+ use super::{
+ MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord,
+ MycOperationAuditStore,
+ };
+
+ #[test]
+ fn append_and_list_operation_audit_records() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let store = MycOperationAuditStore::new(temp.path());
+ let connection_id =
+ RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id");
+
+ store
+ .append(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ConnectAcceptPublish,
+ MycOperationAuditOutcome::Rejected,
+ Some(&connection_id),
+ Some("request-1"),
+ 2,
+ 0,
+ "0/2 relays acknowledged publish; failures: relay-a: rejected",
+ ))
+ .expect("append rejected record");
+ store
+ .append(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::AuthReplayRestore,
+ MycOperationAuditOutcome::Restored,
+ Some(&connection_id),
+ Some("request-1"),
+ 0,
+ 0,
+ "restored pending auth challenge after replay publish rejection",
+ ))
+ .expect("append restored record");
+
+ let records = store.list().expect("list records");
+ assert_eq!(records.len(), 2);
+ assert_eq!(
+ records[0].operation,
+ MycOperationAuditKind::ConnectAcceptPublish
+ );
+ assert_eq!(records[0].outcome, MycOperationAuditOutcome::Rejected);
+ assert_eq!(records[0].connection_id.as_deref(), Some("connection-1"));
+ assert_eq!(records[0].request_id.as_deref(), Some("request-1"));
+ assert_eq!(records[0].relay_count, 2);
+ assert_eq!(records[0].acknowledged_relay_count, 0);
+
+ let connection_records = store
+ .list_for_connection(&connection_id)
+ .expect("list connection records");
+ assert_eq!(connection_records, records);
+ }
+
+ #[test]
+ fn list_returns_empty_when_audit_file_is_missing() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let store = MycOperationAuditStore::new(temp.path());
+
+ assert!(store.list().expect("list missing records").is_empty());
+ }
+}
diff --git a/src/error.rs b/src/error.rs
@@ -38,6 +38,25 @@ pub enum MycError {
#[source]
source: std::io::Error,
},
+ #[error("audit io error at {path}: {source}")]
+ AuditIo {
+ path: PathBuf,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("audit parse error at {path}:{line_number}: {source}")]
+ AuditParse {
+ path: PathBuf,
+ line_number: usize,
+ #[source]
+ source: serde_json::Error,
+ },
+ #[error("failed to serialize audit record at {path}: {source}")]
+ AuditSerialize {
+ path: PathBuf,
+ #[source]
+ source: serde_json::Error,
+ },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
@@ -55,7 +74,12 @@ pub enum MycError {
#[error("NIP-46 listener notifications closed")]
Nip46ListenerClosed,
#[error("Nostr publish failed for {operation}: {details}")]
- PublishRejected { operation: String, details: String },
+ PublishRejected {
+ operation: String,
+ relay_count: usize,
+ acknowledged_relay_count: usize,
+ details: String,
+ },
#[error(
"configured signer identity `{configured_identity_id}` at {identity_path} does not match persisted signer identity `{persisted_identity_id}` in {state_path}"
)]
@@ -66,3 +90,23 @@ pub enum MycError {
persisted_identity_id: String,
},
}
+
+impl MycError {
+ pub fn publish_rejection_details(&self) -> Option<&str> {
+ match self {
+ Self::PublishRejected { details, .. } => Some(details.as_str()),
+ _ => None,
+ }
+ }
+
+ pub fn publish_rejection_counts(&self) -> Option<(usize, usize)> {
+ match self {
+ Self::PublishRejected {
+ relay_count,
+ acknowledged_relay_count,
+ ..
+ } => Some((*relay_count, *acknowledged_relay_count)),
+ _ => None,
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -1,6 +1,7 @@
#![forbid(unsafe_code)]
pub mod app;
+pub mod audit;
pub mod cli;
pub mod config;
pub mod control;
@@ -9,6 +10,10 @@ pub mod logging;
pub mod transport;
pub use app::{MycApp, MycRuntime, MycRuntimePaths, MycSignerContext, MycStartupSnapshot};
+pub use audit::{
+ MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord,
+ MycOperationAuditStore,
+};
pub use config::{
DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig,
MycPolicyConfig, MycServiceConfig, MycTransportConfig,