use std::collections::BTreeSet;
use content_addressable::canonical::to_canonical_dagcbor;
use content_addressable::{ContentAddressable, ContentError, ContentId};
use serde::{Deserialize, Serialize};
use crate::caveats::{Caveats, Scope};
const AUTHORITY_KIND: &str = "agent-mesh/provenance/authority/v1";
const GRANT_KIND: &str = "agent-mesh/provenance/grant/v1";
#[derive(Serialize)]
struct Tagged<'a, B: Serialize> {
kind: &'a str,
body: &'a B,
}
fn typed_canonical_form<B: Serialize>(kind: &str, body: &B) -> Result<Vec<u8>, ContentError> {
to_canonical_dagcbor(&Tagged { kind, body })
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AuthorityId(ContentId);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct GrantId(ContentId);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct AttestationId(ContentId);
impl AuthorityId {
#[must_use]
pub fn content_id(&self) -> ContentId {
self.0
}
}
impl GrantId {
#[must_use]
pub fn content_id(&self) -> ContentId {
self.0
}
}
impl AttestationId {
#[must_use]
pub fn content_id(&self) -> ContentId {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Authority {
caveats: Caveats,
}
impl Authority {
#[must_use]
pub fn new(caveats: Caveats) -> Self {
Self { caveats }
}
#[must_use]
pub fn caveats(&self) -> &Caveats {
&self.caveats
}
pub fn id(&self) -> Result<AuthorityId, ContentError> {
Ok(AuthorityId(self.content_id()?))
}
}
impl ContentAddressable for Authority {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
typed_canonical_form(AUTHORITY_KIND, &self.caveats)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Derivation {
Root,
Attenuation { parent: GrantId },
Elevation {
parent: GrantId,
attestation: AttestationId,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Grant {
authority: AuthorityId,
derivation: Derivation,
}
impl Grant {
#[must_use]
pub fn new(authority: AuthorityId, derivation: Derivation) -> Self {
Self {
authority,
derivation,
}
}
#[must_use]
pub fn authority(&self) -> AuthorityId {
self.authority
}
#[must_use]
pub fn derivation(&self) -> &Derivation {
&self.derivation
}
pub fn id(&self) -> Result<GrantId, ContentError> {
Ok(GrantId(self.content_id()?))
}
}
impl ContentAddressable for Grant {
fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
#[derive(Serialize)]
struct Body<'a> {
authority: &'a AuthorityId,
derivation: &'a Derivation,
}
typed_canonical_form(
GRANT_KIND,
&Body {
authority: &self.authority,
derivation: &self.derivation,
},
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthorityMismatch {
pub named_by_grant: AuthorityId,
pub of_body: AuthorityId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindError {
Encoding(String),
AuthorityCidMismatch(Box<AuthorityMismatch>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedGrant {
grant: Grant,
authority: Authority,
}
impl ResolvedGrant {
pub fn bind(grant: Grant, authority: Authority) -> Result<Self, BindError> {
let of_body = authority
.id()
.map_err(|e| BindError::Encoding(e.to_string()))?;
if of_body != grant.authority() {
return Err(BindError::AuthorityCidMismatch(Box::new(
AuthorityMismatch {
named_by_grant: grant.authority(),
of_body,
},
)));
}
Ok(Self { grant, authority })
}
#[must_use]
pub fn grant(&self) -> &Grant {
&self.grant
}
#[must_use]
pub fn authority(&self) -> &Authority {
&self.authority
}
pub fn id(&self) -> Result<GrantId, ContentError> {
self.grant.id()
}
}
pub trait AttestationVerifier {
fn verify_elevation(&self, parent: GrantId, child: &Grant, attestation: AttestationId) -> bool;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DenyAllElevations;
impl AttestationVerifier for DenyAllElevations {
fn verify_elevation(
&self,
_parent: GrantId,
_child: &Grant,
_attestation: AttestationId,
) -> bool {
false
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DerivationReject {
AttenuationWidens,
ParentMismatch,
ElevationUnauthorized,
MissingParent,
CidUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DerivationDecision {
Valid,
Reject(DerivationReject),
}
#[must_use]
pub fn check_derivation(
child: &ResolvedGrant,
parent: Option<&ResolvedGrant>,
verifier: &dyn AttestationVerifier,
) -> DerivationDecision {
use DerivationReject as R;
let matched_parent = |declared: &GrantId| -> Result<&ResolvedGrant, DerivationDecision> {
let parent = parent.ok_or(DerivationDecision::Reject(R::MissingParent))?;
let parent_id = parent
.id()
.map_err(|_| DerivationDecision::Reject(R::CidUnavailable))?;
if &parent_id != declared {
return Err(DerivationDecision::Reject(R::ParentMismatch));
}
Ok(parent)
};
match child.grant().derivation() {
Derivation::Root => DerivationDecision::Valid,
Derivation::Attenuation {
parent: declared_parent,
} => {
let parent = match matched_parent(declared_parent) {
Ok(p) => p,
Err(reject) => return reject,
};
if child
.authority()
.caveats()
.leq(parent.authority().caveats())
{
DerivationDecision::Valid
} else {
DerivationDecision::Reject(R::AttenuationWidens)
}
}
Derivation::Elevation {
parent: declared_parent,
attestation,
} => {
let parent = match matched_parent(declared_parent) {
Ok(p) => p,
Err(reject) => return reject,
};
let parent_id = match parent.id() {
Ok(id) => id,
Err(_) => return DerivationDecision::Reject(R::CidUnavailable),
};
if verifier.verify_elevation(parent_id, child.grant(), *attestation) {
DerivationDecision::Valid
} else {
DerivationDecision::Reject(R::ElevationUnauthorized)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainReject {
Empty,
Edge {
index: usize,
reason: DerivationReject,
},
RootNotTerminal { index: usize },
NonRootTerminus,
UntrustedRoot { found: GrantId, expected: GrantId },
CidUnavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainDecision {
Trusted,
Reject(ChainReject),
}
#[must_use]
pub fn verify_chain(
chain: &[ResolvedGrant],
trusted_root: GrantId,
verifier: &dyn AttestationVerifier,
) -> ChainDecision {
let Some(terminus) = chain.last() else {
return ChainDecision::Reject(ChainReject::Empty);
};
for (index, child) in chain.iter().enumerate() {
if index == chain.len() - 1 {
break;
}
if matches!(child.grant().derivation(), Derivation::Root) {
return ChainDecision::Reject(ChainReject::RootNotTerminal { index });
}
let parent = &chain[index + 1];
match check_derivation(child, Some(parent), verifier) {
DerivationDecision::Valid => {}
DerivationDecision::Reject(reason) => {
return ChainDecision::Reject(ChainReject::Edge { index, reason });
}
}
}
if !matches!(terminus.grant().derivation(), Derivation::Root) {
return ChainDecision::Reject(ChainReject::NonRootTerminus);
}
let root_id = match terminus.id() {
Ok(id) => id,
Err(_) => return ChainDecision::Reject(ChainReject::CidUnavailable),
};
if root_id != trusted_root {
return ChainDecision::Reject(ChainReject::UntrustedRoot {
found: root_id,
expected: trusted_root,
});
}
ChainDecision::Trusted
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedScope {
Bounded {
concrete: BTreeSet<String>,
classes: BTreeSet<String>,
},
Unbounded,
Unknown,
}
impl ResolvedScope {
#[must_use]
pub fn empty() -> Self {
Self::Bounded {
concrete: BTreeSet::new(),
classes: BTreeSet::new(),
}
}
#[must_use]
pub fn concrete<I: IntoIterator<Item = String>>(items: I) -> Self {
Self::Bounded {
concrete: items.into_iter().collect(),
classes: BTreeSet::new(),
}
}
#[must_use]
pub fn class(name: impl Into<String>) -> Self {
Self::Bounded {
concrete: BTreeSet::new(),
classes: BTreeSet::from([name.into()]),
}
}
#[must_use]
pub fn from_scope(scope: &Scope<String>) -> Self {
match scope {
Scope::All => Self::Unbounded,
Scope::Only(set) => Self::Bounded {
concrete: set.clone(),
classes: BTreeSet::new(),
},
}
}
#[must_use]
pub fn union(&self, other: &Self) -> Self {
match (self, other) {
(Self::Unknown, _) | (_, Self::Unknown) => Self::Unknown,
(Self::Unbounded, _) | (_, Self::Unbounded) => Self::Unbounded,
(
Self::Bounded {
concrete: ca,
classes: la,
},
Self::Bounded {
concrete: cb,
classes: lb,
},
) => Self::Bounded {
concrete: ca.union(cb).cloned().collect(),
classes: la.union(lb).cloned().collect(),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeRelation {
Equal,
Subset,
Superset,
Incomparable,
Unknown,
}
#[must_use]
pub fn relate(resolved: &ResolvedScope, bound: &ResolvedScope) -> ScopeRelation {
use ResolvedScope as S;
match (resolved, bound) {
(S::Unknown, _) | (_, S::Unknown) => ScopeRelation::Unknown,
(S::Unbounded, S::Unbounded) => ScopeRelation::Equal,
(S::Unbounded, _) => ScopeRelation::Superset,
(_, S::Unbounded) => ScopeRelation::Subset,
(
S::Bounded {
concrete: ca,
classes: la,
},
S::Bounded {
concrete: cb,
classes: lb,
},
) => {
if ca == cb && la == lb {
ScopeRelation::Equal
} else if ca.is_subset(cb) && la.is_subset(lb) {
ScopeRelation::Subset
} else if ca.is_superset(cb) && la.is_superset(lb) {
ScopeRelation::Superset
} else {
ScopeRelation::Incomparable
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedAuthority {
pub fs_read: ResolvedScope,
pub fs_write: ResolvedScope,
pub exec: ResolvedScope,
pub net: ResolvedScope,
}
impl ResolvedAuthority {
fn axes(&self) -> [(ConfinedAxis, &ResolvedScope); 4] {
[
(ConfinedAxis::FsRead, &self.fs_read),
(ConfinedAxis::FsWrite, &self.fs_write),
(ConfinedAxis::Exec, &self.exec),
(ConfinedAxis::Net, &self.net),
]
}
#[must_use]
pub fn from_delegated(caveats: &Caveats) -> Self {
Self {
fs_read: ResolvedScope::from_scope(&caveats.fs_read),
fs_write: ResolvedScope::from_scope(&caveats.fs_write),
exec: ResolvedScope::from_scope(&caveats.exec),
net: ResolvedScope::from_scope(&caveats.net),
}
}
fn axis(&self, axis: ConfinedAxis) -> &ResolvedScope {
match axis {
ConfinedAxis::FsRead => &self.fs_read,
ConfinedAxis::FsWrite => &self.fs_write,
ConfinedAxis::Exec => &self.exec,
ConfinedAxis::Net => &self.net,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConfinedAxis {
FsRead,
FsWrite,
Exec,
Net,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdmissionReject {
pub axis: ConfinedAxis,
pub relation: ScopeRelation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdmissionDecision {
Admit,
Reject(AdmissionReject),
}
#[must_use]
pub fn admit(
resolved: &ResolvedAuthority,
delegated: &Caveats,
closure: &ResolvedAuthority,
) -> AdmissionDecision {
let delegated = ResolvedAuthority::from_delegated(delegated);
for (axis, fence_scope) in resolved.axes() {
let bound = delegated.axis(axis).union(closure.axis(axis));
match relate(fence_scope, &bound) {
ScopeRelation::Equal | ScopeRelation::Subset => {}
relation => {
return AdmissionDecision::Reject(AdmissionReject { axis, relation });
}
}
}
AdmissionDecision::Admit
}
#[must_use]
pub fn empty_closure() -> ResolvedAuthority {
ResolvedAuthority {
fs_read: ResolvedScope::empty(),
fs_write: ResolvedScope::empty(),
exec: ResolvedScope::empty(),
net: ResolvedScope::empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::caveats::CountBound;
fn concrete(items: &[&str]) -> ResolvedScope {
ResolvedScope::concrete(items.iter().map(|s| s.to_string()))
}
fn caveats(fs_read: Scope<String>, exec: Scope<String>, net: Scope<String>) -> Caveats {
Caveats {
fs_read,
fs_write: Scope::none(),
exec,
net,
max_calls: CountBound::top(),
valid_for_generation: Scope::top(),
}
}
fn root_grant(c: Caveats) -> ResolvedGrant {
let auth = Authority::new(c);
let grant = Grant::new(auth.id().unwrap(), Derivation::Root);
ResolvedGrant::bind(grant, auth).unwrap()
}
fn derived_grant(c: Caveats, d: Derivation) -> ResolvedGrant {
let auth = Authority::new(c);
let grant = Grant::new(auth.id().unwrap(), d);
ResolvedGrant::bind(grant, auth).unwrap()
}
#[test]
fn authority_id_is_deterministic() {
let a = Authority::new(Caveats::top());
assert_eq!(a.id().unwrap(), a.clone().id().unwrap());
}
#[test]
fn typed_domains_cannot_collide() {
let auth = Authority::new(Caveats::top());
let auth_id = auth.id().unwrap();
let grant = Grant::new(auth_id, Derivation::Root);
assert_ne!(auth_id.content_id(), grant.id().unwrap().content_id());
}
#[test]
fn identity_is_transport_agnostic() {
let auth_id = Authority::new(caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
))
.id()
.unwrap();
let g = Grant::new(auth_id, Derivation::Root);
let back: Grant = serde_json::from_slice(&serde_json::to_vec(&g).unwrap()).unwrap();
assert_eq!(g, back);
assert_eq!(g.id().unwrap(), back.id().unwrap());
}
#[test]
fn grant_id_splits_authority_from_derivation() {
let auth_id = Authority::new(Caveats::top()).id().unwrap();
let other_parent = Grant::new(
Authority::new(caveats(Scope::none(), Scope::none(), Scope::none()))
.id()
.unwrap(),
Derivation::Root,
)
.id()
.unwrap();
let root = Grant::new(auth_id, Derivation::Root);
let attenuated = Grant::new(
auth_id,
Derivation::Attenuation {
parent: other_parent,
},
);
assert_ne!(root.id().unwrap(), attenuated.id().unwrap());
}
#[test]
fn attenuation_admits_only_when_child_leq_parent() {
let parent = root_grant(caveats(
Scope::only(["/repo".into(), "/tmp".into()]),
Scope::only(["git".into()]),
Scope::none(),
));
let parent_id = parent.id().unwrap();
let child = derived_grant(
caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
),
Derivation::Attenuation { parent: parent_id },
);
assert_eq!(
check_derivation(&child, Some(&parent), &DenyAllElevations),
DerivationDecision::Valid
);
let widening = derived_grant(
caveats(
Scope::only(["/repo".into(), "/etc".into()]),
Scope::only(["git".into()]),
Scope::none(),
),
Derivation::Attenuation { parent: parent_id },
);
assert_eq!(
check_derivation(&widening, Some(&parent), &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::AttenuationWidens)
);
}
#[test]
fn elevation_is_fail_closed_without_a_verifier() {
let parent = root_grant(Caveats::top());
let parent_id = parent.id().unwrap();
let child = derived_grant(
Caveats::top(),
Derivation::Elevation {
parent: parent_id,
attestation: AttestationId(parent_id.content_id()),
},
);
assert_eq!(
check_derivation(&child, Some(&parent), &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::ElevationUnauthorized)
);
}
#[test]
fn derivation_rejects_a_parent_that_is_not_the_named_grant() {
let parent = root_grant(Caveats::top());
let parent_id = parent.id().unwrap();
let impostor = root_grant(caveats(Scope::none(), Scope::none(), Scope::none()));
assert_ne!(impostor.id().unwrap(), parent_id);
let child = derived_grant(
Caveats::top(),
Derivation::Attenuation { parent: parent_id },
);
assert_eq!(
check_derivation(&child, Some(&impostor), &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::ParentMismatch)
);
}
#[test]
fn bind_rejects_a_body_the_grant_does_not_name() {
let named = Authority::new(caveats(
Scope::only(["/repo".into(), "/etc".into()]),
Scope::none(),
Scope::none(),
));
let grant = Grant::new(named.id().unwrap(), Derivation::Root);
let convenient = Authority::new(caveats(
Scope::only(["/repo".into()]),
Scope::none(),
Scope::none(),
));
match ResolvedGrant::bind(grant, convenient) {
Err(BindError::AuthorityCidMismatch(_)) => {}
other => panic!("a mismatched body must be rejected, got {other:?}"),
}
}
#[test]
fn attenuation_cannot_be_validated_against_a_substituted_body() {
let parent = root_grant(caveats(
Scope::only(["/repo".into()]),
Scope::none(),
Scope::none(),
));
let parent_id = parent.id().unwrap();
let widening = Authority::new(caveats(
Scope::only(["/repo".into(), "/etc".into()]),
Scope::none(),
Scope::none(),
));
let child_grant = Grant::new(
widening.id().unwrap(),
Derivation::Attenuation { parent: parent_id },
);
let convenient = Authority::new(caveats(
Scope::only(["/repo".into()]),
Scope::none(),
Scope::none(),
));
assert!(ResolvedGrant::bind(child_grant.clone(), convenient).is_err());
let honest = ResolvedGrant::bind(child_grant, widening).unwrap();
assert_eq!(
check_derivation(&honest, Some(&parent), &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::AttenuationWidens)
);
}
#[test]
fn attenuation_cannot_be_validated_against_a_substituted_parent_body() {
let child_caveats = caveats(
Scope::only(["/repo".into(), "/etc".into()]),
Scope::none(),
Scope::none(),
);
let narrow_parent_auth = Authority::new(caveats(
Scope::only(["/repo".into()]),
Scope::none(),
Scope::none(),
));
let parent_grant = Grant::new(narrow_parent_auth.id().unwrap(), Derivation::Root);
let child = derived_grant(
child_caveats,
Derivation::Attenuation {
parent: parent_grant.id().unwrap(),
},
);
let convenient_wide = Authority::new(caveats(
Scope::only(["/repo".into(), "/etc".into(), "/srv".into()]),
Scope::none(),
Scope::none(),
));
assert!(ResolvedGrant::bind(parent_grant.clone(), convenient_wide).is_err());
let honest_parent = ResolvedGrant::bind(parent_grant, narrow_parent_auth).unwrap();
assert_eq!(
check_derivation(&child, Some(&honest_parent), &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::AttenuationWidens)
);
}
#[test]
fn derivation_without_a_parent_fails_closed() {
let parent_id = root_grant(Caveats::top()).id().unwrap();
let child = derived_grant(
Caveats::top(),
Derivation::Attenuation { parent: parent_id },
);
assert_eq!(
check_derivation(&child, None, &DenyAllElevations),
DerivationDecision::Reject(DerivationReject::MissingParent)
);
}
#[test]
fn canonical_form_is_a_hash_preimage_not_a_value_codec() {
use content_addressable::canonical::{
from_canonical_dagcbor_checked, to_canonical_dagcbor,
};
let auth = golden_authority();
let hash_preimage = auth.canonical_form().unwrap();
let decoded_as_value: Result<Authority, _> = from_canonical_dagcbor_checked(&hash_preimage);
assert!(
decoded_as_value.is_err(),
"the tagged hash preimage must not decode as the bare value type"
);
let wire = to_canonical_dagcbor(&auth).unwrap();
let back: Authority = from_canonical_dagcbor_checked(&wire).unwrap();
assert_eq!(auth, back);
assert_eq!(auth.id().unwrap(), back.id().unwrap());
let grant = Grant::new(auth.id().unwrap(), Derivation::Root);
let grant_wire = to_canonical_dagcbor(&grant).unwrap();
let grant_back: Grant = from_canonical_dagcbor_checked(&grant_wire).unwrap();
assert_eq!(grant, grant_back);
assert_eq!(grant.id().unwrap(), grant_back.id().unwrap());
assert!(from_canonical_dagcbor_checked::<Grant>(&grant.canonical_form().unwrap()).is_err());
}
fn golden_authority() -> Authority {
Authority::new(caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
))
}
#[test]
fn golden_authority_cid_is_pinned() {
assert_eq!(
golden_authority().id().unwrap().content_id().to_string(),
"bafyr4igphdy36nsau5r4pzj2qmraozhexhp4k2acxv4bymeuq4xj7kjsjq"
);
}
#[test]
fn golden_root_grant_cid_is_pinned() {
let root = Grant::new(golden_authority().id().unwrap(), Derivation::Root);
assert_eq!(
root.id().unwrap().content_id().to_string(),
"bafyr4ia3m4fhli2zz3lnuulufidc5qitut5wkahyguh3fjneakpgn6keqi"
);
}
#[test]
fn golden_attenuation_grant_cid_is_pinned() {
let auth_id = golden_authority().id().unwrap();
let root = Grant::new(auth_id, Derivation::Root);
let atten = Grant::new(
auth_id,
Derivation::Attenuation {
parent: root.id().unwrap(),
},
);
assert_eq!(
atten.id().unwrap().content_id().to_string(),
"bafyr4ihshpvudtgiiseiwnbezg6cwzmonkmzzdiwf54plgfzenmhbohly4"
);
}
fn root_and_child(parent_c: Caveats, child_c: Caveats) -> (ResolvedGrant, ResolvedGrant) {
let root = root_grant(parent_c);
let child = derived_grant(
child_c,
Derivation::Attenuation {
parent: root.id().unwrap(),
},
);
(root, child)
}
#[test]
fn chain_is_trusted_only_when_it_terminates_at_the_configured_root() {
let (root, child) = root_and_child(
caveats(
Scope::only(["/repo".into(), "/tmp".into()]),
Scope::only(["git".into()]),
Scope::none(),
),
caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
),
);
let trusted = root.id().unwrap();
let chain = [child, root];
assert_eq!(
verify_chain(&chain, trusted, &DenyAllElevations),
ChainDecision::Trusted
);
}
#[test]
fn a_self_authored_root_is_rejected_unless_it_is_the_configured_anchor() {
let (attacker_root, child) = root_and_child(
Caveats::top(),
caveats(Scope::only(["/repo".into()]), Scope::none(), Scope::none()),
);
assert_eq!(
check_derivation(&child, Some(&attacker_root), &DenyAllElevations),
DerivationDecision::Valid
);
let real_anchor = root_grant(caveats(
Scope::only(["/srv".into()]),
Scope::none(),
Scope::none(),
))
.id()
.unwrap();
let attacker_root_id = attacker_root.id().unwrap();
let chain = [child, attacker_root];
assert_eq!(
verify_chain(&chain, real_anchor, &DenyAllElevations),
ChainDecision::Reject(ChainReject::UntrustedRoot {
found: attacker_root_id,
expected: real_anchor,
})
);
}
#[test]
fn a_chain_that_does_not_bottom_out_at_a_root_is_rejected() {
let orphan = derived_grant(
Caveats::top(),
Derivation::Attenuation {
parent: root_grant(Caveats::top()).id().unwrap(),
},
);
let anchor = root_grant(Caveats::top()).id().unwrap();
assert_eq!(
verify_chain(&[orphan], anchor, &DenyAllElevations),
ChainDecision::Reject(ChainReject::NonRootTerminus)
);
}
#[test]
fn a_broken_link_in_the_chain_is_rejected() {
let real_root = root_grant(Caveats::top());
let other_parent_id = root_grant(caveats(
Scope::only(["/elsewhere".into()]),
Scope::none(),
Scope::none(),
))
.id()
.unwrap();
let child = derived_grant(
Caveats::top(),
Derivation::Attenuation {
parent: other_parent_id,
},
);
let trusted = real_root.id().unwrap();
let chain = [child, real_root];
assert_eq!(
verify_chain(&chain, trusted, &DenyAllElevations),
ChainDecision::Reject(ChainReject::Edge {
index: 0,
reason: DerivationReject::ParentMismatch,
})
);
}
#[test]
fn an_empty_chain_proves_nothing() {
let anchor = root_grant(Caveats::top()).id().unwrap();
assert_eq!(
verify_chain(&[], anchor, &DenyAllElevations),
ChainDecision::Reject(ChainReject::Empty)
);
}
#[test]
fn relate_classifies_subset_superset_equal() {
assert_eq!(
relate(&concrete(&["a"]), &concrete(&["a", "b"])),
ScopeRelation::Subset
);
assert_eq!(
relate(&concrete(&["a", "b"]), &concrete(&["a"])),
ScopeRelation::Superset
);
assert_eq!(
relate(&concrete(&["a"]), &concrete(&["a"])),
ScopeRelation::Equal
);
assert_eq!(
relate(&concrete(&["a"]), &concrete(&["b"])),
ScopeRelation::Incomparable
);
assert_eq!(
relate(&ResolvedScope::Unbounded, &concrete(&["a"])),
ScopeRelation::Superset
);
assert_eq!(
relate(&ResolvedScope::Unknown, &concrete(&["a"])),
ScopeRelation::Unknown
);
assert_eq!(
relate(&ResolvedScope::class("loopback"), &concrete(&["127.0.0.1"])),
ScopeRelation::Incomparable
);
assert_eq!(
relate(
&ResolvedScope::class("loopback"),
&ResolvedScope::class("loopback")
),
ScopeRelation::Equal
);
}
#[test]
fn admit_when_fence_matches_the_delegated_grant() {
let delegated = caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
);
let fence = ResolvedAuthority {
fs_read: concrete(&["/repo"]),
fs_write: concrete(&[]),
exec: concrete(&["git"]),
net: concrete(&[]),
};
assert_eq!(
admit(&fence, &delegated, &empty_closure()),
AdmissionDecision::Admit
);
}
#[test]
fn admit_refuses_a_fence_that_widens_beyond_delegated_plus_closure() {
let delegated = caveats(
Scope::only(["/repo".into()]),
Scope::only(["git".into()]),
Scope::none(),
);
let fence = ResolvedAuthority {
fs_read: concrete(&["/repo", "/etc"]),
fs_write: concrete(&[]),
exec: concrete(&["git"]),
net: concrete(&[]),
};
assert_eq!(
admit(&fence, &delegated, &empty_closure()),
AdmissionDecision::Reject(AdmissionReject {
axis: ConfinedAxis::FsRead,
relation: ScopeRelation::Superset,
})
);
}
#[test]
fn admit_accepts_a_widening_that_the_explicit_closure_authorizes() {
let delegated = caveats(Scope::only(["/repo".into()]), Scope::none(), Scope::none());
let closure = ResolvedAuthority {
fs_read: concrete(&["/usr/lib"]),
fs_write: concrete(&[]),
exec: concrete(&[]),
net: concrete(&[]),
};
let fence = ResolvedAuthority {
fs_read: concrete(&["/repo", "/usr/lib"]),
fs_write: concrete(&[]),
exec: concrete(&[]),
net: concrete(&[]),
};
assert_eq!(
admit(&fence, &delegated, &closure),
AdmissionDecision::Admit
);
let sneaky = ResolvedAuthority {
fs_read: concrete(&["/repo", "/usr/lib", "/root/.ssh"]),
..fence
};
assert_eq!(
admit(&sneaky, &delegated, &closure),
AdmissionDecision::Reject(AdmissionReject {
axis: ConfinedAxis::FsRead,
relation: ScopeRelation::Superset,
})
);
}
#[test]
fn admit_fail_closes_on_an_unknown_resolution() {
let delegated = caveats(Scope::top(), Scope::top(), Scope::top());
let fence = ResolvedAuthority {
fs_read: ResolvedScope::Unknown,
fs_write: ResolvedScope::Unbounded,
exec: ResolvedScope::Unbounded,
net: ResolvedScope::Unbounded,
};
assert_eq!(
admit(&fence, &delegated, &empty_closure()),
AdmissionDecision::Reject(AdmissionReject {
axis: ConfinedAxis::FsRead,
relation: ScopeRelation::Unknown,
})
);
}
mod lattice_laws {
use super::*;
use proptest::prelude::*;
fn scope() -> impl Strategy<Value = ResolvedScope> {
prop_oneof![
Just(ResolvedScope::Unbounded),
Just(ResolvedScope::Unknown),
(
prop::collection::btree_set("[a-d]", 0..4),
prop::collection::btree_set("class-[x-z]", 0..3),
)
.prop_map(|(concrete, classes)| ResolvedScope::Bounded { concrete, classes }),
]
}
proptest! {
#[test]
fn empty_is_the_union_identity(x in scope()) {
prop_assert_eq!(ResolvedScope::empty().union(&x), x.clone());
prop_assert_eq!(x.union(&ResolvedScope::empty()), x);
}
#[test]
fn union_is_commutative(a in scope(), b in scope()) {
prop_assert_eq!(a.union(&b), b.union(&a));
}
#[test]
fn union_is_associative(a in scope(), b in scope(), c in scope()) {
prop_assert_eq!(a.union(&b).union(&c), a.union(&b.union(&c)));
}
#[test]
fn union_is_idempotent(a in scope()) {
prop_assert_eq!(a.union(&a), a.clone());
}
#[test]
fn unknown_propagates_through_union(a in scope()) {
prop_assert_eq!(a.union(&ResolvedScope::Unknown), ResolvedScope::Unknown);
prop_assert_eq!(ResolvedScope::Unknown.union(&a), ResolvedScope::Unknown);
}
#[test]
fn unbounded_absorbs_bounded(
concrete in prop::collection::btree_set("[a-d]", 0..4),
classes in prop::collection::btree_set("class-[x-z]", 0..3),
) {
let b = ResolvedScope::Bounded { concrete, classes };
prop_assert_eq!(ResolvedScope::Unbounded.union(&b), ResolvedScope::Unbounded);
prop_assert_eq!(b.union(&ResolvedScope::Unbounded), ResolvedScope::Unbounded);
}
#[test]
fn union_is_an_upper_bound(a in scope(), b in scope()) {
let bound = a.union(&b);
prop_assert_ne!(relate(&a, &bound), ScopeRelation::Superset);
prop_assert_ne!(relate(&b, &bound), ScopeRelation::Superset);
}
#[test]
fn relate_is_reflexive(a in scope()) {
match a {
ResolvedScope::Unknown => {
prop_assert_eq!(relate(&a, &a), ScopeRelation::Unknown);
}
_ => prop_assert_eq!(relate(&a, &a), ScopeRelation::Equal),
}
}
#[test]
fn relate_direction_is_mirrored(a in scope(), b in scope()) {
match relate(&a, &b) {
ScopeRelation::Subset => {
prop_assert_eq!(relate(&b, &a), ScopeRelation::Superset);
}
ScopeRelation::Superset => {
prop_assert_eq!(relate(&b, &a), ScopeRelation::Subset);
}
ScopeRelation::Equal => {
prop_assert_eq!(relate(&b, &a), ScopeRelation::Equal);
}
ScopeRelation::Incomparable => {
prop_assert_eq!(relate(&b, &a), ScopeRelation::Incomparable);
}
ScopeRelation::Unknown => {
prop_assert_eq!(relate(&b, &a), ScopeRelation::Unknown);
}
}
}
}
}
}