myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit dbb30705811dbb2762eb7bf308c4bc4da8d60bb5
parent 95873b5a8ddd5bb643ce1c4b7cf5f14a020f5554
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 02:24:00 +0000

cli: add audit summary views

- extend audit commands with configurable read limits and a summary output mode
- summarize signer request decisions and runtime operation outcomes for operators
- apply the configured audit read limit by default while preserving per-command overrides
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --check, cargo check --locked, and cargo test --locked

Diffstat:
Msrc/cli.rs | 284++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 276 insertions(+), 8 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -9,7 +10,7 @@ use radroots_nostr_signer::prelude::{ use serde::Serialize; use crate::app::MycRuntime; -use crate::audit::MycOperationAuditRecord; +use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::{DEFAULT_CONFIG_PATH, MycConfig}; use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; use crate::error::MycError; @@ -61,6 +62,16 @@ pub enum MycAuditCommand { connection_id: Option<String>, #[arg(long, value_enum, default_value_t = MycAuditScope::All)] scope: MycAuditScope, + #[arg(long)] + limit: Option<usize>, + }, + Summary { + #[arg(long)] + connection_id: Option<String>, + #[arg(long, value_enum, default_value_t = MycAuditScope::All)] + scope: MycAuditScope, + #[arg(long)] + limit: Option<usize>, }, } @@ -115,6 +126,32 @@ pub struct MycAuditListOutput { pub runtime_operation_audit: Vec<MycOperationAuditRecord>, } +#[derive(Debug, Default, Serialize, PartialEq, Eq)] +pub struct MycAuditDecisionCounts { + pub allowed: usize, + pub denied: usize, + pub challenged: usize, +} + +#[derive(Debug, Default, Serialize, PartialEq, Eq)] +pub struct MycOperationOutcomeCounts { + pub succeeded: usize, + pub rejected: usize, + pub restored: usize, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct MycAuditSummaryOutput { + pub record_limit: usize, + pub signer_request_total: usize, + pub signer_request_decisions: MycAuditDecisionCounts, + pub runtime_operation_total: usize, + pub runtime_operation_outcomes: MycOperationOutcomeCounts, + pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, + pub runtime_publish_rejection_count: usize, + pub runtime_replay_restore_count: usize, +} + pub async fn run_from_env() -> Result<(), MycError> { let cli = MycCli::parse(); let config = load_config(cli.config.as_deref())?; @@ -164,9 +201,29 @@ pub async fn run_from_env() -> Result<(), MycError> { MycAuditCommand::List { connection_id, scope, + limit, + } => { + let output = load_audit_output( + &runtime, + &manager, + connection_id.as_deref(), + scope, + limit, + )?; + print_json(&output) + } + MycAuditCommand::Summary { + connection_id, + scope, + limit, } => { - let output = - load_audit_output(&runtime, &manager, connection_id.as_deref(), scope)?; + let output = summarize_audit_output( + &runtime, + &manager, + connection_id.as_deref(), + scope, + limit, + )?; print_json(&output) } } @@ -233,19 +290,37 @@ fn load_audit_output( manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager, connection_id: Option<&str>, scope: MycAuditScope, + limit: Option<usize>, ) -> Result<MycAuditListOutput, MycError> { + let limit = audit_read_limit(runtime, limit); let connection_id = connection_id.map(parse_connection_id).transpose()?; let signer_request_audit = match (scope, connection_id.as_ref()) { (MycAuditScope::Operation, _) => Vec::new(), - (_, Some(connection_id)) => manager.audit_records_for_connection(connection_id)?, - (_, None) => manager.list_audit_records()?, + (_, Some(connection_id)) => manager + .audit_records_for_connection(connection_id)? + .into_iter() + .rev() + .take(limit) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(), + (_, None) => manager + .list_audit_records()? + .into_iter() + .rev() + .take(limit) + .collect::<Vec<_>>() + .into_iter() + .rev() + .collect(), }; let runtime_operation_audit = match (scope, connection_id.as_ref()) { (MycAuditScope::Request, _) => Vec::new(), (_, Some(connection_id)) => runtime .operation_audit_store() - .list_for_connection(connection_id)?, - (_, None) => runtime.operation_audit_store().list()?, + .list_for_connection_with_limit(connection_id, limit)?, + (_, None) => runtime.operation_audit_store().list_with_limit(limit)?, }; Ok(MycAuditListOutput { @@ -254,6 +329,87 @@ fn load_audit_output( }) } +fn summarize_audit_output( + runtime: &MycRuntime, + manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager, + connection_id: Option<&str>, + scope: MycAuditScope, + limit: Option<usize>, +) -> Result<MycAuditSummaryOutput, MycError> { + let record_limit = audit_read_limit(runtime, limit); + let audit = load_audit_output(runtime, manager, connection_id, scope, Some(record_limit))?; + let mut signer_request_decisions = MycAuditDecisionCounts::default(); + for record in &audit.signer_request_audit { + match record.decision { + radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => { + signer_request_decisions.allowed += 1; + } + radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Denied => { + signer_request_decisions.denied += 1; + } + radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => { + signer_request_decisions.challenged += 1; + } + } + } + + let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default(); + let mut runtime_operation_by_kind = BTreeMap::new(); + let mut runtime_publish_rejection_count = 0; + let mut runtime_replay_restore_count = 0; + for record in &audit.runtime_operation_audit { + increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome); + let key = operation_kind_label(record.operation); + increment_outcome_counts( + runtime_operation_by_kind.entry(key).or_default(), + record.outcome, + ); + if record.outcome == MycOperationAuditOutcome::Rejected { + runtime_publish_rejection_count += 1; + } + if record.operation == MycOperationAuditKind::AuthReplayRestore + && record.outcome == MycOperationAuditOutcome::Restored + { + runtime_replay_restore_count += 1; + } + } + + Ok(MycAuditSummaryOutput { + record_limit, + signer_request_total: audit.signer_request_audit.len(), + signer_request_decisions, + runtime_operation_total: audit.runtime_operation_audit.len(), + runtime_operation_outcomes, + runtime_operation_by_kind, + runtime_publish_rejection_count, + runtime_replay_restore_count, + }) +} + +fn audit_read_limit(runtime: &MycRuntime, limit: Option<usize>) -> usize { + limit.unwrap_or(runtime.operation_audit_store().config().default_read_limit) +} + +fn increment_outcome_counts( + counts: &mut MycOperationOutcomeCounts, + outcome: MycOperationAuditOutcome, +) { + match outcome { + MycOperationAuditOutcome::Succeeded => counts.succeeded += 1, + MycOperationAuditOutcome::Rejected => counts.rejected += 1, + MycOperationAuditOutcome::Restored => counts.restored += 1, + } +} + +fn operation_kind_label(kind: MycOperationAuditKind) -> String { + match kind { + MycOperationAuditKind::ListenerResponsePublish => "listener_response_publish".to_owned(), + MycOperationAuditKind::ConnectAcceptPublish => "connect_accept_publish".to_owned(), + MycOperationAuditKind::AuthReplayPublish => "auth_replay_publish".to_owned(), + MycOperationAuditKind::AuthReplayRestore => "auth_replay_restore".to_owned(), + } +} + fn print_json<T>(value: &T) -> Result<(), MycError> where T: Serialize, @@ -266,14 +422,16 @@ where mod tests { use std::path::PathBuf; + use nostr::Timestamp; use radroots_identity::RadrootsIdentity; use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest; use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; + use serde_json::json; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; use crate::config::MycConfig; - use super::{MycAuditScope, load_audit_output}; + use super::{MycAuditScope, load_audit_output, summarize_audit_output}; use crate::app::MycRuntime; fn write_identity(path: &std::path::Path, secret_key: &str) { @@ -286,6 +444,7 @@ mod tests { fn runtime() -> MycRuntime { let temp = tempfile::tempdir().expect("tempdir").keep(); let mut config = MycConfig::default(); + config.audit.default_read_limit = 2; config.paths.state_dir = PathBuf::from(&temp).join("state"); config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); @@ -334,6 +493,7 @@ mod tests { &manager, Some(connection.connection_id.as_str()), MycAuditScope::All, + None, ) .expect("load audit output"); @@ -344,4 +504,112 @@ mod tests { MycOperationAuditKind::AuthReplayRestore ); } + + #[test] + fn audit_summary_counts_recent_failures_and_restores() { + let runtime = runtime(); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + nostr::Keys::generate().public_key(), + runtime.user_public_identity(), + )) + .expect("register connection"); + + let denied = manager + .evaluate_request( + &connection.connection_id, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( + "request-1", + RadrootsNostrConnectRequest::SignEvent( + serde_json::from_value(json!({ + "pubkey": runtime.user_identity().public_key().to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": 1, + "tags": [], + "content": "hello" + })) + .expect("unsigned event"), + ), + ), + ) + .expect("denied request"); + let challenged = manager + .require_auth_challenge(&connection.connection_id, "https://auth.example") + .expect("require auth challenge"); + let challenged_eval = manager + .evaluate_request( + &challenged.connection_id, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( + "request-2", + RadrootsNostrConnectRequest::Ping, + ), + ) + .expect("challenged request"); + + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Rejected, + Some(&connection.connection_id), + Some("request-1"), + 1, + 0, + "listener publish rejected", + )); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::AuthReplayRestore, + MycOperationAuditOutcome::Restored, + Some(&connection.connection_id), + Some("request-2"), + 1, + 0, + "restored pending auth challenge after replay failure", + )); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ConnectAcceptPublish, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some("request-3"), + 1, + 1, + "publish succeeded", + )); + + let summary = summarize_audit_output( + &runtime, + &manager, + Some(connection.connection_id.as_str()), + MycAuditScope::All, + None, + ) + .expect("summary"); + + assert_eq!(summary.record_limit, 2); + assert_eq!(summary.signer_request_total, 2); + assert_eq!(summary.signer_request_decisions.denied, 1); + assert_eq!(summary.signer_request_decisions.challenged, 1); + assert_eq!(summary.runtime_operation_total, 2); + assert_eq!(summary.runtime_operation_outcomes.succeeded, 1); + assert_eq!(summary.runtime_operation_outcomes.restored, 1); + assert_eq!(summary.runtime_publish_rejection_count, 0); + assert_eq!(summary.runtime_replay_restore_count, 1); + assert_eq!( + summary + .runtime_operation_by_kind + .get("auth_replay_restore") + .expect("restore kind") + .restored, + 1 + ); + assert_eq!( + summary + .runtime_operation_by_kind + .get("connect_accept_publish") + .expect("connect kind") + .succeeded, + 1 + ); + assert_eq!(denied.audit.request_id.as_str(), "request-1"); + assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2"); + } }