crates/gunnar-cli/tests/audit_chain.rs
#![cfg(all(feature = "server", feature = "control"))]
//! **The control plane's audit trail is tamper-evident** (GUN-044).
//!
//! `gunnar-core`'s unit tests prove the chain's arithmetic on entries a test
//! constructed. This proves it on the records the **real control plane** writes:
//! a `Repos.Get` that is allowed and one that is refused, through the real
//! `ServiceContext`, the real `CoreControlPlane` and the real
//! `gunnar_core::AuditChain` — written to the file a server really uses,
//! `<control dir>/audit-chain.jsonl`.
//!
//! # Why this file and not only the unit tests
//!
//! The finding was not "gunnar has no hash chain", it was "the control plane's
//! audit trail is `tracing` only". A chain nothing is wired to would close the
//! finding on paper. So every assertion here is on the bytes that landed
//! because an RPC was served, and the tamper cases rewrite that file rather
//! than a fixture.
mod common;
use std::sync::Arc;
use common::Harness;
use gunnar_cli::server::grpc_gate::control_audit_ledger;
use gunnar_cli::server::CoreControlPlane;
use gunnar_core::{AuditChain, HashKind, AUDIT_FILE};
use gunnar_grpc::{AuthMode, Caller, ControlPlane, EventBus, ServerConfig, ServiceContext};
use gunnar_proto::repos_server::Repos as _;
use gunnar_proto::{GetRepoRequest, RepoPath};
use tonic::Request;
const NAMESPACE: &str = "team";
const NAME: &str = "proj";
fn now<F: std::future::Future>(future: F) -> F::Output {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime")
.block_on(future)
}
fn as_caller<T>(message: T, principal: &str) -> Request<T> {
let mut request = Request::new(message);
request.extensions_mut().insert(Caller {
principal_id: principal.to_owned(),
fingerprint: format!("SHA256:{principal}"),
machine: false,
authenticated: true,
});
request
}
/// A repos service whose decisions go to `chain` as well as to `tracing`.
fn repos(harness: &Harness, chain: Arc<AuditChain>) -> gunnar_grpc::ReposService {
let control: Arc<dyn ControlPlane> =
Arc::new(CoreControlPlane::new(Arc::clone(&harness.stores)));
gunnar_grpc::ReposService::new(
ServiceContext::new(
control,
Arc::new(EventBus::new(16)),
ServerConfig {
auth: AuthMode::Required,
..ServerConfig::default()
},
)
.with_audit_ledger(control_audit_ledger(chain)),
)
}
/// **Two real decisions land in the chain, and the chain holds.**
///
/// The allow and the deny both, because a ledger that recorded only refusals
/// would leave "who read what" exactly where GUN-044 found it — and a ledger
/// that recorded only allows would lose the line an operator greps for.
#[test]
fn the_decisions_a_served_rpc_makes_land_in_a_chain_that_verifies() {
let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let path = harness.stores.control_dir().join(AUDIT_FILE);
assert!(
!path.exists(),
"the chain must not exist before anything is decided"
);
let chain = Arc::new(AuditChain::open(&path).expect("open a fresh chain"));
let service = repos(&harness, Arc::clone(&chain));
// Allowed: the harness's own principal holds Write on this repository.
now(service.get(as_caller(
GetRepoRequest {
path: Some(RepoPath::new(NAMESPACE, NAME)),
},
"tester",
)))
.expect("the granted principal must be served");
// Refused: a principal with no grant anywhere.
let refused = now(service.get(as_caller(
GetRepoRequest {
path: Some(RepoPath::new(NAMESPACE, NAME)),
},
"stranger",
)))
.expect_err("a principal with no grant must be refused");
assert_eq!(refused.code(), tonic::Code::PermissionDenied);
// ── the trail ──────────────────────────────────────────────────────────
let entries = chain.entries().expect("read the chain back");
assert_eq!(
entries.len(),
2,
"a served RPC and a refused one must both be in the trail: {entries:?}"
);
assert_eq!(chain.verify().expect("the chain holds"), 2);
assert_eq!(entries[0].decision, "allow");
assert_eq!(entries[0].principal, "tester");
assert_eq!(entries[0].method, "Repos.Get");
assert_eq!(entries[0].scope, format!("repo:{NAMESPACE}/{NAME}"));
assert_eq!(
entries[0].fingerprint, "SHA256:tester",
"the trail must name the credential, not only the principal"
);
assert_eq!(entries[1].decision, "deny");
assert_eq!(entries[1].principal, "stranger");
assert_eq!(
entries[1].reason, "no grant",
"a refusal must carry why: {:?}",
entries[1]
);
// Each entry names the one before it. This is the property, and it is
// asserted on the emitted records rather than on constructed ones.
assert_eq!(entries[0].prev, gunnar_core::audit::GENESIS);
assert_eq!(entries[1].prev, entries[0].hash);
assert_eq!(chain.tip(), entries[1].hash);
}
/// **An edited record is detected**, on the file a real server wrote.
///
/// The edit is the one somebody covering their tracks would make: turn the
/// refusal into an allow, or the other way round, and leave everything else —
/// the hash included — exactly as gunnar wrote it.
#[test]
fn a_decision_cannot_be_rewritten_after_the_fact() {
let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let path = harness.stores.control_dir().join(AUDIT_FILE);
let chain = Arc::new(AuditChain::open(&path).expect("open"));
let service = repos(&harness, Arc::clone(&chain));
// Three entries: an allowed read, then two refusals. (A second identical read inside the
// window would be counted into one aggregated entry when the window closes — P3,
// `grpc_gate::READ_WINDOW` — not written at once.)
for principal in ["tester", "stranger", "intruder"] {
let _ = now(service.get(as_caller(
GetRepoRequest {
path: Some(RepoPath::new(NAMESPACE, NAME)),
},
principal,
)));
}
assert_eq!(chain.verify().expect("green first"), 3);
drop(chain);
let text = std::fs::read_to_string(&path).expect("read the trail");
let mut lines: Vec<String> = text.lines().map(str::to_owned).collect();
assert!(lines[1].contains("\"decision\":\"deny\""), "{}", lines[1]);
lines[1] = lines[1].replace("\"decision\":\"deny\"", "\"decision\":\"allow\"");
std::fs::write(&path, lines.join("\n") + "\n").expect("rewrite the trail");
let err = AuditChain::open(&path).expect_err("a rewritten trail must not open");
let text = format!("{err}");
assert!(
text.contains("broken") && text.contains("edited"),
"the refusal must say what happened and where: {text}"
);
}
/// **A removed record is detected** — the tamper `tracing` cannot show at all,
/// because a deleted line leaves nothing behind.
#[test]
fn a_decision_cannot_be_cut_out_of_the_trail() {
let harness = Harness::start(NAMESPACE, NAME, HashKind::Sha1);
let path = harness.stores.control_dir().join(AUDIT_FILE);
let chain = Arc::new(AuditChain::open(&path).expect("open"));
let service = repos(&harness, Arc::clone(&chain));
// Three entries: an allowed read, then two refusals. (A second identical read inside the
// window would be counted into one aggregated entry when the window closes — P3,
// `grpc_gate::READ_WINDOW` — not written at once.)
for principal in ["tester", "stranger", "intruder"] {
let _ = now(service.get(as_caller(
GetRepoRequest {
path: Some(RepoPath::new(NAMESPACE, NAME)),
},
principal,
)));
}
assert_eq!(chain.verify().expect("green first"), 3);
drop(chain);
// Cut the refusal out of the middle.
let text = std::fs::read_to_string(&path).expect("read");
let kept: Vec<&str> = text
.lines()
.enumerate()
.filter(|(index, _)| *index != 1)
.map(|(_, line)| line)
.collect();
std::fs::write(&path, kept.join("\n") + "\n").expect("rewrite");
let err = AuditChain::open(&path).expect_err("a cut trail must not open");
assert!(format!("{err}").contains("removed or reordered"), "{err}");
// And the server says so rather than extending it: a second `open` is what
// a restart does, and it must keep failing rather than laying genuine
// records on top of a forgery.
assert!(AuditChain::open(&path).is_err());
}