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/promote/duties.rs

//! **Separation of duties on the intake chain.**
//!
//! Holger's intake chain ([`super::intake`]) holds every artifact in one state.
//! This module decides WHO may move it, and it is the one place the three doors
//! (gRPC `PromotionService.AdvanceArtifact`, HTTP `POST /-/intake/advance`, the
//! `holger-server intake` CLI, which speaks gRPC) ask.
//!
//! ## The roles are duties of the existing RBAC
//!
//! The five roles are [`Duty`] grants in the SAME `auth` block, keyed on the SAME
//! identity (subject or group), as the coarse [`Role`](crate::auth::Role) ladder
//! (`nornir_auth_policy::AuthConfig::duties` / `group_duties`). There is no
//! second auth system: a duty also lifts the coarse role to its floor, so the
//! existing write and read gates keep working.
//!
//! | duty | floor | may |
//! |---|---|---|
//! | `Developer` | reader | read AVAILABLE artifacts, nothing else |
//! | `Importer` | writer | ingest (the upload doors), move to SCANNED / POLICY_CHECKED |
//! | `SecurityApprover` | reader | approve, quarantine, release, block; seal onward |
//! | `RepositoryAdmin` | admin | configure; seal onward; **never approves** |
//! | `Auditor` | reader | read the audit log and every state's evidence; changes nothing |
//!
//! With no duty configured, duties derive from the coarse role (`Admin` holds
//! all five), and with RBAC off every caller holds all five: a config written
//! before duties existed keeps its meaning.
//!
//! ## The rules, which hold whatever the duties say
//!
//! 1. **The importer never approves.** The principal recorded on the INGESTED
//!    entry cannot cast an approval vote nor release a hold of that artifact,
//!    even when it also holds `SecurityApprover` ([`DutyError::ImporterCannotApprove`]).
//! 2. **N distinct approvers.** `promotion.intake.approvals: N` (default 1):
//!    APPROVED needs N different principals, none of them the importer. A vote
//!    short of the quorum is recorded and answered with the count; the same
//!    principal twice is refused ([`DutyError::DuplicateApprover`]).
//! 3. **Every approval is attested by its principal.** The vote is an in-toto
//!    Statement v1 (`predicateType` [`APPROVAL_PREDICATE`]) naming the artifact's
//!    sha256, the authenticated subject and the method that authenticated it; its
//!    sha256 is the `reference` of the `approval` evidence slot the APPROVED
//!    entry carries, and the statement itself is kept in the append-only vote
//!    record. A caller never supplies the approval slot ([`DutyError::ForgedApproval`]).
//! 4. **An approval needs a principal.** `anonymous` never approves
//!    ([`DutyError::AnonymousApprover`]).
//!
//! Rules 1, 2 and 4 are enforced inside [`IntakeChain`](super::intake::IntakeChain)
//! itself, so an in-process caller obeys them too; the duty table is enforced
//! here, at the doors, where the identity is known.

use std::collections::BTreeSet;

pub use nornir_auth_policy::Duty;

use super::intake::ArtifactState;
use crate::auth::{AuthConfig, AuthIdentity};

/// The `predicateType` of an approval statement.
pub const APPROVAL_PREDICATE: &str = "https://holger.rs/intake/approval/v1";

/// The principal an unauthenticated request acts as. It never approves.
pub const ANONYMOUS: &str = "anonymous";

/// What a caller asks the intake chain to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
    /// Move to this chain state (APPROVED is [`Verb::Approve`]).
    Advance(ArtifactState),
    /// Cast an approval vote for APPROVED.
    Approve,
    Quarantine,
    /// Release a QUARANTINED artifact back to the state it was held at.
    Release,
    Block,
    /// Read the state, history and evidence of an artifact in this state.
    Read(ArtifactState),
    /// Import a signed bundle at the box's import doors ([`crate::import_door`]):
    /// an intel bundle (`what` = `intel`) or an update / airgap bundle
    /// (`what` = `update`). Repository Admin or Security Approver.
    Import(&'static str),
    /// Authorize the FIRST import of a vendor's product (lane HOL-CDS, NCSC gap 2): an
    /// admin's action on the secure side. Repository Admin only.
    AllowFirstImport,
}

impl Verb {
    /// The verb for a move to `to` from `from` (`held_from` for a QUARANTINED one).
    pub fn for_move(from: ArtifactState, to: ArtifactState) -> Verb {
        match to {
            ArtifactState::Blocked => Verb::Block,
            ArtifactState::Quarantined => Verb::Quarantine,
            _ if from == ArtifactState::Quarantined => Verb::Release,
            ArtifactState::Approved => Verb::Approve,
            other => Verb::Advance(other),
        }
    }

    fn name(&self) -> String {
        match self {
            Verb::Advance(s) => format!("advance to {s}"),
            Verb::Approve => "approve".into(),
            Verb::Quarantine => "quarantine".into(),
            Verb::Release => "release".into(),
            Verb::Block => "block".into(),
            Verb::Read(s) => format!("read a {s} artifact"),
            Verb::Import(what) => format!("import an {what} bundle"),
            Verb::AllowFirstImport => "authorize a first import".into(),
        }
    }

    /// The duties of which ONE permits this verb.
    pub fn needs(&self) -> &'static [Duty] {
        use ArtifactState::*;
        match self {
            Verb::Advance(Scanned | PolicyChecked) => &[
                Duty::Importer,
                Duty::SecurityApprover,
                Duty::RepositoryAdmin,
            ],
            Verb::Advance(Sealed | Promoted | Available) => {
                &[Duty::SecurityApprover, Duty::RepositoryAdmin]
            }
            // INGESTED is the upload doors' move; nothing else reaches UNTRUSTED.
            Verb::Advance(_) => &[],
            Verb::Approve | Verb::Quarantine | Verb::Release | Verb::Block => {
                &[Duty::SecurityApprover]
            }
            Verb::Read(Available) => &Duty::ALL,
            Verb::Read(_) => &[
                Duty::Importer,
                Duty::SecurityApprover,
                Duty::RepositoryAdmin,
                Duty::Auditor,
            ],
            Verb::Import(_) => &[Duty::RepositoryAdmin, Duty::SecurityApprover],
            Verb::AllowFirstImport => &[Duty::RepositoryAdmin],
        }
    }
}

/// Why separation of duties refused. Every variant leaves the state as it was.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DutyError {
    /// The principal that imported the artifact asked to approve or release it.
    ImporterCannotApprove { artifact: String, principal: String },
    /// The caller's duties do not permit the verb.
    NotPermitted {
        principal: String,
        held: Vec<Duty>,
        verb: String,
        needs: Vec<Duty>,
    },
    /// This principal already approved this artifact; it counts once.
    DuplicateApprover {
        artifact: String,
        principal: String,
        have: u32,
        need: u32,
    },
    /// An in-process APPROVED move whose vote did not reach the quorum.
    QuorumPending {
        artifact: String,
        have: u32,
        need: u32,
    },
    /// An approval with no authenticated principal.
    AnonymousApprover { artifact: String },
    /// A Developer asked about an artifact that is not AVAILABLE.
    NotAvailable { artifact: String },
    /// The request carried an `approval` evidence slot; only the server mints one.
    ForgedApproval { source: String },
}

impl DutyError {
    /// The frozen `holger-duty-<n>` code. Exhaustive on purpose.
    pub fn code(&self) -> &'static holger_errcode::ErrCode {
        match self {
            DutyError::ImporterCannotApprove { .. } => {
                &holger_errcode::DUTY_IMPORTER_CANNOT_APPROVE
            }
            DutyError::NotPermitted { .. } => &holger_errcode::DUTY_NOT_PERMITTED,
            DutyError::DuplicateApprover { .. } => &holger_errcode::DUTY_DUPLICATE_APPROVER,
            DutyError::QuorumPending { .. } => &holger_errcode::DUTY_QUORUM_PENDING,
            DutyError::AnonymousApprover { .. } => &holger_errcode::DUTY_ANONYMOUS_APPROVER,
            DutyError::NotAvailable { .. } => &holger_errcode::DUTY_NOT_AVAILABLE,
            DutyError::ForgedApproval { .. } => &holger_errcode::DUTY_FORGED_APPROVAL,
        }
    }

    /// The HTTP-like status a door answers with.
    pub fn status(&self) -> u16 {
        match self {
            DutyError::ImporterCannotApprove { .. }
            | DutyError::NotPermitted { .. }
            | DutyError::AnonymousApprover { .. }
            | DutyError::NotAvailable { .. } => 403,
            DutyError::DuplicateApprover { .. } => 409,
            DutyError::QuorumPending { .. } => 412,
            DutyError::ForgedApproval { .. } => 400,
        }
    }

    pub fn tagged(&self) -> String {
        self.code().tag(self)
    }
}

fn duty_names(d: &[Duty]) -> String {
    if d.is_empty() {
        return "none".into();
    }
    d.iter().map(|d| d.name()).collect::<Vec<_>>().join(", ")
}

impl std::fmt::Display for DutyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DutyError::ImporterCannotApprove {
                artifact,
                principal,
            } => write!(
                f,
                "'{principal}' imported '{artifact}' and can never approve or release it, whatever \
                 duties it holds; another SecurityApprover must"
            ),
            DutyError::NotPermitted {
                principal,
                held,
                verb,
                needs,
            } => write!(
                f,
                "'{principal}' holds duties [{}] and may not {verb}; that needs one of [{}]",
                duty_names(held),
                duty_names(needs)
            ),
            DutyError::DuplicateApprover {
                artifact,
                principal,
                have,
                need,
            } => write!(
                f,
                "'{principal}' already approved '{artifact}'; a principal counts once ({have} of \
                 {need} distinct approvals)"
            ),
            DutyError::QuorumPending {
                artifact,
                have,
                need,
            } => write!(
                f,
                "'{artifact}' has {have} of {need} distinct approvals; the vote is recorded and \
                 APPROVED waits for the rest"
            ),
            DutyError::AnonymousApprover { artifact } => write!(
                f,
                "an approval of '{artifact}' must be attested by an authenticated principal; \
                 anonymous never approves"
            ),
            DutyError::NotAvailable { artifact } => write!(
                f,
                "'{artifact}' is not AVAILABLE, and a Developer reads only AVAILABLE artifacts"
            ),
            DutyError::ForgedApproval { source } => write!(
                f,
                "the request carries an approval evidence slot (from '{source}'); only the server \
                 mints approval evidence, from the authenticated approver"
            ),
        }
    }
}

impl std::error::Error for DutyError {}

/// **The duties a caller holds.** RBAC off ⇒ every duty (the pre-RBAC open
/// posture, the same one the admin gate keeps). RBAC on ⇒ the identity's
/// resolved duties, and none without an identity.
pub fn duties_of(auth: &AuthConfig, identity: Option<&AuthIdentity>) -> BTreeSet<Duty> {
    if !auth.rbac_enabled() {
        return Duty::ALL.into_iter().collect();
    }
    identity
        .map(|id| auth.duties_for_identity(id))
        .unwrap_or_default()
}

/// **May a caller INGEST into the intake chain?** (lane W7) With duties configured, only an
/// `Importer` brings an artifact in: the estate's admins (RepositoryAdmin, SecurityApprover) do
/// not, so every upload comes from a principal they invited and they may judge it. With no duty
/// configured, duties are derived from the coarse role and the write gate alone decides, as
/// before.
pub fn may_ingest(auth: &AuthConfig, identity: Option<&AuthIdentity>) -> Result<(), DutyError> {
    if !auth.duties_enabled() {
        return Ok(());
    }
    let held = duties_of(auth, identity);
    if held.contains(&Duty::Importer) {
        return Ok(());
    }
    Err(DutyError::NotPermitted {
        principal: principal_of(identity),
        held: held.into_iter().collect(),
        verb: "ingest into the intake chain".into(),
        needs: vec![Duty::Importer],
    })
}

/// The principal a request acts as.
pub fn principal_of(identity: Option<&AuthIdentity>) -> String {
    identity
        .map(|i| i.subject.clone())
        .unwrap_or_else(|| ANONYMOUS.to_string())
}

/// **May a caller holding `held` perform `verb` on `artifact`?**
pub fn authorize(
    held: &BTreeSet<Duty>,
    principal: &str,
    artifact: &str,
    verb: Verb,
) -> Result<(), DutyError> {
    let needs = verb.needs();
    if needs.iter().any(|d| held.contains(d)) {
        return Ok(());
    }
    if let Verb::Read(state) = verb {
        if state != ArtifactState::Available && held.contains(&Duty::Developer) {
            return Err(DutyError::NotAvailable {
                artifact: artifact.to_string(),
            });
        }
    }
    Err(DutyError::NotPermitted {
        principal: principal.to_string(),
        held: held.iter().copied().collect(),
        verb: verb.name(),
        needs: needs.to_vec(),
    })
}

/// May a caller read the audit log? An Auditor, or anyone who administers.
pub fn may_read_audit(held: &BTreeSet<Duty>) -> bool {
    held.contains(&Duty::Auditor) || held.contains(&Duty::RepositoryAdmin)
}

// ── the wire verb, shared by the gRPC, HTTP and CLI doors ──────────────────

/// What a door asks for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
    /// The next state on the chain (or `to`, which must be the next one).
    Advance,
    Quarantine,
    Release,
    Block,
}

impl Action {
    /// `advance`, `quarantine`, `release`, `block` (any case).
    pub fn parse(raw: &str) -> Option<Self> {
        match raw.trim().to_ascii_lowercase().as_str() {
            "" | "advance" => Some(Action::Advance),
            "quarantine" => Some(Action::Quarantine),
            "release" => Some(Action::Release),
            "block" => Some(Action::Block),
            _ => None,
        }
    }
}

/// One wire request to move an artifact.
#[derive(Debug, Clone)]
pub struct AdvanceAsk {
    pub id: traits::ArtifactId,
    pub action: Action,
    pub to: Option<ArtifactState>,
    pub reason: String,
    pub evidence: Vec<super::intake::Evidence>,
    /// The client address, for the audit record.
    pub source: String,
}

/// What the move did.
#[derive(Debug, Clone)]
pub struct AdvanceAnswer {
    pub from: ArtifactState,
    pub state: ArtifactState,
    pub approvals: u32,
    pub approvals_required: u32,
    pub evidence: Vec<super::intake::Evidence>,
    pub sha256: String,
    pub actor: String,
    pub message: String,
}

/// What a reader is shown.
#[derive(Debug, Clone)]
pub struct StateView {
    pub state: ArtifactState,
    pub sha256: String,
    pub importer: String,
    pub approvals_required: u32,
    pub history: Vec<super::intake::IntakeEntry>,
    pub pending: Vec<super::intake::ApprovalVote>,
}

/// **Move an artifact as `identity`**: its duties decide the verb, the chain
/// enforces the importer rule, the quorum and the attestation.
pub fn advance_as(
    chain: &super::intake::IntakeChain,
    auth: &AuthConfig,
    identity: Option<&AuthIdentity>,
    ask: AdvanceAsk,
) -> Result<AdvanceAnswer, super::intake::IntakeError> {
    use super::intake::{ApproveOutcome, Approver, IntakeError, TransitionRequest};
    let label = super::artifact_label(&ask.id);
    let principal = principal_of(identity);
    let status = chain
        .status(&ask.id)
        .ok_or_else(|| IntakeError::NotIngested(label.clone()))?;
    let from = status.state;
    let to = match ask.action {
        Action::Quarantine => ArtifactState::Quarantined,
        Action::Block => ArtifactState::Blocked,
        Action::Release => match status.held_from {
            Some(h) if from == ArtifactState::Quarantined => h,
            _ => {
                return Err(IntakeError::Terminal {
                    state: from,
                    to: from,
                })
            }
        },
        Action::Advance => match (ask.to, from.next()) {
            (Some(t), _) => t,
            (None, Some(n)) => n,
            (None, None) => {
                return Err(IntakeError::Terminal {
                    state: from,
                    to: from,
                })
            }
        },
    };
    let held = duties_of(auth, identity);
    if let Err(e) = authorize(&held, &principal, &label, Verb::for_move(from, to)) {
        let e = IntakeError::Duty(e);
        chain.record_refusal(&principal, &status.last.repo, &label, &ask.source, &e);
        return Err(e);
    }
    let mut req = TransitionRequest::new(ask.id.clone(), to, principal.clone())
        .with_reason(ask.reason)
        .from_source(ask.source);
    req.evidence = ask.evidence;
    let approver = Approver::from_identity(identity);
    let need = chain.approvals_required();
    Ok(match chain.approve_or_advance(req, &approver)? {
        ApproveOutcome::Pending { have, need, vote } => AdvanceAnswer {
            from,
            state: from,
            approvals: have,
            approvals_required: need,
            evidence: vec![vote.evidence()],
            sha256: vote.sha256,
            actor: principal,
            message: format!(
                "approval {have} of {need} recorded for {label}; {from} until the quorum"
            ),
        },
        ApproveOutcome::Approved { have, entry, .. } => AdvanceAnswer {
            from: entry.from,
            state: entry.to,
            approvals: have,
            approvals_required: need,
            message: format!("{label}: {} → {}", entry.from, entry.to),
            evidence: entry.evidence,
            sha256: entry.sha256,
            actor: principal,
        },
    })
}

/// **Read an artifact's state as `identity`**: a Developer sees only AVAILABLE.
pub fn read_as(
    chain: &super::intake::IntakeChain,
    auth: &AuthConfig,
    identity: Option<&AuthIdentity>,
    id: &traits::ArtifactId,
) -> Result<StateView, super::intake::IntakeError> {
    use super::intake::IntakeError;
    let label = super::artifact_label(id);
    let status = chain
        .status(id)
        .ok_or_else(|| IntakeError::NotIngested(label.clone()))?;
    authorize(
        &duties_of(auth, identity),
        &principal_of(identity),
        &label,
        Verb::Read(status.state),
    )?;
    Ok(StateView {
        state: status.state,
        sha256: status.sha256,
        importer: chain.importer(id).unwrap_or_default(),
        approvals_required: chain.approvals_required(),
        history: chain.history(id),
        pending: chain.pending_approvals(id),
    })
}

/// One row of a queue, as a reader is shown it.
#[derive(Debug, Clone)]
pub struct QueueRow {
    pub id: traits::ArtifactId,
    pub status: super::intake::IntakeStatus,
    pub importer: String,
    pub approvals: u32,
}

/// What the security dashboard is shown.
#[derive(Debug, Clone)]
pub struct QueueView {
    /// Every queue, with how many artifacts in it the caller may read.
    pub counts: Vec<(&'static super::intake::IntakeQueue, u64)>,
    /// The rows of the queue asked for, newest first, at most `limit`.
    pub rows: Vec<QueueRow>,
    pub truncated: bool,
    pub approvals_required: u32,
}

/// **List the intake queues as `identity`**: the same read duty as
/// [`read_as`] decides every row, so an artifact the caller may not read is
/// neither listed nor counted. `queue` empty returns the counts only.
pub fn list_as(
    chain: &super::intake::IntakeChain,
    auth: &AuthConfig,
    identity: Option<&AuthIdentity>,
    queue: Option<&'static super::intake::IntakeQueue>,
    limit: usize,
) -> QueueView {
    let held = duties_of(auth, identity);
    let principal = principal_of(identity);
    let readable: Vec<(traits::ArtifactId, super::intake::IntakeStatus)> = chain
        .list()
        .into_iter()
        .filter(|(id, st)| {
            authorize(
                &held,
                &principal,
                &super::artifact_label(id),
                Verb::Read(st.state),
            )
            .is_ok()
        })
        .collect();
    let counts = super::intake::QUEUES
        .iter()
        .map(|q| {
            (
                q,
                readable
                    .iter()
                    .filter(|(_, st)| q.states.contains(&st.state))
                    .count() as u64,
            )
        })
        .collect();
    let mut rows: Vec<QueueRow> = Vec::new();
    let mut truncated = false;
    if let Some(q) = queue {
        for (id, status) in readable
            .into_iter()
            .filter(|(_, st)| q.states.contains(&st.state))
        {
            if rows.len() == limit {
                truncated = true;
                break;
            }
            rows.push(QueueRow {
                importer: chain.importer(&id).unwrap_or_default(),
                approvals: chain.pending_approvals(&id).len() as u32,
                id,
                status,
            });
        }
    }
    QueueView {
        counts,
        rows,
        truncated,
        approvals_required: chain.approvals_required(),
    }
}

/// What an artifact's evidence reader is shown.
#[derive(Debug, Clone)]
pub struct EvidenceView {
    pub state: ArtifactState,
    pub sha256: String,
    /// The latest scan record for the artifact's bytes; `None` before a scan.
    pub evidence: Option<crate::scanner::Evidence>,
    /// The policy's words on the last transition.
    pub policy: String,
    /// W5: every stored record, oldest scan first, with its signing envelope.
    pub stored: Vec<EvidenceRecordView>,
    /// The attestation signer's certificate (DER), when evidence is signed.
    pub signer_cert: Vec<u8>,
    /// `/-/intake/sbom?sha256=…` when the newest record carries an SBOM.
    pub sbom_link: Option<String>,
}

impl EvidenceView {
    /// The SBOM the newest record carries, if any.
    pub fn sbom(&self) -> Option<&serde_json::Value> {
        self.evidence
            .as_ref()
            .and_then(|e| e.document_of(crate::scanner::ScannerKind::Sbom))
    }
}

/// One stored scan-evidence record, as a reader is shown it (W5).
#[derive(Debug, Clone)]
pub struct EvidenceRecordView {
    /// The blake3 of the record's bytes.
    pub id: String,
    /// The record's bytes, as stored.
    pub bytes: Vec<u8>,
    pub evidence: crate::scanner::Evidence,
    /// The DSSE envelope that verifies to this record, when it is signed.
    pub envelope: Option<Vec<u8>>,
    /// Why a scan now could differ; `None` when it is fresh.
    pub stale: Option<String>,
}

/// The HTTP route an SBOM link names.
pub const SBOM_ROUTE: &str = "/-/intake/sbom";

/// The signer's certificate (DER); a build without `sign` signs nothing.
#[cfg(feature = "sign")]
fn signer_cert_of(signer: &crate::attest::AttestSigner) -> Vec<u8> {
    signer.cert_der().to_vec()
}
#[cfg(not(feature = "sign"))]
fn signer_cert_of(_signer: &crate::attest::AttestSigner) -> Vec<u8> {
    Vec::new()
}

/// Every stored record about `sha256` with the envelope that verifies to it,
/// and the signer's certificate.
fn stored_records(
    pipeline: &crate::scanner::ScanPipeline,
    sha256: &str,
) -> Result<(Vec<EvidenceRecordView>, Vec<u8>), super::intake::IntakeError> {
    use super::intake::IntakeError;
    let digest = crate::attest::ArtifactDigest::sha256(sha256)
        .map_err(|e| IntakeError::Store(e.tagged()))?;
    let stored = pipeline
        .store()
        .list_records(sha256)
        .map_err(|e| IntakeError::Store(e.tagged()))?;
    let (envelopes, signer_cert) = match pipeline.attestation() {
        Some((store, signer)) => (
            store
                .query(&digest, crate::attest::HOLGER_SCAN_EVIDENCE_V1)
                .map_err(|e| IntakeError::Store(e.tagged()))?
                .into_iter()
                .map(|a| a.envelope_json)
                .collect::<Vec<_>>(),
            signer_cert_of(signer),
        ),
        None => (Vec::new(), Vec::new()),
    };
    let certs = vec![signer_cert.clone()];
    let records = stored
        .into_iter()
        .map(|(id, bytes, evidence)| {
            let envelope = pipeline.attestation().and_then(|(store, _)| {
                envelopes
                    .iter()
                    .find(|env| {
                        crate::scanner::attestation::verify_evidence(store, env, &certs, &digest)
                            .is_ok_and(|ev| ev == evidence)
                    })
                    .cloned()
            });
            let stale = pipeline.scanners().staleness(&evidence);
            EvidenceRecordView {
                id,
                bytes,
                evidence,
                envelope,
                stale,
            }
        })
        .collect();
    Ok((records, signer_cert))
}

/// **The coordinate that holds these bytes**, for the `?sha256=` doors: an
/// AVAILABLE one first, otherwise the newest record. Then [`evidence_as`]
/// reads it, so both keys go through the ONE read.
pub fn evidence_by_sha256_as(
    chain: &super::intake::IntakeChain,
    auth: &AuthConfig,
    identity: Option<&AuthIdentity>,
    sha256: &str,
) -> Result<EvidenceView, super::intake::IntakeError> {
    let holding: Vec<(traits::ArtifactId, super::intake::IntakeStatus)> = chain
        .list()
        .into_iter()
        .filter(|(_, st)| st.sha256 == sha256)
        .collect();
    let id = holding
        .iter()
        .find(|(_, st)| st.state == ArtifactState::Available)
        .or(holding.first())
        .map(|(id, _)| id.clone())
        .ok_or_else(|| super::intake::IntakeError::NotIngested(format!("sha256:{sha256}")))?;
    evidence_as(chain, auth, identity, &id)
}

/// **Read an artifact's scan evidence as `identity`**: the read duty of
/// [`read_as`], then the latest record of the evidence store for the bytes
/// recorded at ingest.
pub fn evidence_as(
    chain: &super::intake::IntakeChain,
    auth: &AuthConfig,
    identity: Option<&AuthIdentity>,
    id: &traits::ArtifactId,
) -> Result<EvidenceView, super::intake::IntakeError> {
    let view = read_as(chain, auth, identity, id)?;
    let evidence = match chain.scanner() {
        Some(p) if !view.sha256.is_empty() => p
            .store()
            .latest(&view.sha256)
            .map_err(|e| super::intake::IntakeError::Store(e.tagged()))?,
        _ => None,
    };
    let policy = view
        .history
        .last()
        .map(|e| e.policy.clone())
        .unwrap_or_default();
    let (stored, signer_cert) = match chain.scanner() {
        Some(p) if !view.sha256.is_empty() => stored_records(p, &view.sha256)?,
        _ => (Vec::new(), Vec::new()),
    };
    let sbom_link = evidence
        .as_ref()
        .and_then(|e| e.document_of(crate::scanner::ScannerKind::Sbom))
        .map(|_| format!("{SBOM_ROUTE}?sha256={}", view.sha256));
    Ok(EvidenceView {
        state: view.state,
        sha256: view.sha256,
        evidence,
        policy,
        stored,
        signer_cert,
        sbom_link,
    })
}

/// **The wire verb's client**, for the `holger-server intake` 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::promotion_service_client::PromotionServiceClient;
    use crate::grpc::holger_proto::{
        AdvanceArtifactRequest, AdvanceArtifactResponse, GetArtifactStateRequest,
        GetArtifactStateResponse,
    };

    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<PromotionServiceClient<tonic::transport::Channel>, String> {
        PromotionServiceClient::connect(crate::twin_follow::endpoint_of(server))
            .await
            .map_err(|e| format!("holger's gRPC door at {server} was not reached: {e}"))
    }

    /// `PromotionService.AdvanceArtifact`; a refusal is its tagged message.
    pub async fn advance(
        server: &str,
        bearer: Option<&str>,
        req: AdvanceArtifactRequest,
    ) -> Result<AdvanceArtifactResponse, String> {
        let mut c = connect(server).await?;
        c.advance_artifact(with_bearer(req, bearer)?)
            .await
            .map(|r| r.into_inner())
            .map_err(|s| s.message().to_string())
    }

    /// `PromotionService.GetArtifactEvidence`; a refusal is its tagged message.
    pub async fn evidence(
        server: &str,
        bearer: Option<&str>,
        req: crate::grpc::holger_proto::GetArtifactEvidenceRequest,
    ) -> Result<crate::grpc::holger_proto::GetArtifactEvidenceResponse, String> {
        let mut c = connect(server).await?;
        c.get_artifact_evidence(with_bearer(req, bearer)?)
            .await
            .map(|r| r.into_inner())
            .map_err(|s| s.message().to_string())
    }

    /// `PromotionService.GetArtifactState`; a refusal is its tagged message.
    pub async fn state(
        server: &str,
        bearer: Option<&str>,
        req: GetArtifactStateRequest,
    ) -> Result<GetArtifactStateResponse, String> {
        let mut c = connect(server).await?;
        c.get_artifact_state(with_bearer(req, bearer)?)
            .await
            .map(|r| r.into_inner())
            .map_err(|s| s.message().to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ArtifactState::*;

    fn set(d: &[Duty]) -> BTreeSet<Duty> {
        d.iter().copied().collect()
    }

    #[test]
    fn an_auditor_and_a_developer_move_nothing() {
        for held in [
            set(&[Duty::Auditor]),
            set(&[Duty::Developer]),
            set(&[Duty::Auditor, Duty::Developer]),
        ] {
            for verb in [
                Verb::Advance(Scanned),
                Verb::Advance(PolicyChecked),
                Verb::Approve,
                Verb::Advance(Sealed),
                Verb::Advance(Promoted),
                Verb::Advance(Available),
                Verb::Quarantine,
                Verb::Release,
                Verb::Block,
            ] {
                let e = authorize(&held, "aud", "w@1", verb).expect_err("refused");
                assert_eq!(e.code().code, "holger-duty-2", "{e}");
            }
        }
    }

    #[test]
    fn a_repository_admin_configures_and_seals_but_never_approves() {
        let held = set(&[Duty::RepositoryAdmin]);
        assert!(authorize(&held, "adm", "w@1", Verb::Advance(Sealed)).is_ok());
        for verb in [Verb::Approve, Verb::Quarantine, Verb::Release, Verb::Block] {
            assert!(authorize(&held, "adm", "w@1", verb).is_err(), "{verb:?}");
        }
    }

    #[test]
    fn a_developer_reads_only_available() {
        let held = set(&[Duty::Developer]);
        assert!(authorize(&held, "dev", "w@1", Verb::Read(Available)).is_ok());
        for s in [
            Ingested,
            Scanned,
            PolicyChecked,
            Approved,
            Sealed,
            Promoted,
            Quarantined,
            Blocked,
        ] {
            let e = authorize(&held, "dev", "w@1", Verb::Read(s)).expect_err("refused");
            assert_eq!(e.code().code, "holger-duty-6", "{s}: {e}");
        }
        assert!(authorize(
            &set(&[Duty::Auditor]),
            "aud",
            "w@1",
            Verb::Read(Quarantined)
        )
        .is_ok());
    }

    #[test]
    fn rbac_off_holds_every_duty_and_rbac_on_without_identity_holds_none() {
        let open = AuthConfig::default();
        assert_eq!(duties_of(&open, None).len(), Duty::ALL.len());
        let mut roles = std::collections::HashMap::new();
        roles.insert("x".to_string(), crate::auth::Role::Reader);
        let on = AuthConfig {
            roles,
            ..Default::default()
        };
        assert!(duties_of(&on, None).is_empty());
    }

    #[test]
    fn only_a_repository_admin_or_a_security_approver_imports_a_bundle() {
        for d in [Duty::RepositoryAdmin, Duty::SecurityApprover] {
            assert!(
                authorize(&set(&[d]), "p", "b", Verb::Import("intel")).is_ok(),
                "{d:?}"
            );
        }
        for d in [Duty::Importer, Duty::Developer, Duty::Auditor] {
            let e = authorize(&set(&[d]), "p", "b", Verb::Import("update")).expect_err("refused");
            assert_eq!(e.code().code, "holger-duty-2", "{d:?}: {e}");
            assert!(
                e.to_string().contains("may not import an update bundle"),
                "{e}"
            );
        }
    }

    #[test]
    fn the_verb_for_a_move_names_holds_and_releases() {
        assert_eq!(Verb::for_move(Scanned, Quarantined), Verb::Quarantine);
        assert_eq!(Verb::for_move(Quarantined, Scanned), Verb::Release);
        assert_eq!(Verb::for_move(Quarantined, Blocked), Verb::Block);
        assert_eq!(Verb::for_move(PolicyChecked, Approved), Verb::Approve);
        assert_eq!(Verb::for_move(Approved, Sealed), Verb::Advance(Sealed));
    }
}