org_audit.rs (B8 verified-signer test) + the two uncommitted org.rs diffs (item-CRUD B9-B13, status/audit B8) from the wf_22020aea first-run worktrees. All superseded by v0.8.0 main; also committed on the -r2 branches. Kept so nothing is lost when the stale worktrees are removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
522 lines
21 KiB
Diff
522 lines
21 KiB
Diff
diff --git a/Cargo.lock b/Cargo.lock
|
|
index ffaf13f..5b9a869 100644
|
|
--- a/Cargo.lock
|
|
+++ b/Cargo.lock
|
|
@@ -2172,6 +2172,7 @@ dependencies = [
|
|
"predicates",
|
|
"qrcode",
|
|
"rand",
|
|
+ "regex",
|
|
"relicario-core",
|
|
"reqwest",
|
|
"rpassword",
|
|
diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml
|
|
index db05181..928004c 100644
|
|
--- a/crates/relicario-cli/Cargo.toml
|
|
+++ b/crates/relicario-cli/Cargo.toml
|
|
@@ -31,10 +31,11 @@ rqrr = "0.7"
|
|
reqwest = { version = "0.12", features = ["blocking", "json"] }
|
|
qrcode = { version = "0.14", features = ["svg"] }
|
|
ssh-key = { version = "0.6", features = ["ed25519", "std"] }
|
|
+regex = "1"
|
|
+tempfile = "3"
|
|
|
|
[dev-dependencies]
|
|
assert_cmd = "2"
|
|
predicates = "3"
|
|
-tempfile = "3"
|
|
serde_json = "1"
|
|
ed25519-dalek = "2"
|
|
diff --git a/crates/relicario-cli/src/commands/org.rs b/crates/relicario-cli/src/commands/org.rs
|
|
index b0f1bf8..799b14b 100644
|
|
--- a/crates/relicario-cli/src/commands/org.rs
|
|
+++ b/crates/relicario-cli/src/commands/org.rs
|
|
@@ -329,6 +329,285 @@ fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
|
|
}
|
|
}
|
|
|
|
+// ═══════════ Status / Audit (B8) ═══════════
|
|
+
|
|
+/// `org status`: print the org's members + collections with no decryption. Reads
|
|
+/// the three plaintext metadata files (org.json, members.json, collections.json)
|
|
+/// directly — the manifest stays encrypted and is never touched.
|
|
+pub fn run_org_status(dir: &Path) -> Result<()> {
|
|
+ let root = crate::org_session::org_dir(Some(dir))?;
|
|
+
|
|
+ let meta: relicario_core::OrgMeta = {
|
|
+ let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
|
|
+ serde_json::from_str(&s).context("parse org.json")?
|
|
+ };
|
|
+ let members: OrgMembers = {
|
|
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
|
+ serde_json::from_str(&s).context("parse members.json")?
|
|
+ };
|
|
+ let collections: OrgCollections = {
|
|
+ let s = fs::read_to_string(root.join("collections.json"))
|
|
+ .context("read collections.json")?;
|
|
+ serde_json::from_str(&s).context("parse collections.json")?
|
|
+ };
|
|
+
|
|
+ println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
|
|
+ println!();
|
|
+ println!("Members ({}):", members.members.len());
|
|
+ for m in &members.members {
|
|
+ let colls = if m.collections.is_empty() {
|
|
+ "(no collections)".to_string()
|
|
+ } else {
|
|
+ m.collections.join(", ")
|
|
+ };
|
|
+ println!(
|
|
+ " {:?} {} {} [{}]",
|
|
+ m.role,
|
|
+ m.member_id.as_str(),
|
|
+ m.display_name,
|
|
+ colls
|
|
+ );
|
|
+ }
|
|
+ println!();
|
|
+ println!("Collections ({}):", collections.collections.len());
|
|
+ for c in &collections.collections {
|
|
+ println!(" {} — {}", c.slug, c.display_name);
|
|
+ }
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+/// One audited org-vault commit, attributed to a VERIFIED git signer.
|
|
+#[derive(Debug, serde::Serialize)]
|
|
+pub struct AuditEvent {
|
|
+ pub commit: String,
|
|
+ pub timestamp: String,
|
|
+ /// Actor as resolved from the VERIFIED signing key (authoritative).
|
|
+ pub actor_name: Option<String>,
|
|
+ pub actor_id: Option<String>,
|
|
+ /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
|
|
+ pub trailer_actor_id: Option<String>,
|
|
+ pub action: Option<String>,
|
|
+ pub collection: Option<String>,
|
|
+ pub item_id: Option<String>,
|
|
+ pub device_id: Option<String>,
|
|
+ /// True when the trailer's claimed actor disagrees with the verified signer,
|
|
+ /// or when no current member matches the signing key.
|
|
+ pub tampered: bool,
|
|
+}
|
|
+
|
|
+/// Parse a commit's `Relicario-*` trailer block into an `AuditEvent`. The actor
|
|
+/// id captured here is the trailer's CLAIM (`trailer_actor_id`) — the
|
|
+/// authoritative `actor_id` is resolved later from the verified signature.
|
|
+fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
|
|
+ let mut ev = AuditEvent {
|
|
+ commit: commit.to_string(),
|
|
+ timestamp: timestamp.to_string(),
|
|
+ actor_name: None,
|
|
+ actor_id: None,
|
|
+ trailer_actor_id: None,
|
|
+ action: None,
|
|
+ collection: None,
|
|
+ item_id: None,
|
|
+ device_id: None,
|
|
+ tampered: false,
|
|
+ };
|
|
+ for line in trailers.lines() {
|
|
+ let line = line.trim();
|
|
+ if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
|
|
+ // Contract format: "<name> <member_id>" (member_id is the last token).
|
|
+ let rest = rest.trim();
|
|
+ if let Some((_name, id)) = rest.rsplit_once(' ') {
|
|
+ ev.trailer_actor_id = Some(id.trim().to_string());
|
|
+ } else if !rest.is_empty() {
|
|
+ ev.trailer_actor_id = Some(rest.to_string());
|
|
+ }
|
|
+ } else if let Some(v) = line.strip_prefix("Relicario-Action:") {
|
|
+ ev.action = Some(v.trim().to_string());
|
|
+ } else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
|
|
+ ev.collection = Some(v.trim().to_string());
|
|
+ } else if let Some(v) = line.strip_prefix("Relicario-Item:") {
|
|
+ ev.item_id = Some(v.trim().to_string());
|
|
+ } else if let Some(v) = line.strip_prefix("Relicario-Device:") {
|
|
+ ev.device_id = Some(v.trim().to_string());
|
|
+ }
|
|
+ }
|
|
+ ev
|
|
+}
|
|
+
|
|
+/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
|
|
+/// the pre-receive hook: build an allowed_signers from members.json, inject it
|
|
+/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
|
|
+/// stderr. Returns None if the commit is unsigned or the signer is not a member.
|
|
+fn resolve_signer<'m>(
|
|
+ root: &Path,
|
|
+ commit: &str,
|
|
+ members: &'m relicario_core::OrgMembers,
|
|
+) -> Option<&'m relicario_core::OrgMember> {
|
|
+ use std::io::Write;
|
|
+ let mut tmp = tempfile::NamedTempFile::new().ok()?;
|
|
+ for m in &members.members {
|
|
+ let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
|
|
+ }
|
|
+ let allowed_path = tmp.path();
|
|
+
|
|
+ let output = std::process::Command::new("git")
|
|
+ .current_dir(root)
|
|
+ .args(["verify-commit", "--raw", commit])
|
|
+ .env("GIT_CONFIG_COUNT", "1")
|
|
+ .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
|
+ .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
|
+ .output()
|
|
+ .ok()?;
|
|
+ let stderr = String::from_utf8_lossy(&output.stderr);
|
|
+
|
|
+ let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
|
|
+ let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();
|
|
+
|
|
+ members.members.iter().find(|m| {
|
|
+ relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
|
|
+ })
|
|
+}
|
|
+
|
|
+/// `org audit`: parse `git log`, resolve each commit's VERIFIED signer to a
|
|
+/// member and report THAT as the actor (trailers are advisory), flag
|
|
+/// trailer/signer mismatch as `TAMPERED`, and frame records with `%x1e`/`%x1f`
|
|
+/// (so multi-line trailer values cannot misalign records) using the committer
|
|
+/// date (`%cI`).
|
|
+pub fn run_org_audit(
|
|
+ dir: &Path,
|
|
+ since: Option<&str>,
|
|
+ member_filter: Option<&str>,
|
|
+ collection_filter: Option<&str>,
|
|
+ action_filter: Option<&str>,
|
|
+ format: &str,
|
|
+) -> Result<()> {
|
|
+ // Spec surface is `--format <table|json>` (default table). Accept only those.
|
|
+ let json = match format {
|
|
+ "json" => true,
|
|
+ "table" => false,
|
|
+ other => anyhow::bail!("unknown --format `{other}` — use table or json"),
|
|
+ };
|
|
+ let root = crate::org_session::org_dir(Some(dir))?;
|
|
+
|
|
+ // members.json — needed to resolve each commit's verified signer to a member.
|
|
+ let members: relicario_core::OrgMembers = {
|
|
+ let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
|
|
+ serde_json::from_str(&s).context("parse members.json")?
|
|
+ };
|
|
+
|
|
+ // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
|
|
+ // field separator (%x1f, U+001F) between fields, so multi-line trailer
|
|
+ // values cannot misalign record boundaries. Committer date (%cI), not
|
|
+ // author date: it is what revocation/audit is anchored to.
|
|
+ let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
|
|
+ let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
|
|
+ if let Some(s) = since {
|
|
+ args.push(format!("--since={s}"));
|
|
+ }
|
|
+
|
|
+ let output = std::process::Command::new("git")
|
|
+ .current_dir(&root)
|
|
+ .args(&args)
|
|
+ .output()
|
|
+ .context("git log")?;
|
|
+ let log = String::from_utf8_lossy(&output.stdout);
|
|
+
|
|
+ let events = parse_audit_log(&root, &log, &members, member_filter, collection_filter, action_filter);
|
|
+
|
|
+ if json {
|
|
+ println!("{}", serde_json::to_string_pretty(&events)?);
|
|
+ } else {
|
|
+ println!(
|
|
+ "{:<44} {:<26} {:<20} {:<18} {}",
|
|
+ "COMMIT", "TIMESTAMP", "ACTION", "ACTOR", "FLAG"
|
|
+ );
|
|
+ for ev in &events {
|
|
+ println!(
|
|
+ "{:<44} {:<26} {:<20} {:<18} {}",
|
|
+ ev.commit,
|
|
+ ev.timestamp,
|
|
+ ev.action.as_deref().unwrap_or("-"),
|
|
+ ev.actor_name.as_deref().unwrap_or("<unverified>"),
|
|
+ if ev.tampered { "TAMPERED" } else { "" },
|
|
+ );
|
|
+ }
|
|
+ }
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+/// Frame a raw `git log` body (records split on `%x1e`, fields on `%x1f`) into
|
|
+/// attributed `AuditEvent`s. Each commit's VERIFIED signer is resolved via
|
|
+/// `resolve_signer` and reported as the authoritative actor; trailer/signer
|
|
+/// disagreement (or no matching member) sets the `tampered` flag. Filters apply
|
|
+/// to the VERIFIED actor id, not the spoofable trailer. Split out from
|
|
+/// `run_org_audit` so it can be unit-tested over a real signed repo.
|
|
+fn parse_audit_log(
|
|
+ root: &Path,
|
|
+ log: &str,
|
|
+ members: &relicario_core::OrgMembers,
|
|
+ member_filter: Option<&str>,
|
|
+ collection_filter: Option<&str>,
|
|
+ action_filter: Option<&str>,
|
|
+) -> Vec<AuditEvent> {
|
|
+ let mut events: Vec<AuditEvent> = Vec::new();
|
|
+ for record in log.split('\u{1e}') {
|
|
+ let record = record.trim_start_matches('\n');
|
|
+ if record.trim().is_empty() {
|
|
+ continue;
|
|
+ }
|
|
+ let mut fields = record.splitn(3, '\u{1f}');
|
|
+ let commit = fields.next().unwrap_or("").trim();
|
|
+ let ts = fields.next().unwrap_or("").trim();
|
|
+ let trailers = fields.next().unwrap_or("");
|
|
+ if commit.is_empty() {
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ let mut ev = parse_trailer_block(commit, ts, trailers);
|
|
+ if ev.action.is_none() {
|
|
+ continue; // not an org commit
|
|
+ }
|
|
+
|
|
+ // Resolve the VERIFIED signer and attribute it as the authoritative actor.
|
|
+ match resolve_signer(root, commit, members) {
|
|
+ Some(m) => {
|
|
+ ev.actor_name = Some(m.display_name.clone());
|
|
+ ev.actor_id = Some(m.member_id.as_str().to_string());
|
|
+ // Tampered if the trailer claims a different actor than the signer.
|
|
+ if let Some(claimed) = ev.trailer_actor_id.as_deref() {
|
|
+ if claimed != m.member_id.as_str() {
|
|
+ ev.tampered = true;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ None => {
|
|
+ // No current member matched the signature -> cannot trust the
|
|
+ // trailer's claimed actor.
|
|
+ ev.tampered = true;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ if let Some(mid) = member_filter {
|
|
+ // Filter on the VERIFIED actor id, not the spoofable trailer.
|
|
+ if ev.actor_id.as_deref() != Some(mid) {
|
|
+ continue;
|
|
+ }
|
|
+ }
|
|
+ if let Some(col) = collection_filter {
|
|
+ if ev.collection.as_deref() != Some(col) {
|
|
+ continue;
|
|
+ }
|
|
+ }
|
|
+ if let Some(act) = action_filter {
|
|
+ if ev.action.as_deref() != Some(act) {
|
|
+ continue;
|
|
+ }
|
|
+ }
|
|
+ events.push(ev);
|
|
+ }
|
|
+ events
|
|
+}
|
|
+
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
@@ -385,4 +664,201 @@ mod tests {
|
|
assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
|
|
assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
|
|
}
|
|
+
|
|
+ // ───── Status / Audit (B8) ─────
|
|
+
|
|
+ #[test]
|
|
+ fn parse_trailers_extracts_relicario_fields() {
|
|
+ // Contract trailer shape: "Relicario-Actor: <name> <member_id>".
|
|
+ let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
|
|
+ let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
|
|
+ assert_eq!(event.action.as_deref(), Some("item-create"));
|
|
+ assert_eq!(event.collection.as_deref(), Some("prod"));
|
|
+ // The verified actor_id is resolved later from the signature, not the trailer;
|
|
+ // the trailer only populates trailer_actor_id here.
|
|
+ assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
|
|
+ assert_eq!(event.actor_id, None);
|
|
+ assert!(!event.tampered);
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_trailers_captures_item_and_device() {
|
|
+ let raw = "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Item: 0123456789abcdef\nRelicario-Device: laptop\n";
|
|
+ let ev = parse_trailer_block("def456", "2026-06-06T13:00:00+00:00", raw);
|
|
+ assert_eq!(ev.action.as_deref(), Some("item-update"));
|
|
+ assert_eq!(ev.item_id.as_deref(), Some("0123456789abcdef"));
|
|
+ assert_eq!(ev.device_id.as_deref(), Some("laptop"));
|
|
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("feedfacefeedface"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_trailers_single_token_actor_falls_back_to_whole_value() {
|
|
+ // No space => the whole value is treated as the member id.
|
|
+ let raw = "Relicario-Actor: lonelytoken00000\nRelicario-Action: org-init\n";
|
|
+ let ev = parse_trailer_block("c0ffee", "2026-06-06T14:00:00+00:00", raw);
|
|
+ assert_eq!(ev.trailer_actor_id.as_deref(), Some("lonelytoken00000"));
|
|
+ assert_eq!(ev.action.as_deref(), Some("org-init"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_trailers_non_org_commit_has_no_action() {
|
|
+ // A commit with no Relicario-* trailers parses to an event with no action,
|
|
+ // which run_org_audit skips.
|
|
+ let ev = parse_trailer_block("beef", "2026-06-06T15:00:00+00:00", "");
|
|
+ assert!(ev.action.is_none());
|
|
+ }
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod audit_log_tests {
|
|
+ //! Record-framing + filter tests for `parse_audit_log` against a synthetic
|
|
+ //! `git log` body (no real repo / signatures needed: members.json is empty so
|
|
+ //! `resolve_signer` always returns None and every org commit is flagged
|
|
+ //! TAMPERED — which is exactly the "signer is not a current member" path).
|
|
+ use super::*;
|
|
+ use relicario_core::OrgMembers;
|
|
+
|
|
+ /// Build one framed record: leading %x1e, then commit %x1f ts %x1f trailers.
|
|
+ fn record(commit: &str, ts: &str, trailers: &str) -> String {
|
|
+ format!("\u{1e}{commit}\u{1f}{ts}\u{1f}{trailers}")
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_audit_log_frames_records_and_flags_unverified() {
|
|
+ let members = OrgMembers::new(); // no members => no signer can resolve
|
|
+ let log = format!(
|
|
+ "{}{}",
|
|
+ record(
|
|
+ "1111111111111111111111111111111111111111",
|
|
+ "2026-06-06T12:00:00+00:00",
|
|
+ "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n",
|
|
+ ),
|
|
+ record(
|
|
+ "2222222222222222222222222222222222222222",
|
|
+ "2026-06-06T13:00:00+00:00",
|
|
+ "Relicario-Actor: bob feedfacefeedface\nRelicario-Action: item-update\nRelicario-Collection: dev\n",
|
|
+ ),
|
|
+ );
|
|
+ // root path is unused once resolve_signer short-circuits on empty members,
|
|
+ // but verify-commit will run; point it at a tempdir to be safe.
|
|
+ let tmp = tempfile::tempdir().unwrap();
|
|
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
|
|
+ assert_eq!(events.len(), 2);
|
|
+ // Leading %x1e produced an empty leading split element that was filtered.
|
|
+ assert_eq!(events[0].commit, "1111111111111111111111111111111111111111");
|
|
+ assert_eq!(events[0].action.as_deref(), Some("item-create"));
|
|
+ assert_eq!(events[0].collection.as_deref(), Some("prod"));
|
|
+ // No member matched the (absent) signature => TAMPERED, no verified actor.
|
|
+ assert!(events[0].tampered);
|
|
+ assert_eq!(events[0].actor_name, None);
|
|
+ assert_eq!(events[0].actor_id, None);
|
|
+ // Trailer claim is preserved for forensic comparison.
|
|
+ assert_eq!(events[0].trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_audit_log_skips_non_org_commits() {
|
|
+ let members = OrgMembers::new();
|
|
+ let log = format!(
|
|
+ "{}{}",
|
|
+ // A non-org commit: no Relicario-Action trailer.
|
|
+ record("3333", "2026-06-06T10:00:00+00:00", "Some-Other: trailer\n"),
|
|
+ record(
|
|
+ "4444",
|
|
+ "2026-06-06T11:00:00+00:00",
|
|
+ "Relicario-Action: org-init\nRelicario-Actor: alice a1b2c3d4e5f6a1b2\n",
|
|
+ ),
|
|
+ );
|
|
+ let tmp = tempfile::tempdir().unwrap();
|
|
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
|
|
+ assert_eq!(events.len(), 1);
|
|
+ assert_eq!(events[0].commit, "4444");
|
|
+ assert_eq!(events[0].action.as_deref(), Some("org-init"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_audit_log_multiline_trailer_value_does_not_misalign() {
|
|
+ // A multi-line trailer value must not break record framing: only %x1e
|
|
+ // ends a record, not a newline inside the trailer block.
|
|
+ let members = OrgMembers::new();
|
|
+ let log = format!(
|
|
+ "{}{}",
|
|
+ record(
|
|
+ "5555",
|
|
+ "2026-06-06T09:00:00+00:00",
|
|
+ "Relicario-Action: item-create\nRelicario-Actor: carol cafecafecafecafe\nRelicario-Collection: prod\n",
|
|
+ ),
|
|
+ record(
|
|
+ "6666",
|
|
+ "2026-06-06T09:30:00+00:00",
|
|
+ "Relicario-Action: item-delete\nRelicario-Actor: dave deaddeaddeaddead\nRelicario-Collection: dev\n",
|
|
+ ),
|
|
+ );
|
|
+ let tmp = tempfile::tempdir().unwrap();
|
|
+ let events = parse_audit_log(tmp.path(), &log, &members, None, None, None);
|
|
+ assert_eq!(events.len(), 2);
|
|
+ assert_eq!(events[0].commit, "5555");
|
|
+ assert_eq!(events[1].commit, "6666");
|
|
+ assert_eq!(events[1].action.as_deref(), Some("item-delete"));
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_audit_log_collection_and_action_filters_apply() {
|
|
+ let members = OrgMembers::new();
|
|
+ let log = format!(
|
|
+ "{}{}{}",
|
|
+ record(
|
|
+ "7777",
|
|
+ "2026-06-06T08:00:00+00:00",
|
|
+ "Relicario-Action: item-create\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
|
|
+ ),
|
|
+ record(
|
|
+ "8888",
|
|
+ "2026-06-06T08:10:00+00:00",
|
|
+ "Relicario-Action: item-update\nRelicario-Collection: prod\nRelicario-Actor: a aaaa000000000000\n",
|
|
+ ),
|
|
+ record(
|
|
+ "9999",
|
|
+ "2026-06-06T08:20:00+00:00",
|
|
+ "Relicario-Action: item-create\nRelicario-Collection: dev\nRelicario-Actor: a aaaa000000000000\n",
|
|
+ ),
|
|
+ );
|
|
+ let tmp = tempfile::tempdir().unwrap();
|
|
+
|
|
+ // Collection filter: only prod commits survive.
|
|
+ let prod = parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), None);
|
|
+ assert_eq!(prod.len(), 2);
|
|
+ assert!(prod.iter().all(|e| e.collection.as_deref() == Some("prod")));
|
|
+
|
|
+ // Action filter: only item-create commits survive.
|
|
+ let creates = parse_audit_log(tmp.path(), &log, &members, None, None, Some("item-create"));
|
|
+ assert_eq!(creates.len(), 2);
|
|
+ assert!(creates.iter().all(|e| e.action.as_deref() == Some("item-create")));
|
|
+
|
|
+ // Combined: item-create AND prod => just commit 7777.
|
|
+ let combined =
|
|
+ parse_audit_log(tmp.path(), &log, &members, None, Some("prod"), Some("item-create"));
|
|
+ assert_eq!(combined.len(), 1);
|
|
+ assert_eq!(combined[0].commit, "7777");
|
|
+ }
|
|
+
|
|
+ #[test]
|
|
+ fn parse_audit_log_member_filter_uses_verified_actor_not_trailer() {
|
|
+ // With no resolvable signer, actor_id is None, so a member filter naming
|
|
+ // the TRAILER's claimed id must NOT match — the filter is on the verified
|
|
+ // actor, which is the whole point of TAMPERED attribution.
|
|
+ let members = OrgMembers::new();
|
|
+ let log = record(
|
|
+ "aaaa",
|
|
+ "2026-06-06T07:00:00+00:00",
|
|
+ "Relicario-Action: item-create\nRelicario-Actor: mallory deadbeefdeadbeef\n",
|
|
+ );
|
|
+ let tmp = tempfile::tempdir().unwrap();
|
|
+ let filtered =
|
|
+ parse_audit_log(tmp.path(), &log, &members, Some("deadbeefdeadbeef"), None, None);
|
|
+ assert!(
|
|
+ filtered.is_empty(),
|
|
+ "member filter must match the verified actor id, never the spoofable trailer"
|
|
+ );
|
|
+ }
|
|
}
|