server/lib/src/audit/trail.rs
//! **The per-artifact trail** — one ordered list per artifact, keyed by the
//! SHA-256 of its bytes, read out of the one audit log.
//!
//! An event that is a step of an artifact's life is written with
//! [`AuditEvent::on_trail`](super::AuditEvent::on_trail): its `sha256` and one
//! [`TrailKind`]. Nothing else is kept anywhere else, so the trail cannot
//! disagree with the log, and it is exactly as tamper-evident as the log's hash
//! chain ([`super::chain`]). Each [`TrailEntry`] carries the record's `seq` and
//! `hash`, so a reader can check one entry against a verified chain.
//!
//! | kind | written by |
//! |---|---|
//! | `imported` | the intake's INGESTED transition (`promote::intake`) |
//! | `hash_verified` | the intake re-reading the bytes against the ingested digest (SCANNED, AVAILABLE); a bundle import verifying a payload against its signed manifest |
//! | `scanned` | the scan evidence store, one entry per scanner, with its verdict |
//! | `policy_passed` | the POLICY_CHECKED transition |
//! | `policy_denied` | any intake transition a policy, a gate or missing evidence refused |
//! | `approved` | each approval vote, one entry per approver |
//! | `sealed` | the SEALED transition |
//! | `exported` | an export of the artifact out of this holger (`IntakeChain::record_export`) |
//! | `airgap_imported` | `holger-server bundle import` on the airgap side, one entry per payload |
//!
//! Reading a trail is an Auditor's or an Admin's ([`authorize_read`]).
use std::collections::BTreeSet;
use super::{AuditError, QuerySource, StoredRecord};
/// One step of an artifact's trail.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TrailKind {
/// The bytes entered this holger (INGESTED).
Imported,
/// The bytes were re-read and match the recorded digest.
HashVerified,
/// One scanner ran over the bytes; the detail names it and its verdict.
Scanned,
/// The policy for the artifact ran and allowed it.
PolicyPassed,
/// A policy, a gate or missing evidence refused a transition.
PolicyDenied,
/// One approver's vote.
Approved,
/// Sealed into the immutable production store.
Sealed,
/// Exported out of this holger (a bundle, a transfer medium).
Exported,
/// Imported on the airgap side from a verified bundle.
AirgapImported,
/// Held at once because its upstream invalidated it (the sync protocol, H4).
Invalidated,
}
impl TrailKind {
/// Every kind, in the order an artifact usually meets them.
pub const ALL: [TrailKind; 10] = [
TrailKind::Imported,
TrailKind::HashVerified,
TrailKind::Scanned,
TrailKind::PolicyPassed,
TrailKind::PolicyDenied,
TrailKind::Approved,
TrailKind::Sealed,
TrailKind::Exported,
TrailKind::AirgapImported,
TrailKind::Invalidated,
];
/// Stable wire string (log line, proto field, SIEM field).
pub fn as_str(&self) -> &'static str {
match self {
TrailKind::Imported => "imported",
TrailKind::HashVerified => "hash_verified",
TrailKind::Scanned => "scanned",
TrailKind::PolicyPassed => "policy_passed",
TrailKind::PolicyDenied => "policy_denied",
TrailKind::Approved => "approved",
TrailKind::Sealed => "sealed",
TrailKind::Exported => "exported",
TrailKind::AirgapImported => "airgap_imported",
TrailKind::Invalidated => "invalidated",
}
}
/// Inverse of [`as_str`](Self::as_str); `None` for an unknown string.
pub fn from_wire(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.as_str() == s)
}
}
impl std::fmt::Display for TrailKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// One entry of a trail.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct TrailEntry {
/// The record's place in the audit chain.
pub seq: u64,
/// The record's chain hash (empty for a sink that keeps no chain).
pub hash: String,
/// When, nanoseconds since the Unix epoch.
pub ts_nanos: i64,
/// The step.
pub kind: String,
/// Who.
pub ident: String,
/// The repository.
pub repo: String,
/// The artifact's label.
pub artifact: String,
/// The HTTP-like result status.
pub status: u16,
/// The detail: the transition, the scanner and its verdict, the approval.
pub detail: String,
}
impl TrailEntry {
fn of(r: &StoredRecord, kind: TrailKind) -> Self {
Self {
seq: r.seq,
hash: r.hash.clone(),
ts_nanos: r.event.ts_nanos,
kind: kind.as_str().to_string(),
ident: r.event.ident.clone(),
repo: r.event.repo.clone(),
artifact: r.event.artifact.clone(),
status: r.event.status,
detail: r.event.detail.clone(),
}
}
}
/// Whether `s` is a lowercase-hex SHA-256.
pub fn is_sha256_hex(s: &str) -> bool {
s.len() == 64
&& s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// **The trail of the artifact whose bytes hash to `sha256`**, in log order.
pub fn artifact_trail(source: &QuerySource, sha256: &str) -> Result<Vec<TrailEntry>, AuditError> {
let want = sha256.trim().to_ascii_lowercase();
let want = want.strip_prefix("sha256:").unwrap_or(&want).to_string();
if !is_sha256_hex(&want) {
return Err(AuditError::Other(format!(
"'{sha256}' is not a lowercase-hex SHA-256"
)));
}
Ok(source
.records()?
.iter()
.filter(|r| r.event.sha256 == want)
.filter_map(|r| r.event.trail.map(|k| TrailEntry::of(r, k)))
.collect())
}
/// **May this caller read trails?** An Auditor, or an Admin (the coarse admin
/// role, or the RepositoryAdmin duty). With RBAC off every caller holds every
/// duty — the same open posture as every other gate of this server.
pub fn authorize_read(
auth: &crate::auth::AuthConfig,
identity: Option<&crate::auth::AuthIdentity>,
) -> Result<(), String> {
use nornir_auth_policy::Duty;
if !auth.rbac_enabled() {
return Ok(());
}
let Some(id) = identity else {
return Err(holger_errcode::AUDIT_TRAIL_FORBIDDEN.tag(
"an artifact trail is read by an Auditor or an Admin; this request carries no identity",
));
};
let held: BTreeSet<Duty> = auth.duties_for_identity(id);
let admin = auth.role_for_identity(id) >= crate::auth::Role::Admin;
if admin || held.contains(&Duty::Auditor) || held.contains(&Duty::RepositoryAdmin) {
return Ok(());
}
Err(holger_errcode::AUDIT_TRAIL_FORBIDDEN.tag(format!(
"'{}' holds {:?}; an artifact trail is read by an Auditor or an Admin",
id.subject,
held.iter().collect::<Vec<_>>()
)))
}
/// **The trail's client**, for the `holger-server audit trail` CLI and for
/// tests: one call over a server's gRPC door with the caller's own bearer.
pub mod client {
use crate::grpc::holger_proto::audit_service_client::AuditServiceClient;
use crate::grpc::holger_proto::{
GetArtifactTrailRequest, GetArtifactTrailResponse, VerifyAuditChainRequest,
VerifyAuditChainResponse,
};
fn with_bearer<T>(body: T, bearer: Option<&str>) -> Result<tonic::Request<T>, String> {
let mut r = tonic::Request::new(body);
if let Some(b) = bearer {
r.metadata_mut().insert(
"authorization",
format!("Bearer {b}")
.parse()
.map_err(|_| "the bearer is not a header value".to_string())?,
);
}
Ok(r)
}
async fn connect(
server: &str,
) -> Result<AuditServiceClient<tonic::transport::Channel>, String> {
AuditServiceClient::connect(crate::twin_follow::endpoint_of(server))
.await
.map_err(|e| format!("holger's gRPC door at {server} was not reached: {e}"))
}
/// `AuditService.GetArtifactTrail`; a refusal is its tagged message.
pub async fn trail(
server: &str,
bearer: Option<&str>,
sha256: &str,
) -> Result<GetArtifactTrailResponse, String> {
let mut c = connect(server).await?;
c.get_artifact_trail(with_bearer(
GetArtifactTrailRequest {
sha256: sha256.to_string(),
},
bearer,
)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
/// `AuditService.QueryDownloads` ("what did C download"); a refusal is its tagged message.
pub async fn downloads(
server: &str,
bearer: Option<&str>,
q: crate::grpc::holger_proto::QueryDownloadsRequest,
) -> Result<crate::grpc::holger_proto::QueryDownloadsResponse, String> {
let mut c = connect(server).await?;
c.query_downloads(with_bearer(q, bearer)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
/// `AuditService.VerifyAuditChain`; a refusal is its tagged message.
pub async fn verify(
server: &str,
bearer: Option<&str>,
) -> Result<VerifyAuditChainResponse, String> {
let mut c = connect(server).await?;
c.verify_audit_chain(with_bearer(VerifyAuditChainRequest {}, bearer)?)
.await
.map(|r| r.into_inner())
.map_err(|s| s.message().to_string())
}
}
#[cfg(test)]
mod tests {
use super::super::{AuditAction, AuditEvent, AuditLog, MemoryAuditLog};
use super::*;
const D: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn kinds_roundtrip() {
for k in TrailKind::ALL {
assert_eq!(TrailKind::from_wire(k.as_str()), Some(k));
}
assert_eq!(TrailKind::from_wire("teleported"), None);
}
#[test]
fn the_trail_keeps_only_this_artifacts_steps_in_order() {
let log = MemoryAuditLog::new();
log.record(
AuditEvent::new("a", AuditAction::Upload, "in", "w", "", 200, 1)
.on_trail(TrailKind::Imported, D),
)
.unwrap();
log.record(AuditEvent::new(
"a",
AuditAction::Download,
"in",
"w",
"",
200,
1,
))
.unwrap();
log.record(
AuditEvent::new("b", AuditAction::Upload, "in", "x", "", 200, 1)
.on_trail(TrailKind::Imported, "b".repeat(64)),
)
.unwrap();
log.record(
AuditEvent::new("s", AuditAction::Scan, "in", "w", "", 200, 1)
.on_trail(TrailKind::Scanned, D),
)
.unwrap();
let t = artifact_trail(
&log.query_source().unwrap(),
&format!("sha256:{}", D.to_uppercase()),
)
.unwrap();
let kinds: Vec<&str> = t.iter().map(|e| e.kind.as_str()).collect();
assert_eq!(kinds, ["imported", "scanned"]);
assert!(artifact_trail(&log.query_source().unwrap(), "nope").is_err());
}
}