feat(grpc): Export + Audit services over authenticated gRPC

Add authenticated Export (ExportMailbox -> mbox/eml-zip/json/jsonl written to a
local file, backed by the real ExportEngine + a scoped message query) and Audit
(ListRecords, VerifyChain over the WORM ledger with a structured report) gRPC
services, each mounted with the bearer interceptor like the others. Wire CLI
'mail export' and 'system audit list/verify'. Audit records expose the record
MAC, never the signing key. Offline tests cover export content/counts, chain
verify (intact + tampered), and auth rejection.

Refs #172
This commit is contained in:
James Maes committed 2026-07-27 07:09:06 -05:00
1 parent ffe6af655a
commit 7053d79f6b
8 files changed
+1763 -24

No files matched your search

+158
View File
@@ -302,6 +302,35 @@ pub enum MailSubcommand {
#[arg(long, help = "Mark the message as unread", conflicts_with = "read")]
unread: bool,
},
/// Export mailbox messages to a portable file format (backlog story
/// 2.B, GH #172): MBOX, an EML zip archive, JSON, or JSON Lines.
Export {
/// Export format (mbox, eml, json, or jsonl).
#[arg(
short = 'f',
long,
default_value = "mbox",
help = "Export format (mbox, eml, json, jsonl)"
)]
format: String,
/// Destination output file path, on the daemon's host filesystem.
#[arg(short = 'o', long, help = "Destination output file path")]
out: String,
/// Restrict the export to a single account's messages.
#[arg(
long,
help = "Restrict export to a single account ID",
conflicts_with = "folder"
)]
account: Option<String>,
/// Restrict the export to a single folder's messages.
#[arg(
long,
help = "Restrict export to a single folder ID",
conflicts_with = "account"
)]
folder: Option<String>,
},
}
/// Folder subcommands (`nuncio folder <verb>`).
@@ -328,6 +357,31 @@ pub enum CalSubcommand {
pub enum SystemSubcommand {
/// Display system, daemon, and event bus status.
Status,
/// WORM (Write Once, Read Many) tamper-evident audit ledger operations
/// (`nuncio system audit <verb>`, backlog story 2.B, GH #172).
Audit {
#[command(subcommand)]
action: AuditSubcommand,
},
}
/// Audit subcommands (`nuncio system audit <verb>`, backlog story 2.B,
/// GH #172).
#[derive(Subcommand, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuditSubcommand {
/// List persisted WORM audit ledger records, sequence ascending.
List {
/// Max records to fetch (0 = server default).
#[arg(short, long, default_value_t = 50, help = "Max records to fetch")]
limit: u32,
/// Pagination offset.
#[arg(short, long, default_value_t = 0, help = "Pagination offset")]
offset: u32,
},
/// Verify the WORM audit ledger's cryptographic HMAC hash-chain
/// integrity.
Verify,
}
/// Filter subcommands (`nuncio filter <verb>`).
@@ -499,6 +553,110 @@ mod tests {
assert!(conflict.is_err());
}
#[test]
fn parse_pure_noun_verb_mail_export_command() {
let cli_export = Cli::parse_from([
"nuncio",
"mail",
"export",
"--format",
"json",
"--out",
"/tmp/out.json",
]);
assert_eq!(
cli_export.command,
Commands::Mail {
action: MailSubcommand::Export {
format: "json".to_string(),
out: "/tmp/out.json".to_string(),
account: None,
folder: None,
}
}
);
let cli_export_scoped = Cli::parse_from([
"nuncio",
"mail",
"export",
"--format",
"jsonl",
"--out",
"/tmp/scoped.jsonl",
"--account",
"acct-1",
]);
assert_eq!(
cli_export_scoped.command,
Commands::Mail {
action: MailSubcommand::Export {
format: "jsonl".to_string(),
out: "/tmp/scoped.jsonl".to_string(),
account: Some("acct-1".to_string()),
folder: None,
}
}
);
// `--account` and `--folder` are mutually exclusive at the Clap
// level -- an export scope is either "one account", "one folder",
// or (neither flag) "everything".
let conflict = Cli::try_parse_from([
"nuncio",
"mail",
"export",
"--out",
"/tmp/out.json",
"--account",
"acct-1",
"--folder",
"inbox",
]);
assert!(conflict.is_err());
}
#[test]
fn parse_pure_noun_verb_system_audit_commands() {
let cli_list = Cli::parse_from(["nuncio", "system", "audit", "list"]);
assert_eq!(
cli_list.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 50,
offset: 0,
}
}
}
);
let cli_list_paged = Cli::parse_from([
"nuncio", "system", "audit", "list", "--limit", "10", "--offset", "5",
]);
assert_eq!(
cli_list_paged.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 10,
offset: 5,
}
}
}
);
let cli_verify = Cli::parse_from(["nuncio", "system", "audit", "verify"]);
assert_eq!(
cli_verify.command,
Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::Verify
}
}
);
}
#[test]
fn parse_pure_noun_verb_update_commands() {
let cli_check = Cli::parse_from(["nuncio", "update", "check"]);
+540 -2
View File
@@ -8,8 +8,8 @@ use std::sync::Arc;
use thiserror::Error;
use crate::args::{
AccountSubcommand, CalSubcommand, Commands, ContactSubcommand, FilterSubcommand,
FolderSubcommand, MailSubcommand, SystemSubcommand, UpdateSubcommand,
AccountSubcommand, AuditSubcommand, CalSubcommand, Commands, ContactSubcommand,
FilterSubcommand, FolderSubcommand, MailSubcommand, SystemSubcommand, UpdateSubcommand,
};
use crate::output::{format_json, format_json_error};
@@ -57,6 +57,34 @@ fn message_proto_to_json(message: &nuncio_proto::v1::Message) -> serde_json::Val
})
}
/// Maps a `nuncio_core::export::ExportFormat` onto its wire-format
/// `nuncio.v1.ExportFormat` enum value, mirroring `nunciod::grpc`'s
/// server-side mapping (backlog story 2.B, GH #172).
fn map_export_format_to_proto(format: nuncio_core::ExportFormat) -> nuncio_proto::v1::ExportFormat {
match format {
nuncio_core::ExportFormat::Mbox => nuncio_proto::v1::ExportFormat::Mbox,
nuncio_core::ExportFormat::EmlZip => nuncio_proto::v1::ExportFormat::EmlZip,
nuncio_core::ExportFormat::Json => nuncio_proto::v1::ExportFormat::Json,
nuncio_core::ExportFormat::JsonLines => nuncio_proto::v1::ExportFormat::Jsonl,
}
}
/// Renders a `nuncio.v1.AuditRecord` (as returned by the daemon's `Audit`
/// gRPC service, backlog story 2.B / GH #172) into the JSON shape used by
/// `system audit list`'s `--json` output. `record_hmac` is a verification
/// MAC output, never secret key material, so it is safe to include here.
fn audit_record_proto_to_json(record: &nuncio_proto::v1::AuditRecord) -> serde_json::Value {
json!({
"sequence": record.sequence,
"timestamp_ns": record.timestamp_ns,
"actor": record.actor,
"action": record.action,
"data_hash": record.data_hash,
"previous_block_hash": record.previous_block_hash,
"record_hmac": record.record_hmac,
})
}
/// Renders a `nuncio.v1.FilterRule` (as returned by the daemon's `Filters`
/// gRPC service, backlog story 2.A / GH #171) into the JSON shape used by
/// `filter list`/`filter create`'s `--json` output.
@@ -236,6 +264,21 @@ impl HeadlessRunner {
MailSubcommand::Mark { id, read, unread } => {
self.handle_mark_read(id, *read, *unread, json_mode).await
}
MailSubcommand::Export {
format,
out,
account,
folder,
} => {
self.handle_mail_export(
format,
out,
account.as_deref(),
folder.as_deref(),
json_mode,
)
.await
}
},
Commands::Banner => {
crate::output::print_splash_banner();
@@ -319,6 +362,12 @@ impl HeadlessRunner {
},
Commands::System { action } => match action {
SystemSubcommand::Status => self.handle_system_status(json_mode).await,
SystemSubcommand::Audit { action } => match action {
AuditSubcommand::List { limit, offset } => {
self.handle_audit_list(*limit, *offset, json_mode).await
}
AuditSubcommand::Verify => self.handle_audit_verify(json_mode).await,
},
},
Commands::Contact { action } => match action {
ContactSubcommand::List => {
@@ -941,6 +990,191 @@ impl HeadlessRunner {
}
}
/// `mail export`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Export` API (backlog story 2.B, GH #172). The
/// daemon writes the export to `out` on ITS OWN host filesystem (the
/// same host this CLI runs on, in this local deployment) using the real
/// `nuncio_core::export::ExportEngine`, and reports back the real
/// message/byte counts it actually wrote -- never a fabricated
/// summary. `account`/`folder` are mutually exclusive (enforced both by
/// Clap's `conflicts_with` and the daemon's own oneof `scope`); leaving
/// both unset exports every message.
async fn handle_mail_export(
&self,
format: &str,
out: &str,
account: Option<&str>,
folder: Option<&str>,
json_mode: bool,
) -> String {
let export_format = match format.parse::<nuncio_core::ExportFormat>() {
Ok(f) => f,
Err(e) => return Self::render_error(&e, json_mode),
};
let mut client = match self.connect_export_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
let scope = if let Some(account_id) = account {
Some(nuncio_proto::v1::export_request::Scope::AccountId(
account_id.to_string(),
))
} else {
folder.map(|folder_id| {
nuncio_proto::v1::export_request::Scope::FolderId(folder_id.to_string())
})
};
match client
.export_mailbox(nuncio_proto::v1::ExportRequest {
scope,
format: map_export_format_to_proto(export_format).into(),
output_path: out.to_string(),
})
.await
{
Ok(response) => {
let resp = response.into_inner();
if json_mode {
format_json(&json!({
"output_path": resp.output_path,
"message_count": resp.message_count,
"bytes_written": resp.bytes_written,
}))
} else {
format!(
"Exported {} message(s) to '{}' ({} bytes)",
resp.message_count, resp.output_path, resp.bytes_written
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected export_mailbox: {status}"),
json_mode,
),
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Export` service at `self.grpc_addr` (backlog story 2.B,
/// GH #172), shared by [`Self::handle_mail_export`].
async fn connect_export_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedExportClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_export(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// `system audit list`: a real thin gRPC client of the running
/// `nunciod` daemon's `nuncio.v1.Audit` API (backlog story 2.B,
/// GH #172). Lists a page of the daemon's real, persisted WORM audit
/// ledger, sequence ascending.
async fn handle_audit_list(&self, limit: u32, offset: u32, json_mode: bool) -> String {
let mut client = match self.connect_audit_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_records(nuncio_proto::v1::ListRecordsRequest { limit, offset })
.await
{
Ok(response) => {
let records = response.into_inner().records;
if json_mode {
let records_json: Vec<serde_json::Value> =
records.iter().map(audit_record_proto_to_json).collect();
format_json(&json!({ "records": records_json }))
} else if records.is_empty() {
"No audit records recorded.".to_string()
} else {
let mut out = String::from(
"SEQ ACTOR ACTION TIMESTAMP_NS\n",
);
for r in records {
out.push_str(&format!(
"{:<4} {:<20} {:<20} {}\n",
r.sequence, r.actor, r.action, r.timestamp_ns
));
}
out
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_records: {status}"),
json_mode,
),
}
}
/// `system audit verify`: a real thin gRPC client of the running
/// `nunciod` daemon's `nuncio.v1.Audit` API (backlog story 2.B,
/// GH #172). Re-verifies the ENTIRE persisted WORM audit ledger's
/// HMAC hash-chain integrity server-side, using the ledger's real WORM
/// HMAC key -- never fabricates a `valid` verdict.
async fn handle_audit_verify(&self, json_mode: bool) -> String {
let mut client = match self.connect_audit_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.verify_chain(nuncio_proto::v1::VerifyChainRequest {})
.await
{
Ok(response) => {
let resp = response.into_inner();
if json_mode {
format_json(&json!({
"valid": resp.valid,
"record_count": resp.record_count,
"first_broken_seq": resp.first_broken_seq,
}))
} else if resp.valid {
format!("Audit chain OK: {} record(s) verified.", resp.record_count)
} else {
format!(
"Audit chain TAMPERED: first broken at sequence {}.",
resp.first_broken_seq
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string())
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected verify_chain: {status}"),
json_mode,
),
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Audit` service at `self.grpc_addr` (backlog story 2.B,
/// GH #172), shared by [`Self::handle_audit_list`] /
/// [`Self::handle_audit_verify`].
async fn connect_audit_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedAuditClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_audit(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// `filter list`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Filters` API (backlog story 2.A, GH #171). Lists
/// every persisted filter rule from the daemon's real, persistent
@@ -2447,6 +2681,310 @@ mod tests {
assert!(out.contains("unreachable"));
}
/// Reference-client proof for backlog story 2.B (GH #172): boots a stub
/// `nuncio.v1.Export` gRPC server (mirroring
/// `system_status_reports_live_daemon_status_over_grpc_when_reachable`'s
/// `StubSystem` pattern above), recording the exact `ExportRequest` it
/// received, and drives the real `HeadlessRunner`'s `mail export` gRPC
/// client path against it. Real message/byte counts and file writing
/// are proven end-to-end by `nunciod`'s own `grpc::tests`; this test
/// exists purely to prove the CLI's connect + call + scope-mapping +
/// JSON-format happy path.
#[tokio::test]
async fn mail_export_round_trips_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::export_server::{Export as ExportService, ExportServer};
use nuncio_proto::v1::{ExportRequest, ExportResponse};
use std::sync::Mutex;
#[derive(Default)]
struct StubExport {
last_request: Arc<Mutex<Option<ExportRequest>>>,
}
#[tonic::async_trait]
impl ExportService for StubExport {
async fn export_mailbox(
&self,
request: tonic::Request<ExportRequest>,
) -> Result<tonic::Response<ExportResponse>, tonic::Status> {
let req = request.into_inner();
let output_path = req.output_path.clone();
*self.last_request.lock().unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(ExportResponse {
output_path,
message_count: 7,
bytes_written: 4096,
}))
}
}
let last_request = Arc::new(Mutex::new(None));
let stub = StubExport {
last_request: last_request.clone(),
};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(ExportServer::new(stub))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
let out = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Export {
format: "jsonl".to_string(),
out: "/tmp/stub-export.jsonl".to_string(),
account: Some("acct-stub-1".to_string()),
folder: None,
},
},
true,
)
.await;
assert!(out.contains(r#""message_count":7"#));
assert!(out.contains(r#""bytes_written":4096"#));
assert!(out.contains("stub-export.jsonl"));
let recorded = last_request
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received an export_mailbox request");
assert_eq!(recorded.output_path, "/tmp/stub-export.jsonl");
assert_eq!(recorded.format(), nuncio_proto::v1::ExportFormat::Jsonl);
assert_eq!(
recorded.scope,
Some(nuncio_proto::v1::export_request::Scope::AccountId(
"acct-stub-1".to_string()
))
);
let text_out = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Export {
format: "mbox".to_string(),
out: "/tmp/stub-export-2.mbox".to_string(),
account: None,
folder: Some("inbox".to_string()),
},
},
false,
)
.await;
assert!(text_out.contains("Exported 7 message(s)"));
let recorded2 = last_request
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received a second export_mailbox request");
assert_eq!(
recorded2.scope,
Some(nuncio_proto::v1::export_request::Scope::FolderId(
"inbox".to_string()
))
);
}
#[tokio::test]
async fn mail_export_rejects_an_unknown_format_before_dialing_the_daemon() {
// An unreachable address deliberately proves the format is
// validated BEFORE the daemon is ever dialed -- if this connected
// first, the error would be a transport failure instead of the
// expected format-parsing error.
let addr = reserve_unreachable_addr().await;
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
.await
.expect("ephemeral runner initializes");
let out = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Export {
format: "not-a-real-format".to_string(),
out: "/tmp/out.json".to_string(),
account: None,
folder: None,
},
},
true,
)
.await;
assert!(out.contains(r#""status":"error""#));
assert!(out.contains("Unknown export format"));
}
/// Reference-client proof for backlog story 2.B (GH #172): boots a stub
/// `nuncio.v1.Audit` gRPC server and drives the real `HeadlessRunner`'s
/// `system audit list` / `system audit verify` gRPC client paths
/// against it. Real ledger seeding, chain verification, and the
/// tampered-chain case are proven end-to-end by `nunciod`'s own
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
/// call + JSON-format happy path.
#[tokio::test]
async fn system_audit_list_and_verify_round_trip_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::audit_server::{Audit as AuditService, AuditServer};
use nuncio_proto::v1::{
AuditRecord, ListRecordsRequest, ListRecordsResponse, VerifyChainRequest,
VerifyChainResponse,
};
struct StubAudit;
#[tonic::async_trait]
impl AuditService for StubAudit {
async fn list_records(
&self,
_request: tonic::Request<ListRecordsRequest>,
) -> Result<tonic::Response<ListRecordsResponse>, tonic::Status> {
Ok(tonic::Response::new(ListRecordsResponse {
records: vec![AuditRecord {
sequence: 1,
timestamp_ns: 1_700_000_000_000_000_000,
actor: "system.test".to_string(),
action: "data.export".to_string(),
data_hash: "deadbeef".to_string(),
previous_block_hash: "GENESIS".to_string(),
record_hmac: "cafebabe".to_string(),
}],
}))
}
async fn verify_chain(
&self,
_request: tonic::Request<VerifyChainRequest>,
) -> Result<tonic::Response<VerifyChainResponse>, tonic::Status> {
Ok(tonic::Response::new(VerifyChainResponse {
valid: false,
record_count: 3,
first_broken_seq: Some(2),
}))
}
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(AuditServer::new(StubAudit))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
let list_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 0,
offset: 0,
},
},
},
true,
)
.await;
assert!(list_out.contains(r#""action":"data.export""#));
assert!(list_out.contains(r#""sequence":1"#));
let verify_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::Verify,
},
},
true,
)
.await;
assert!(verify_out.contains(r#""valid":false"#));
assert!(verify_out.contains(r#""first_broken_seq":2"#));
let verify_text = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::Verify,
},
},
false,
)
.await;
assert!(verify_text.contains("TAMPERED"));
assert!(verify_text.contains("sequence 2"));
}
#[tokio::test]
async fn mail_export_and_system_audit_report_honest_error_when_daemon_unreachable() {
let addr = reserve_unreachable_addr().await;
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr)
.await
.expect("ephemeral runner initializes");
let export_out = runner
.execute_command(
&Commands::Mail {
action: MailSubcommand::Export {
format: "json".to_string(),
out: "/tmp/out.json".to_string(),
account: None,
folder: None,
},
},
true,
)
.await;
assert!(export_out.contains(r#""status":"error""#));
assert!(export_out.contains("unreachable"));
let audit_list_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::List {
limit: 0,
offset: 0,
},
},
},
true,
)
.await;
assert!(audit_list_out.contains(r#""status":"error""#));
assert!(audit_list_out.contains("unreachable"));
let audit_verify_out = runner
.execute_command(
&Commands::System {
action: SystemSubcommand::Audit {
action: AuditSubcommand::Verify,
},
},
true,
)
.await;
assert!(audit_verify_out.contains(r#""status":"error""#));
assert!(audit_verify_out.contains("unreachable"));
}
#[test]
fn runner_error_display() {
let err = RunnerError::InitFailed("failed to open database".to_string());
@@ -581,3 +581,144 @@ message PreviewRuleResponse {
// Human-readable step-by-step match traces.
repeated string condition_traces = 7;
}
// Export exposes the daemon's real mailbox export engine
// (`nuncio_core::export`, via `DatabaseEngine::export_messages_to_file`)
// over authenticated gRPC (backlog story 2.B, GH #172). The daemon and its
// caller run on the SAME host (a local, single-machine deployment), so
// writing the export to a filesystem path on that shared host -- rather
// than streaming potentially very large export bytes back over the RPC
// itself -- is both simpler and honest about where the data actually ends
// up.
//
// SECURITY (GH #165): this service MUST be mounted behind the SAME
// `BearerAuthInterceptor` that guards `System`/`Accounts`/`Mail`/`Filters`
// -- every gRPC service `nunciod` exposes MUST be authenticated, with no
// exceptions carved out for "just one more" unauthenticated service. See
// `nunciod::grpc::serve_on_listener`.
service Export {
// ExportMailbox writes every message in `scope` to `output_path` in
// `format`, using the real `nuncio_core::export::ExportEngine`. Returns
// real message/byte counts read back from the file actually written --
// never fabricated. Also appends a WORM audit record for the export (see
// `Audit`).
rpc ExportMailbox(ExportRequest) returns (ExportResponse);
}
// Mirrors `nuncio_core::export::ExportFormat`.
enum ExportFormat {
EXPORT_FORMAT_UNSPECIFIED = 0;
// RFC 4155 MBOX format (.mbox).
EXPORT_FORMAT_MBOX = 1;
// Zip archive of individual .eml MIME files (.zip).
EXPORT_FORMAT_EML_ZIP = 2;
// Structured JSON array (.json).
EXPORT_FORMAT_JSON = 3;
// JSON Lines, one message per line (.jsonl).
EXPORT_FORMAT_JSONL = 4;
}
// ExportRequest. `scope` selects which messages to export -- absent (no
// oneof case set) means every message across every account and folder;
// setting `account_id` or `folder_id` narrows the export to just that
// account's or that folder's messages. These are the only scopes the store
// can currently filter export by -- there is deliberately no broader query
// scope here (e.g. an arbitrary NSQL filter) since the underlying store has
// no such filtered read path to back one honestly.
message ExportRequest {
oneof scope {
// Restrict the export to a single account's messages.
string account_id = 1;
// Restrict the export to a single folder's messages.
string folder_id = 2;
}
// Export output format.
ExportFormat format = 3;
// Absolute filesystem path to write the export to, on the SAME host the
// `nunciod` daemon runs on (see `Export`'s doc comment).
string output_path = 4;
}
// ExportResponse reports the REAL result of writing `output_path` -- never
// a fabricated count.
message ExportResponse {
// The path written to (echoes `ExportRequest.output_path`).
string output_path = 1;
// Number of messages actually written.
uint64 message_count = 2;
// Total bytes actually written to `output_path`.
uint64 bytes_written = 3;
}
// Audit exposes the daemon's real WORM (Write Once, Read Many) HMAC-chained
// tamper-evident audit ledger (`nuncio_core::worm_audit`, via
// `DatabaseEngine`'s `worm_audit_records` table) over authenticated gRPC
// (backlog story 2.B, GH #172). This is a read/verify-only surface --
// there is deliberately no RPC to create or mutate records here, since the
// ledger is written internally by the daemon as a side effect of other
// operations (e.g. `Export.ExportMailbox`) and the underlying SQLite table
// itself rejects `UPDATE`/`DELETE` at the schema level.
//
// SECURITY (GH #165): this service MUST be mounted behind the SAME
// `BearerAuthInterceptor` that guards every other service `nunciod`
// exposes -- see `nunciod::grpc::serve_on_listener`.
service Audit {
// ListRecords returns a page of the real, persisted WORM audit ledger,
// sequence ascending.
rpc ListRecords(ListRecordsRequest) returns (ListRecordsResponse);
// VerifyChain re-verifies the ENTIRE persisted WORM audit ledger's
// HMAC hash-chain integrity, using the ledger's real WORM HMAC key
// (provisioned once from the OS keyring vault at daemon startup). Never
// fabricates `valid: true`.
rpc VerifyChain(VerifyChainRequest) returns (VerifyChainResponse);
}
// AuditRecord mirrors `nuncio_core::WormAuditRecord` field-for-field.
// `record_hmac` is this record's verification MAC (a derived signature
// output), NOT secret key material -- the WORM HMAC signing key itself is
// never exposed over this or any other RPC.
message AuditRecord {
// Monotonically increasing sequence index.
uint64 sequence = 1;
// UTC timestamp in nanoseconds since Unix epoch.
int64 timestamp_ns = 2;
// Subsystem or actor that performed the audited action.
string actor = 3;
// Action identifier (e.g. "data.export").
string action = 4;
// SHA-256 hash of the audited payload.
string data_hash = 5;
// Previous record's HMAC signature ("GENESIS" for sequence 1).
string previous_block_hash = 6;
// HMAC-SHA256 signature sealing this record.
string record_hmac = 7;
}
// ListRecordsRequest. `limit` caps the number of records returned
// (sequence ascending); a value of 0 means "use the server's default
// limit".
message ListRecordsRequest {
uint32 limit = 1;
uint32 offset = 2;
}
// ListRecordsResponse.
message ListRecordsResponse {
repeated AuditRecord records = 1;
}
// VerifyChainRequest currently carries no parameters.
message VerifyChainRequest {}
// VerifyChainResponse reports the REAL result of re-verifying the entire
// persisted WORM audit ledger's hash chain -- never a fabricated `valid`.
message VerifyChainResponse {
// Whether every persisted record's HMAC AND inter-record chain linkage
// verified.
bool valid = 1;
// Total number of records checked.
uint64 record_count = 2;
// Present only when `valid` is false: the sequence number of the first
// record whose HMAC or chain linkage failed to verify.
optional uint64 first_broken_seq = 3;
}
+83
View File
@@ -10,6 +10,8 @@
//! server-side interceptor in `nunciod::grpc`.
use crate::v1::accounts_client::AccountsClient;
use crate::v1::audit_client::AuditClient;
use crate::v1::export_client::ExportClient;
use crate::v1::filters_client::FiltersClient;
use crate::v1::mail_client::MailClient;
use crate::v1::system_client::SystemClient;
@@ -100,6 +102,16 @@ pub type AuthenticatedMailClient = MailClient<InterceptedService<Channel, Bearer
pub type AuthenticatedFiltersClient =
FiltersClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// The authenticated `nuncio.v1.Export` client type returned by
/// [`connect_export`].
pub type AuthenticatedExportClient =
ExportClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// The authenticated `nuncio.v1.Audit` client type returned by
/// [`connect_audit`].
pub type AuthenticatedAuditClient =
AuditClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
/// token instead) and returns the connected [`Channel`], shared by every
@@ -189,6 +201,43 @@ pub async fn connect_filters(
Ok(FiltersClient::with_interceptor(channel, interceptor))
}
/// Dials the `nuncio.v1.Export` gRPC endpoint at `addr` and returns a
/// client that injects `authorization: Bearer <token>` metadata on every
/// call (backlog story 2.B, GH #172).
///
/// `Export` is guarded by the exact same `BearerAuthInterceptor` as every
/// other service on the server side (see `nunciod::grpc::serve_on_listener`,
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
/// [`connect_system`] / [`connect_accounts`] / [`connect_mail`] /
/// [`connect_filters`] rather than hand-rolling yet another auth handshake.
pub async fn connect_export(
addr: &str,
token: &str,
) -> Result<AuthenticatedExportClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(ExportClient::with_interceptor(channel, interceptor))
}
/// Dials the `nuncio.v1.Audit` gRPC endpoint at `addr` and returns a
/// client that injects `authorization: Bearer <token>` metadata on every
/// call (backlog story 2.B, GH #172).
///
/// `Audit` is guarded by the exact same `BearerAuthInterceptor` as every
/// other service on the server side (see `nunciod::grpc::serve_on_listener`,
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
/// [`connect_system`] / [`connect_accounts`] / [`connect_mail`] /
/// [`connect_filters`] / [`connect_export`] rather than hand-rolling yet
/// another auth handshake.
pub async fn connect_audit(
addr: &str,
token: &str,
) -> Result<AuthenticatedAuditClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(AuditClient::with_interceptor(channel, interceptor))
}
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
/// authenticated client (as returned by [`connect_system`]), reusing the
/// bearer-token interceptor already attached to `client` rather than
@@ -314,6 +363,40 @@ mod tests {
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_export_fails_closed_on_invalid_token() {
let err = connect_export("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_export_reports_transport_error_when_daemon_unreachable() {
let err = connect_export("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_audit_fails_closed_on_invalid_token() {
let err = connect_audit("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_audit_reports_transport_error_when_daemon_unreachable() {
let err = connect_audit("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_system_reports_invalid_endpoint_for_a_malformed_address() {
// An embedded space is not a legal URI authority character, so
+274
View File
@@ -115,6 +115,20 @@ fn resolve_engine_keys(
Ok((storage_key, worm_key, ledger_key))
}
/// Structured result of re-verifying the entire persisted WORM audit
/// ledger's hash chain (backlog story 2.B, GH #172). See
/// [`DatabaseEngine::verify_worm_audit_chain_report`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WormChainReport {
/// Whether every persisted record's HMAC and inter-record chain linkage
/// verified.
pub valid: bool,
/// Total number of records checked.
pub record_count: usize,
/// Sequence number of the first record that failed to verify, if any.
pub first_broken_seq: Option<u64>,
}
impl DatabaseEngine {
/// Maximum concurrent read/write pool size.
pub const MAX_CONNECTIONS: u32 = 16;
@@ -1276,6 +1290,81 @@ impl DatabaseEngine {
.collect())
}
/// Query messages across the WHOLE store for export purposes (backlog
/// story 2.B, GH #172), optionally narrowed to a single account or a
/// single folder. Passing `None` for both returns every message in the
/// store. Ordered by `id` ascending, mirroring [`Self::get_message_chunk`]'s
/// deterministic keyset ordering.
#[allow(clippy::type_complexity)]
pub async fn list_messages_for_export(
&self,
account_id: Option<&str>,
folder_id: Option<&str>,
) -> Result<Vec<nuncio_core::model::Email>, DatabaseError> {
let mut builder: sqlx::QueryBuilder<'_, sqlx::Sqlite> = sqlx::QueryBuilder::new(
"SELECT id, account_id, folder_id, subject, sender, recipient, received_at, read_flag, body_plain, body_html FROM messages"
);
let mut has_filter = false;
if let Some(account_id) = account_id {
builder.push(" WHERE account_id = ");
builder.push_bind(account_id);
has_filter = true;
}
if let Some(folder_id) = folder_id {
builder.push(if has_filter {
" AND folder_id = "
} else {
" WHERE folder_id = "
});
builder.push_bind(folder_id);
}
builder.push(" ORDER BY id ASC");
let query = builder.build_query_as::<(
String,
String,
String,
String,
String,
String,
i64,
i64,
Option<String>,
Option<String>,
)>();
let rows = query
.fetch_all(&self.pool)
.await
.map_err(DatabaseError::Query)?;
Ok(rows
.into_iter()
.map(|r| {
let dec_plain = r.8.map(|p| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &p)
});
let dec_html = r.9.map(|h| {
crate::cipher::PayloadCipher::decrypt_text_at_rest(&self.storage_key, &h)
});
nuncio_core::model::Email {
id: r.0,
account_id: r.1,
folder_id: r.2,
subject: r.3,
sender: r.4,
recipient: r.5,
received_at: r.6,
read: r.7 != 0,
body_plain: dec_plain,
body_html: dec_html,
attachments: Vec::new(),
}
})
.collect())
}
/// Append a new immutable WORM audit record to the log ledger, signed with the WORM
/// HMAC key provisioned for this engine from the secret vault.
pub async fn append_worm_audit_record(
@@ -1365,6 +1454,37 @@ impl DatabaseEngine {
.map_err(|e| DatabaseError::ChainIntegrityFailed(e.to_string()))
}
/// Re-verify the entire WORM audit ledger and report a structured
/// result (backlog story 2.B, GH #172), rather than the opaque
/// pass/fail `Result` [`Self::verify_worm_audit_chain`] returns -- so
/// callers (e.g. the `Audit/VerifyChain` gRPC RPC) can honestly report
/// exactly how many records were checked and, if the chain is broken,
/// exactly which sequence broke it. `first_broken_seq` is populated ONLY
/// when the underlying failure is a genuine
/// `WormAuditError::TamperingDetected` (a specific record's HMAC or
/// chain linkage did not verify) -- never guessed. Any other failure
/// (e.g. a signing error) fails closed as `Err`, never reported as a
/// fabricated `valid: false`.
pub async fn verify_worm_audit_chain_report(&self) -> Result<WormChainReport, DatabaseError> {
let records = self.list_worm_audit_records(100_000, 0).await?;
let record_count = records.len();
match nuncio_core::verify_worm_chain(&records, &self.worm_key) {
Ok(()) => Ok(WormChainReport {
valid: true,
record_count,
first_broken_seq: None,
}),
Err(nuncio_core::WormAuditError::TamperingDetected { sequence, .. }) => {
Ok(WormChainReport {
valid: false,
record_count,
first_broken_seq: Some(sequence),
})
}
Err(e) => Err(DatabaseError::ChainIntegrityFailed(e.to_string())),
}
}
/// Export a collection of email messages to a target output path in the requested portable format.
pub async fn export_messages_to_file(
&self,
@@ -2043,4 +2163,158 @@ mod tests {
"an independently-vaulted engine must not validate another vault's ledger chain"
);
}
fn export_test_email(id: &str, account_id: &str, folder_id: &str) -> nuncio_core::model::Email {
nuncio_core::model::Email {
id: id.to_string(),
account_id: account_id.to_string(),
folder_id: folder_id.to_string(),
subject: format!("Subject {id}"),
sender: "alice@nuncio.mx".to_string(),
recipient: "bob@nuncio.mx".to_string(),
received_at: 1_700_000_000,
read: false,
body_plain: Some(format!("Body {id}")),
body_html: None,
attachments: Vec::new(),
}
}
#[tokio::test]
async fn list_messages_for_export_with_no_filters_returns_every_message() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
engine
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
.await
.unwrap();
engine
.save_email(&export_test_email("msg-2", "acct-b", "archive"))
.await
.unwrap();
let all = engine.list_messages_for_export(None, None).await.unwrap();
assert_eq!(all.len(), 2);
assert_eq!(all[0].id, "msg-1");
assert_eq!(all[1].id, "msg-2");
}
#[tokio::test]
async fn list_messages_for_export_filters_by_account_id() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
engine
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
.await
.unwrap();
engine
.save_email(&export_test_email("msg-2", "acct-b", "inbox"))
.await
.unwrap();
let scoped = engine
.list_messages_for_export(Some("acct-a"), None)
.await
.unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].id, "msg-1");
}
#[tokio::test]
async fn list_messages_for_export_filters_by_folder_id() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
engine
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
.await
.unwrap();
engine
.save_email(&export_test_email("msg-2", "acct-a", "archive"))
.await
.unwrap();
let scoped = engine
.list_messages_for_export(None, Some("archive"))
.await
.unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].id, "msg-2");
}
#[tokio::test]
async fn list_messages_for_export_filters_by_account_and_folder_together() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
engine
.save_email(&export_test_email("msg-1", "acct-a", "inbox"))
.await
.unwrap();
engine
.save_email(&export_test_email("msg-2", "acct-a", "archive"))
.await
.unwrap();
engine
.save_email(&export_test_email("msg-3", "acct-b", "inbox"))
.await
.unwrap();
let scoped = engine
.list_messages_for_export(Some("acct-a"), Some("inbox"))
.await
.unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].id, "msg-1");
}
#[tokio::test]
async fn verify_worm_audit_chain_report_reports_valid_on_an_intact_chain() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
engine
.append_worm_audit_record("system.test", "action.one", b"payload-1")
.await
.unwrap();
engine
.append_worm_audit_record("system.test", "action.two", b"payload-2")
.await
.unwrap();
let report = engine.verify_worm_audit_chain_report().await.unwrap();
assert!(report.valid);
assert_eq!(report.record_count, 2);
assert_eq!(report.first_broken_seq, None);
}
#[tokio::test]
async fn verify_worm_audit_chain_report_reports_empty_chain_as_valid() {
let (engine, _dir) = DatabaseEngine::connect_ephemeral().await.unwrap();
let report = engine.verify_worm_audit_chain_report().await.unwrap();
assert!(report.valid);
assert_eq!(report.record_count, 0);
assert_eq!(report.first_broken_seq, None);
}
#[tokio::test]
async fn verify_worm_audit_chain_report_reports_first_broken_seq_on_a_vault_key_mismatch() {
let dir = tempfile::tempdir().expect("tempdir");
let db_path = dir.path().join("worm_report_mismatch_test.sqlite");
let secrets_a = crate::vault::SecretManager::mock();
let engine_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
.await
.expect("engine_a connects");
engine_a
.append_worm_audit_record("system.test", "action.one", b"payload-1")
.await
.expect("record signed with engine_a's vault key");
engine_a.close().await;
let secrets_b = crate::vault::SecretManager::mock();
let engine_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
.await
.expect("engine_b connects to the same file with an independent vault");
let report = engine_b
.verify_worm_audit_chain_report()
.await
.expect("verification query succeeds (an invalid chain is a normal result)");
assert!(!report.valid);
assert_eq!(report.record_count, 1);
assert_eq!(report.first_broken_seq, Some(1));
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ pub mod search;
pub mod vault;
pub use cipher::{CipherError, PayloadCipher};
pub use db::{DatabaseEngine, DatabaseError};
pub use db::{DatabaseEngine, DatabaseError, WormChainReport};
pub use recovery::{
is_sqlite_corruption_error, CorruptedBackupManager, RecoverySummary, SqliteRecoveryEngine,
};
+564 -19
View File
@@ -11,23 +11,27 @@ use nuncio_core::{CoreCommand, CoreEvent, EventBus};
use nuncio_filter::{FilterEngine, NsqlParser, NsqlValidator, ValidationOptions};
use nuncio_mail::{MailBackend, MessageSender};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::audit_server::{Audit, AuditServer};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::export_server::{Export, ExportServer};
use nuncio_proto::v1::filters_server::{Filters, FiltersServer};
use nuncio_proto::v1::mail_server::{Mail, MailServer};
use nuncio_proto::v1::system_server::{System, SystemServer};
use nuncio_proto::v1::{
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto, BatchFilterProgress,
CreateRuleRequest, CreateRuleResponse, DatabaseRecovered, DeleteRuleRequest,
DeleteRuleResponse, Event, EventError, FilterExecuted, FilterRule as FilterRuleProto,
Folder as FolderProto, GetMessageRequest, GetMessageResponse, GetStatusRequest,
GetStatusResponse, ListAccountsRequest, ListAccountsResponse, ListFoldersRequest,
ListFoldersResponse, ListMessagesRequest, ListMessagesResponse, ListRulesRequest,
ListRulesResponse, MarkReadRequest, MarkReadResponse, Message as MessageProto,
MessageFlagsChanged, MessageSearchHit, PreviewRuleRequest, PreviewRuleResponse,
SearchMessagesRequest, SearchMessagesResponse, SendMessageRequest, SendMessageResponse,
ShuttingDown, SubscribeRequest, SyncCompleted, SyncRequest, SyncResponse, SyncStarted,
TlsMode as TlsModeProto, UpdateAvailable, ValidateRuleRequest, ValidateRuleResponse,
export_request, AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, Attachment as AttachmentProto,
AuditRecord as AuditRecordProto, BatchFilterProgress, CreateRuleRequest, CreateRuleResponse,
DatabaseRecovered, DeleteRuleRequest, DeleteRuleResponse, Event, EventError,
ExportFormat as ExportFormatProto, ExportRequest, ExportResponse, FilterExecuted,
FilterRule as FilterRuleProto, Folder as FolderProto, GetMessageRequest, GetMessageResponse,
GetStatusRequest, GetStatusResponse, ListAccountsRequest, ListAccountsResponse,
ListFoldersRequest, ListFoldersResponse, ListMessagesRequest, ListMessagesResponse,
ListRecordsRequest, ListRecordsResponse, ListRulesRequest, ListRulesResponse, MarkReadRequest,
MarkReadResponse, Message as MessageProto, MessageFlagsChanged, MessageSearchHit,
PreviewRuleRequest, PreviewRuleResponse, SearchMessagesRequest, SearchMessagesResponse,
SendMessageRequest, SendMessageResponse, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncRequest, SyncResponse, SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
ValidateRuleRequest, ValidateRuleResponse, VerifyChainRequest, VerifyChainResponse,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::search::SearchEngine;
@@ -906,6 +910,157 @@ impl Filters for FiltersGrpcService {
}
}
/// Maps a wire-format `nuncio.v1.ExportFormat` enum value back onto
/// `nuncio_core::ExportFormat`. `Unspecified` is rejected rather than
/// silently defaulting to a format the caller never asked for.
fn map_export_format_from_proto(
format: ExportFormatProto,
) -> Result<nuncio_core::ExportFormat, Status> {
match format {
ExportFormatProto::Mbox => Ok(nuncio_core::ExportFormat::Mbox),
ExportFormatProto::EmlZip => Ok(nuncio_core::ExportFormat::EmlZip),
ExportFormatProto::Json => Ok(nuncio_core::ExportFormat::Json),
ExportFormatProto::Jsonl => Ok(nuncio_core::ExportFormat::JsonLines),
ExportFormatProto::Unspecified => {
Err(Status::invalid_argument("export format is required"))
}
}
}
/// `nuncio.v1.Export` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] (backlog story 2.B, GH #172): loads messages via
/// [`DatabaseEngine::list_messages_for_export`] (scoped by `account_id`,
/// `folder_id`, or unscoped for "every message"), then writes them to
/// `output_path` on the local host via
/// [`DatabaseEngine::export_messages_to_file`] -- the SAME real
/// `nuncio_core::export::ExportEngine` `nuncio-cli`'s previous local-only
/// export path used, now reachable over the authenticated gRPC API against
/// the daemon's real, persistent store. `export_messages_to_file` also
/// appends a WORM audit record for the export as a side effect, so every
/// export is itself auditable via `Audit`.
struct ExportGrpcService {
db: Arc<DatabaseEngine>,
}
#[tonic::async_trait]
impl Export for ExportGrpcService {
async fn export_mailbox(
&self,
request: Request<ExportRequest>,
) -> Result<Response<ExportResponse>, Status> {
let req = request.into_inner();
if req.output_path.trim().is_empty() {
return Err(Status::invalid_argument("output_path is required"));
}
let format = map_export_format_from_proto(req.format())?;
let messages = match req.scope {
Some(export_request::Scope::AccountId(account_id)) => {
self.db
.list_messages_for_export(Some(&account_id), None)
.await
}
Some(export_request::Scope::FolderId(folder_id)) => {
self.db
.list_messages_for_export(None, Some(&folder_id))
.await
}
None => self.db.list_messages_for_export(None, None).await,
}
.map_err(|e| Status::internal(format!("failed to load messages for export: {e}")))?;
let output_path = std::path::PathBuf::from(&req.output_path);
let summary = self
.db
.export_messages_to_file(&messages, format, &output_path)
.await
.map_err(|e| Status::internal(format!("export failed: {e}")))?;
Ok(Response::new(ExportResponse {
output_path: summary.output_path,
message_count: summary.message_count as u64,
bytes_written: summary.bytes_written,
}))
}
}
/// Default cap on records returned by `Audit/ListRecords` when the caller
/// supplies `limit: 0` ("use the server default").
const DEFAULT_LIST_RECORDS_LIMIT: u32 = 100;
/// Maps a `nuncio_core::WormAuditRecord` onto its wire-format
/// `nuncio.v1.AuditRecord` representation. `record_hmac` is a verification
/// MAC output, never the WORM HMAC signing key itself -- see the message's
/// doc comment in `proto/nuncio/v1/nuncio.proto`.
fn map_audit_record_to_proto(record: nuncio_core::WormAuditRecord) -> AuditRecordProto {
AuditRecordProto {
sequence: record.sequence,
timestamp_ns: record.timestamp_ns,
actor: record.actor,
action: record.action,
data_hash: record.data_hash,
previous_block_hash: record.previous_block_hash,
record_hmac: record.record_hmac,
}
}
/// `nuncio.v1.Audit` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] WORM audit ledger (backlog story 2.B, GH #172).
/// A read/verify-only surface -- there is no RPC here to create or mutate
/// records, since the ledger is written internally by the daemon as a side
/// effect of other operations (see `ExportGrpcService`).
struct AuditGrpcService {
db: Arc<DatabaseEngine>,
}
#[tonic::async_trait]
impl Audit for AuditGrpcService {
async fn list_records(
&self,
request: Request<ListRecordsRequest>,
) -> Result<Response<ListRecordsResponse>, Status> {
let req = request.into_inner();
let limit = if req.limit == 0 {
DEFAULT_LIST_RECORDS_LIMIT
} else {
req.limit
};
let records = self
.db
.list_worm_audit_records(limit, req.offset)
.await
.map_err(|e| Status::internal(format!("failed to list audit records: {e}")))?
.into_iter()
.map(map_audit_record_to_proto)
.collect();
Ok(Response::new(ListRecordsResponse { records }))
}
/// Re-verifies the ENTIRE persisted WORM audit ledger via
/// [`DatabaseEngine::verify_worm_audit_chain_report`], using the ledger's
/// real WORM HMAC key. Never fabricates `valid: true`; a genuine
/// signing/crypto failure (as opposed to a specific broken record) is
/// surfaced as `Status::internal` rather than a false `valid: false`.
async fn verify_chain(
&self,
_request: Request<VerifyChainRequest>,
) -> Result<Response<VerifyChainResponse>, Status> {
let report = self
.db
.verify_worm_audit_chain_report()
.await
.map_err(|e| Status::internal(format!("failed to verify audit chain: {e}")))?;
Ok(Response::new(VerifyChainResponse {
valid: report.valid,
record_count: report.record_count as u64,
first_broken_seq: report.first_broken_seq,
}))
}
}
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
/// server.
///
@@ -953,9 +1108,9 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
}
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`,
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and `nuncio.v1.Filters` gRPC
/// services on it, all authenticated by `token`, until the transport server
/// errors.
/// `nuncio.v1.Accounts`, `nuncio.v1.Mail`, `nuncio.v1.Filters`,
/// `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC services on it, all
/// authenticated by `token`, until the transport server errors.
///
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
/// responsible for passing loopback-only addresses (see
@@ -977,8 +1132,9 @@ pub async fn serve(
serve_on_listener(listener, event_bus, db, filter_engine, secrets, token).await
}
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`, and
/// `nuncio.v1.Filters` gRPC services on an already-bound [`TcpListener`].
/// Serves the `nuncio.v1.System`, `nuncio.v1.Accounts`, `nuncio.v1.Mail`,
/// `nuncio.v1.Filters`, `nuncio.v1.Export`, and `nuncio.v1.Audit` gRPC
/// services on an already-bound [`TcpListener`].
///
/// # Security (GH #165)
///
@@ -1063,15 +1219,36 @@ pub async fn serve_on_listener_with_overrides(
// `BearerAuthInterceptor`, exactly like `System`/`Accounts`/`Mail` above
// -- see the hard invariant documented on this function's doc comment
// (GH #165).
let filters_service = FiltersGrpcService { db, filter_engine };
let filters_interceptor = BearerAuthInterceptor::new(token);
let filters_service = FiltersGrpcService {
db: db.clone(),
filter_engine,
};
let filters_interceptor = BearerAuthInterceptor::new(token.clone());
let filters_svc = FiltersServer::with_interceptor(filters_service, filters_interceptor);
// Export (backlog story 2.B, GH #172): mounted behind its own
// `BearerAuthInterceptor`, exactly like every other service above --
// see the hard invariant documented on this function's doc comment
// (GH #165).
let export_service = ExportGrpcService { db: db.clone() };
let export_interceptor = BearerAuthInterceptor::new(token.clone());
let export_svc = ExportServer::with_interceptor(export_service, export_interceptor);
// Audit (backlog story 2.B, GH #172): mounted behind its own
// `BearerAuthInterceptor`, exactly like every other service above --
// see the hard invariant documented on this function's doc comment
// (GH #165).
let audit_service = AuditGrpcService { db };
let audit_interceptor = BearerAuthInterceptor::new(token);
let audit_svc = AuditServer::with_interceptor(audit_service, audit_interceptor);
Server::builder()
.add_service(system_svc)
.add_service(accounts_svc)
.add_service(mail_svc)
.add_service(filters_svc)
.add_service(export_svc)
.add_service(audit_svc)
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.map_err(GrpcServeError::Transport)
@@ -2845,4 +3022,372 @@ mod tests {
.expect_err("invalid NSQL must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
// ---- Export (backlog story 2.B, GH #172) ----
use nuncio_proto::v1::export_client::ExportClient;
#[tokio::test]
async fn export_rpcs_reject_missing_bearer_token() {
// Confirms `Export` is mounted behind its own `BearerAuthInterceptor`
// exactly like every other service (GH #165: no un-intercepted
// service).
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = ExportClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.export_mailbox(ExportRequest {
scope: None,
format: ExportFormatProto::Json.into(),
output_path: "irrelevant.json".to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
async fn export_mailbox_rejects_empty_output_path() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = ExportClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.export_mailbox(authed_bearer_request(ExportRequest {
scope: None,
format: ExportFormatProto::Json.into(),
output_path: String::new(),
}))
.await
.expect_err("empty output_path must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn export_mailbox_rejects_unspecified_format() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = ExportClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.export_mailbox(authed_bearer_request(ExportRequest {
scope: None,
format: ExportFormatProto::Unspecified.into(),
output_path: "irrelevant.json".to_string(),
}))
.await
.expect_err("unspecified format must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
/// Backlog story 2.B (GH #172): seeds the daemon's real store with
/// messages directly via `DatabaseEngine::save_email` (the same write
/// path `Mail`'s own tests use), then proves `ExportMailbox` writes a
/// REAL file to `output_path` with the expected message/byte counts,
/// and that the file's actual content contains the seeded messages --
/// never a fabricated summary.
#[tokio::test]
async fn export_mailbox_writes_a_real_file_with_the_expected_seeded_messages() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.save_email(&sample_email(
"msg-export-1",
"inbox",
"Export Subject One",
"Export body one",
))
.await
.expect("seed message 1");
db.save_email(&sample_email(
"msg-export-2",
"archive",
"Export Subject Two",
"Export body two",
))
.await
.expect("seed message 2");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = ExportClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let out_dir = tempfile::tempdir().expect("create temp export dir");
let out_path = out_dir.path().join("export.json");
let response = client
.export_mailbox(authed_bearer_request(ExportRequest {
scope: None,
format: ExportFormatProto::Json.into(),
output_path: out_path.to_string_lossy().to_string(),
}))
.await
.expect("export_mailbox succeeds")
.into_inner();
assert_eq!(response.message_count, 2);
assert!(response.bytes_written > 0);
assert_eq!(response.output_path, out_path.to_string_lossy().to_string());
let written = std::fs::read_to_string(&out_path).expect("export file was written");
assert!(written.contains("Export Subject One"));
assert!(written.contains("Export Subject Two"));
assert_eq!(written.len() as u64, response.bytes_written);
}
/// Proves `ExportRequest.scope`'s `account_id` case narrows the export
/// to just that account's messages -- the daemon's `Export` gRPC layer
/// wiring for `DatabaseEngine::list_messages_for_export`'s account
/// filter, not just its own already-covered unit tests.
#[tokio::test]
async fn export_mailbox_scopes_by_account_id() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let mut msg_a = sample_email("msg-acct-a", "inbox", "From Account A", "Body A");
msg_a.account_id = "acct-a".to_string();
db.save_email(&msg_a).await.expect("seed account a message");
let mut msg_b = sample_email("msg-acct-b", "inbox", "From Account B", "Body B");
msg_b.account_id = "acct-b".to_string();
db.save_email(&msg_b).await.expect("seed account b message");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = ExportClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let out_dir = tempfile::tempdir().expect("create temp export dir");
let out_path = out_dir.path().join("scoped.jsonl");
let response = client
.export_mailbox(authed_bearer_request(ExportRequest {
scope: Some(export_request::Scope::AccountId("acct-a".to_string())),
format: ExportFormatProto::Jsonl.into(),
output_path: out_path.to_string_lossy().to_string(),
}))
.await
.expect("export_mailbox succeeds")
.into_inner();
assert_eq!(response.message_count, 1);
let written = std::fs::read_to_string(&out_path).expect("export file was written");
assert!(written.contains("From Account A"));
assert!(!written.contains("From Account B"));
}
// ---- Audit (backlog story 2.B, GH #172) ----
use nuncio_proto::v1::audit_client::AuditClient;
#[tokio::test]
async fn audit_rpcs_reject_missing_bearer_token() {
// Confirms `Audit` is mounted behind its own `BearerAuthInterceptor`
// exactly like every other service (GH #165: no un-intercepted
// service).
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = AuditClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.list_records(ListRecordsRequest {
limit: 10,
offset: 0,
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
let err = client
.verify_chain(VerifyChainRequest {})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
/// Seeds the daemon's real WORM audit ledger directly via
/// `DatabaseEngine::append_worm_audit_record`, then proves
/// `Audit/ListRecords` returns the REAL seeded records, sequence
/// ascending, with a non-empty `record_hmac` -- never a fabricated
/// list.
#[tokio::test]
async fn list_records_returns_seeded_worm_audit_records_in_sequence_order() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
.await
.expect("append first audit record");
db.append_worm_audit_record("system.test", "test.action.two", b"payload-two")
.await
.expect("append second audit record");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = AuditClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.list_records(authed_bearer_request(ListRecordsRequest {
limit: 0,
offset: 0,
}))
.await
.expect("list_records succeeds")
.into_inner();
assert_eq!(response.records.len(), 2);
assert_eq!(response.records[0].sequence, 1);
assert_eq!(response.records[0].action, "test.action.one");
assert_eq!(response.records[0].previous_block_hash, "GENESIS");
assert!(!response.records[0].record_hmac.is_empty());
assert_eq!(response.records[1].sequence, 2);
assert_eq!(response.records[1].action, "test.action.two");
}
/// Proves `Audit/VerifyChain` reports `valid: true` and the real
/// record count on an intact, honestly-signed chain -- never a
/// fabricated `valid`.
#[tokio::test]
async fn verify_chain_reports_valid_true_on_an_intact_chain() {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
db.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
.await
.expect("append first audit record");
db.append_worm_audit_record("system.test", "test.action.two", b"payload-two")
.await
.expect("append second audit record");
let event_bus = Arc::new(EventBus::new());
let secrets = Arc::new(SecretManager::mock());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db),
filter_engine,
secrets,
"correct-token",
)
.await;
let mut client = AuditClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.verify_chain(authed_bearer_request(VerifyChainRequest {}))
.await
.expect("verify_chain succeeds")
.into_inner();
assert!(response.valid);
assert_eq!(response.record_count, 2);
assert_eq!(response.first_broken_seq, None);
}
/// Proves `Audit/VerifyChain` honestly reports `valid: false` (with the
/// exact broken sequence) when the persisted ledger was signed with a
/// DIFFERENT WORM HMAC key than the one this engine instance was
/// provisioned with -- the same cross-vault mismatch scenario
/// `nuncio_store::db`'s own `worm_audit_key_is_vault_sourced_not_a_shared_default`
/// / `verify_worm_audit_chain_report_reports_first_broken_seq_on_a_vault_key_mismatch`
/// tests prove at the `DatabaseEngine` layer, exercised here end-to-end
/// over the authenticated gRPC API. The ledger's `UPDATE`/`DELETE`
/// triggers make it impossible to tamper with an existing record
/// in-place, so a mismatched vault key is the realistic way a
/// persisted chain becomes "invalid" without bypassing the store's own
/// immutability guarantees.
#[tokio::test]
async fn verify_chain_reports_invalid_and_first_broken_seq_on_a_vault_key_mismatch() {
let dir = tempfile::tempdir().expect("create persistent temp dir");
let db_path = dir.path().join("audit_mismatch_test.db");
let secrets_a = Arc::new(SecretManager::mock());
let db_a = DatabaseEngine::connect_file(&db_path, &secrets_a)
.await
.expect("open db with vault a");
db_a.append_worm_audit_record("system.test", "test.action.one", b"payload-one")
.await
.expect("append audit record signed with vault a's key");
db_a.close().await;
// "Restart" against the SAME on-disk database file, but with a
// DIFFERENT mock vault -- so the WORM HMAC key resolved for this
// second engine instance does not match the key that actually
// signed the persisted record.
let secrets_b = Arc::new(SecretManager::mock());
let db_b = DatabaseEngine::connect_file(&db_path, &secrets_b)
.await
.expect("open db with vault b");
let event_bus = Arc::new(EventBus::new());
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
let (addr, _handle) = spawn_test_server_with(
event_bus,
Arc::new(db_b),
filter_engine,
secrets_b,
"correct-token",
)
.await;
let mut client = AuditClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let response = client
.verify_chain(authed_bearer_request(VerifyChainRequest {}))
.await
.expect("verify_chain succeeds (a broken chain is a normal result, not an RPC error)")
.into_inner();
assert!(!response.valid);
assert_eq!(response.record_count, 1);
assert_eq!(response.first_broken_seq, Some(1));
}
}
+2 -2
View File
@@ -380,8 +380,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let grpc_token = hex::encode(grpc_token_bytes);
let grpc_addr = nunciod::grpc::grpc_addr_from_env();
tracing::info!(
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts, nuncio.v1.Mail, nuncio.v1.Filters) \
starting on {} (loopback only)",
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts, nuncio.v1.Mail, nuncio.v1.Filters, \
nuncio.v1.Export, nuncio.v1.Audit) starting on {} (loopback only)",
grpc_addr
);
let grpc_event_bus = event_bus.clone();