commit 95873b5a8ddd5bb643ce1c4b7cf5f14a020f5554
parent ba89cdabd338b76ff9da0f1550df2a71b107ac2e
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 02:23:52 +0000
audit: add bounded runtime audit retention
- add audit config for default read limits, active file size, and archive retention
- rotate operation audit JSONL files into numbered archives when the active file exceeds its bound
- read recent operation audit records from newest files first while preserving chronological output
- validate config and cover rotation, retention, and bounded reads with unit tests
Diffstat:
4 files changed, 317 insertions(+), 29 deletions(-)
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
@@ -3,7 +3,7 @@ use std::future::Future;
use std::path::{Path, PathBuf};
use crate::audit::{MycOperationAuditRecord, MycOperationAuditStore};
-use crate::config::MycConfig;
+use crate::config::{MycAuditConfig, MycConfig};
use crate::error::MycError;
use crate::transport::{MycNip46Service, MycNostrTransport, MycTransportSnapshot};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
@@ -44,6 +44,7 @@ pub struct MycSignerContext {
user_identity: RadrootsIdentity,
signer_state_path: PathBuf,
audit_dir: PathBuf,
+ audit_config: MycAuditConfig,
connection_approval_requirement: RadrootsNostrSignerApprovalRequirement,
}
@@ -63,6 +64,7 @@ impl MycRuntime {
Self::prepare_filesystem_for(&paths)?;
let signer = MycSignerContext::bootstrap(
&paths,
+ config.audit.clone(),
config
.policy
.connection_approval
@@ -227,7 +229,7 @@ impl MycSignerContext {
}
pub fn operation_audit_store(&self) -> MycOperationAuditStore {
- MycOperationAuditStore::new(&self.audit_dir)
+ MycOperationAuditStore::new(&self.audit_dir, self.audit_config.clone())
}
pub fn record_operation_audit(&self, record: &MycOperationAuditRecord) {
@@ -253,6 +255,7 @@ impl MycSignerContext {
fn bootstrap(
paths: &MycRuntimePaths,
+ audit_config: MycAuditConfig,
connection_approval_requirement: RadrootsNostrSignerApprovalRequirement,
) -> Result<Self, MycError> {
let signer_identity = RadrootsIdentity::load_from_path_auto(&paths.signer_identity_path)?;
@@ -278,6 +281,7 @@ impl MycSignerContext {
user_identity,
signer_state_path: paths.signer_state_path.clone(),
audit_dir: paths.audit_dir.clone(),
+ audit_config,
connection_approval_requirement,
})
}
diff --git a/src/audit.rs b/src/audit.rs
@@ -6,9 +6,12 @@ use std::time::{SystemTime, UNIX_EPOCH};
use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
use serde::{Deserialize, Serialize};
+use crate::config::MycAuditConfig;
use crate::error::MycError;
const MYC_OPERATION_AUDIT_FILE_NAME: &str = "operations.jsonl";
+const MYC_OPERATION_AUDIT_ARCHIVE_PREFIX: &str = "operations.";
+const MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX: &str = ".jsonl";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -43,7 +46,8 @@ pub struct MycOperationAuditRecord {
#[derive(Debug, Clone)]
pub struct MycOperationAuditStore {
- path: PathBuf,
+ audit_dir: PathBuf,
+ config: MycAuditConfig,
}
impl MycOperationAuditRecord {
@@ -70,57 +74,116 @@ impl MycOperationAuditRecord {
}
impl MycOperationAuditStore {
- pub fn new(audit_dir: impl AsRef<Path>) -> Self {
+ pub fn new(audit_dir: impl AsRef<Path>, config: MycAuditConfig) -> Self {
Self {
- path: audit_dir.as_ref().join(MYC_OPERATION_AUDIT_FILE_NAME),
+ audit_dir: audit_dir.as_ref().to_path_buf(),
+ config,
}
}
- pub fn path(&self) -> &Path {
- self.path.as_path()
+ pub fn path(&self) -> PathBuf {
+ self.active_path()
+ }
+
+ pub fn config(&self) -> &MycAuditConfig {
+ &self.config
}
pub fn append(&self, record: &MycOperationAuditRecord) -> Result<(), MycError> {
+ let active_path = self.active_path();
+ let encoded = serde_json::to_vec(record).map_err(|source| MycError::AuditSerialize {
+ path: active_path.clone(),
+ source,
+ })?;
+ self.rotate_if_needed(encoded.len() as u64 + 1)?;
+
let mut file = OpenOptions::new()
.create(true)
.append(true)
- .open(&self.path)
+ .open(&active_path)
.map_err(|source| MycError::AuditIo {
- path: self.path.clone(),
+ path: active_path.clone(),
+ source,
+ })?;
+ file.write_all(&encoded)
+ .map_err(|source| MycError::AuditIo {
+ path: active_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(),
+ path: active_path,
source,
})?;
Ok(())
}
pub fn list(&self) -> Result<Vec<MycOperationAuditRecord>, MycError> {
- self.list_matching(|_| true)
+ self.list_with_limit(self.config.default_read_limit)
+ }
+
+ pub fn list_with_limit(&self, limit: usize) -> Result<Vec<MycOperationAuditRecord>, MycError> {
+ self.list_matching(limit, |_| 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()))
+ self.list_for_connection_with_limit(connection_id, self.config.default_read_limit)
}
- fn list_matching<F>(&self, predicate: F) -> Result<Vec<MycOperationAuditRecord>, MycError>
+ pub fn list_for_connection_with_limit(
+ &self,
+ connection_id: &RadrootsNostrSignerConnectionId,
+ limit: usize,
+ ) -> Result<Vec<MycOperationAuditRecord>, MycError> {
+ self.list_matching(limit, |record| {
+ record.connection_id.as_deref() == Some(connection_id.as_str())
+ })
+ }
+
+ fn list_matching<F>(
+ &self,
+ limit: usize,
+ predicate: F,
+ ) -> Result<Vec<MycOperationAuditRecord>, MycError>
where
F: Fn(&MycOperationAuditRecord) -> bool,
{
- if !self.path.exists() {
+ if limit == 0 {
+ return Ok(Vec::new());
+ }
+
+ let mut newest_records = Vec::new();
+ for path in self.read_paths_newest_first()? {
+ let mut file_records = self.read_records_from_path(&path)?;
+ file_records.reverse();
+
+ for record in file_records {
+ if predicate(&record) {
+ newest_records.push(record);
+ if newest_records.len() == limit {
+ newest_records.reverse();
+ return Ok(newest_records);
+ }
+ }
+ }
+ }
+
+ newest_records.reverse();
+ Ok(newest_records)
+ }
+
+ fn read_records_from_path(
+ &self,
+ path: &Path,
+ ) -> Result<Vec<MycOperationAuditRecord>, MycError> {
+ if !path.exists() {
return Ok(Vec::new());
}
- let file = fs::File::open(&self.path).map_err(|source| MycError::AuditIo {
- path: self.path.clone(),
+ let file = fs::File::open(path).map_err(|source| MycError::AuditIo {
+ path: path.to_path_buf(),
source,
})?;
let reader = BufReader::new(file);
@@ -128,27 +191,151 @@ impl MycOperationAuditStore {
for (line_number, line) in reader.lines().enumerate() {
let line = line.map_err(|source| MycError::AuditIo {
- path: self.path.clone(),
+ path: path.to_path_buf(),
source,
})?;
if line.trim().is_empty() {
continue;
}
+
let record =
serde_json::from_str::<MycOperationAuditRecord>(&line).map_err(|source| {
MycError::AuditParse {
- path: self.path.clone(),
+ path: path.to_path_buf(),
line_number: line_number + 1,
source,
}
})?;
- if predicate(&record) {
- records.push(record);
- }
+ records.push(record);
}
Ok(records)
}
+
+ fn rotate_if_needed(&self, additional_bytes: u64) -> Result<(), MycError> {
+ let active_path = self.active_path();
+ let current_len = match fs::metadata(&active_path) {
+ Ok(metadata) => metadata.len(),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0,
+ Err(source) => {
+ return Err(MycError::AuditIo {
+ path: active_path,
+ source,
+ });
+ }
+ };
+
+ if current_len == 0
+ || current_len.saturating_add(additional_bytes) <= self.config.max_active_file_bytes
+ {
+ return Ok(());
+ }
+
+ self.rotate_active_file()
+ }
+
+ fn rotate_active_file(&self) -> Result<(), MycError> {
+ for index in (1..=self.config.max_archived_files).rev() {
+ let archived_path = self.archive_path(index);
+ if !archived_path.exists() {
+ continue;
+ }
+
+ if index == self.config.max_archived_files {
+ fs::remove_file(&archived_path).map_err(|source| MycError::AuditIo {
+ path: archived_path,
+ source,
+ })?;
+ } else {
+ let next_path = self.archive_path(index + 1);
+ fs::rename(&archived_path, &next_path).map_err(|source| MycError::AuditIo {
+ path: archived_path,
+ source,
+ })?;
+ }
+ }
+
+ let active_path = self.active_path();
+ if !active_path.exists() {
+ return Ok(());
+ }
+
+ if self.config.max_archived_files == 0 {
+ fs::remove_file(&active_path).map_err(|source| MycError::AuditIo {
+ path: active_path,
+ source,
+ })?;
+ return Ok(());
+ }
+
+ let first_archive = self.archive_path(1);
+ fs::rename(&active_path, &first_archive).map_err(|source| MycError::AuditIo {
+ path: active_path,
+ source,
+ })?;
+ Ok(())
+ }
+
+ fn read_paths_newest_first(&self) -> Result<Vec<PathBuf>, MycError> {
+ let mut paths = Vec::new();
+ let active_path = self.active_path();
+ if active_path.exists() {
+ paths.push(active_path);
+ }
+
+ let mut archived = self.archived_paths()?;
+ archived.sort_by_key(|(_, index)| *index);
+ for (path, _) in archived {
+ paths.push(path);
+ }
+
+ Ok(paths)
+ }
+
+ fn archived_paths(&self) -> Result<Vec<(PathBuf, usize)>, MycError> {
+ let mut archived = Vec::new();
+ if !self.audit_dir.exists() {
+ return Ok(archived);
+ }
+
+ for entry in fs::read_dir(&self.audit_dir).map_err(|source| MycError::AuditIo {
+ path: self.audit_dir.clone(),
+ source,
+ })? {
+ let entry = entry.map_err(|source| MycError::AuditIo {
+ path: self.audit_dir.clone(),
+ source,
+ })?;
+ let file_name = entry.file_name();
+ let Some(file_name) = file_name.to_str() else {
+ continue;
+ };
+ let Some(index) = parse_archive_index(file_name) else {
+ continue;
+ };
+ archived.push((entry.path(), index));
+ }
+
+ Ok(archived)
+ }
+
+ fn active_path(&self) -> PathBuf {
+ self.audit_dir.join(MYC_OPERATION_AUDIT_FILE_NAME)
+ }
+
+ fn archive_path(&self, index: usize) -> PathBuf {
+ self.audit_dir.join(format!(
+ "{MYC_OPERATION_AUDIT_ARCHIVE_PREFIX}{index}{MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX}"
+ ))
+ }
+}
+
+fn parse_archive_index(file_name: &str) -> Option<usize> {
+ file_name
+ .strip_prefix(MYC_OPERATION_AUDIT_ARCHIVE_PREFIX)?
+ .strip_suffix(MYC_OPERATION_AUDIT_ARCHIVE_SUFFIX)?
+ .parse()
+ .ok()
}
fn now_unix_secs() -> u64 {
@@ -162,15 +349,25 @@ fn now_unix_secs() -> u64 {
mod tests {
use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionId;
+ use crate::config::MycAuditConfig;
+
use super::{
MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord,
MycOperationAuditStore,
};
+ fn config() -> MycAuditConfig {
+ MycAuditConfig {
+ default_read_limit: 10,
+ max_active_file_bytes: 512,
+ max_archived_files: 2,
+ }
+ }
+
#[test]
fn append_and_list_operation_audit_records() {
let temp = tempfile::tempdir().expect("tempdir");
- let store = MycOperationAuditStore::new(temp.path());
+ let store = MycOperationAuditStore::new(temp.path(), config());
let connection_id =
RadrootsNostrSignerConnectionId::parse("connection-1").expect("connection id");
@@ -218,8 +415,43 @@ mod tests {
#[test]
fn list_returns_empty_when_audit_file_is_missing() {
let temp = tempfile::tempdir().expect("tempdir");
- let store = MycOperationAuditStore::new(temp.path());
+ let store = MycOperationAuditStore::new(temp.path(), config());
assert!(store.list().expect("list missing records").is_empty());
}
+
+ #[test]
+ fn rotation_and_bounded_reads_keep_recent_records() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let store = MycOperationAuditStore::new(
+ temp.path(),
+ MycAuditConfig {
+ default_read_limit: 3,
+ max_active_file_bytes: 180,
+ max_archived_files: 2,
+ },
+ );
+
+ for index in 0..6 {
+ store
+ .append(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ListenerResponsePublish,
+ MycOperationAuditOutcome::Rejected,
+ None,
+ Some(&format!("request-{index}")),
+ 1,
+ 0,
+ format!("failure-{index}"),
+ ))
+ .expect("append record");
+ }
+
+ let records = store.list().expect("list bounded records");
+ assert_eq!(records.len(), 3);
+ assert_eq!(records[0].request_id.as_deref(), Some("request-3"));
+ assert_eq!(records[2].request_id.as_deref(), Some("request-5"));
+ assert!(temp.path().join("operations.1.jsonl").exists());
+ assert!(temp.path().join("operations.2.jsonl").exists());
+ assert!(!temp.path().join("operations.3.jsonl").exists());
+ }
}
diff --git a/src/config.rs b/src/config.rs
@@ -17,6 +17,7 @@ pub struct MycConfig {
pub service: MycServiceConfig,
pub logging: MycLoggingConfig,
pub paths: MycPathsConfig,
+ pub audit: MycAuditConfig,
pub policy: MycPolicyConfig,
pub transport: MycTransportConfig,
}
@@ -43,6 +44,14 @@ pub struct MycPathsConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
+pub struct MycAuditConfig {
+ pub default_read_limit: usize,
+ pub max_active_file_bytes: u64,
+ pub max_archived_files: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
pub struct MycTransportConfig {
pub enabled: bool,
pub connect_timeout_secs: u64,
@@ -68,6 +77,7 @@ impl Default for MycConfig {
service: MycServiceConfig::default(),
logging: MycLoggingConfig::default(),
paths: MycPathsConfig::default(),
+ audit: MycAuditConfig::default(),
policy: MycPolicyConfig::default(),
transport: MycTransportConfig::default(),
}
@@ -110,6 +120,16 @@ impl Default for MycTransportConfig {
}
}
+impl Default for MycAuditConfig {
+ fn default() -> Self {
+ Self {
+ default_read_limit: 200,
+ max_active_file_bytes: 262_144,
+ max_archived_files: 8,
+ }
+ }
+}
+
impl Default for MycPolicyConfig {
fn default() -> Self {
Self {
@@ -194,6 +214,18 @@ impl MycConfig {
));
}
+ if self.audit.default_read_limit == 0 {
+ return Err(MycError::InvalidConfig(
+ "audit.default_read_limit must be greater than zero".to_owned(),
+ ));
+ }
+
+ if self.audit.max_active_file_bytes == 0 {
+ return Err(MycError::InvalidConfig(
+ "audit.max_active_file_bytes must be greater than zero".to_owned(),
+ ));
+ }
+
if self.transport.connect_timeout_secs == 0 {
return Err(MycError::InvalidConfig(
"transport.connect_timeout_secs must be greater than zero".to_owned(),
@@ -257,6 +289,9 @@ mod tests {
config.policy.connection_approval,
MycConnectionApproval::ExplicitUser
);
+ assert_eq!(config.audit.default_read_limit, 200);
+ assert_eq!(config.audit.max_active_file_bytes, 262_144);
+ assert_eq!(config.audit.max_archived_files, 8);
assert!(!config.transport.enabled);
assert_eq!(config.transport.connect_timeout_secs, 10);
assert!(config.transport.relays.is_empty());
@@ -277,6 +312,11 @@ mod tests {
signer_identity_path = "/tmp/myc-identity.json"
user_identity_path = "/tmp/myc-user.json"
+ [audit]
+ default_read_limit = 50
+ max_active_file_bytes = 4096
+ max_archived_files = 3
+
[policy]
connection_approval = "not_required"
@@ -299,6 +339,9 @@ mod tests {
config.paths.user_identity_path,
PathBuf::from("/tmp/myc-user.json")
);
+ assert_eq!(config.audit.default_read_limit, 50);
+ assert_eq!(config.audit.max_active_file_bytes, 4096);
+ assert_eq!(config.audit.max_archived_files, 3);
assert_eq!(
config.policy.connection_approval,
MycConnectionApproval::NotRequired
@@ -345,4 +388,13 @@ mod tests {
let err = config.validate().expect_err("missing relays");
assert!(err.to_string().contains("transport.relays"));
}
+
+ #[test]
+ fn validate_rejects_zero_audit_read_limit() {
+ let mut config = MycConfig::default();
+ config.audit.default_read_limit = 0;
+
+ let err = config.validate().expect_err("invalid audit read limit");
+ assert!(err.to_string().contains("audit.default_read_limit"));
+ }
}
diff --git a/src/lib.rs b/src/lib.rs
@@ -15,8 +15,8 @@ pub use audit::{
MycOperationAuditStore,
};
pub use config::{
- DEFAULT_CONFIG_PATH, MycConfig, MycConnectionApproval, MycLoggingConfig, MycPathsConfig,
- MycPolicyConfig, MycServiceConfig, MycTransportConfig,
+ DEFAULT_CONFIG_PATH, MycAuditConfig, MycConfig, MycConnectionApproval, MycLoggingConfig,
+ MycPathsConfig, MycPolicyConfig, MycServiceConfig, MycTransportConfig,
};
pub use control::{MycAcceptedConnectionOutput, MycAuthorizedReplayOutput};
pub use error::MycError;