Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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());
}