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

nordisk/holger

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