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