Skip to main content

spacedb_access/
authorize.rs

1//! The enforcement engine: does this credential authorize this access, right now?
2//!
3//! [`authorize`] handles a single (root) capability; [`authorize_chain`] handles a
4//! delegation chain. Both check, for every link: the signature (against the
5//! issuer's published key) and revocation; the chain additionally enforces that
6//! each sub-grant **narrows** its parent (issuer = delegator, scope ⊆, ops ⊆,
7//! expiry ≤, depth −1). The leaf is then checked against the request (bearer,
8//! scope, ops, expiry). Every failure is a typed [`DenyReason`] — denial is a
9//! normal result, not an error. The caller supplies `now_unix`, so the engine
10//! stays deterministic.
11
12use serde::{Deserialize, Serialize};
13
14use crate::capability::{Capability, Ops, Scope, SignedCapability};
15use crate::chain::CapabilityChain;
16use crate::directory::KeyDirectory;
17use crate::error::AccessResult;
18use crate::identity::{verify_sec1, Did};
19use crate::revocation::RevocationSet;
20
21/// A request to access something, presented by a bearer.
22pub struct AccessRequest<'a> {
23    pub bearer: &'a Did,
24    pub scope: &'a Scope,
25    pub op: Ops,
26}
27
28/// Why a delegation link was invalid.
29#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
30pub enum DelegationError {
31    /// A sub-grant's issuer is not the bearer of the link above it.
32    IssuerNotDelegator,
33    /// The parent grant is not delegable (`delegation_depth == 0`).
34    ParentNotDelegable,
35    /// The sub-grant's delegation depth exceeds `parent.depth - 1`.
36    DepthExceeded,
37    /// The sub-grant's scope is broader than its parent's.
38    ScopeEscalation,
39    /// The sub-grant requests operations its parent did not have.
40    OpsEscalation,
41    /// The sub-grant would outlive its parent.
42    ExpiryExtension,
43}
44
45/// Why an access was denied.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47pub enum DenyReason {
48    UnknownIssuer,
49    BadSignature,
50    BearerMismatch,
51    OutOfScope,
52    OpNotGranted,
53    Expired,
54    /// The capability (or an ancestor in its chain) has been revoked.
55    Revoked,
56    /// A delegation link is invalid.
57    Delegation(DelegationError),
58    /// An empty capability chain was presented.
59    EmptyChain,
60    /// Policy required a capability, but none was presented (e.g. an AI agent
61    /// with no grant).
62    NoCapability,
63    /// Policy required the grant chain to root at an accountable roster member,
64    /// and it did not.
65    NotAccountable,
66}
67
68/// The authorization decision.
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub enum Decision {
71    Allow,
72    Deny(DenyReason),
73}
74
75impl Decision {
76    pub fn is_allowed(&self) -> bool {
77        matches!(self, Decision::Allow)
78    }
79}
80
81enum LinkSig {
82    Ok,
83    UnknownIssuer,
84    BadSignature,
85}
86
87fn verify_link_signature(
88    link: &SignedCapability,
89    directory: &dyn KeyDirectory,
90) -> AccessResult<LinkSig> {
91    let key = match directory.published_key(&link.capability.issuer)? {
92        Some(k) => k,
93        None => return Ok(LinkSig::UnknownIssuer),
94    };
95    let canonical = link.capability.canonical_bytes()?;
96    if verify_sec1(&key, &canonical, &link.issuer_signature) {
97        Ok(LinkSig::Ok)
98    } else {
99        Ok(LinkSig::BadSignature)
100    }
101}
102
103/// Check the leaf capability against the actual request.
104fn check_request(cap: &Capability, request: &AccessRequest, now_unix: u64) -> Option<DenyReason> {
105    if &cap.bearer != request.bearer {
106        return Some(DenyReason::BearerMismatch);
107    }
108    if !cap.scope.covers(request.scope) {
109        return Some(DenyReason::OutOfScope);
110    }
111    if !cap.ops.contains(request.op) {
112        return Some(DenyReason::OpNotGranted);
113    }
114    if let Some(expiry) = cap.expiry {
115        if now_unix >= expiry {
116            return Some(DenyReason::Expired);
117        }
118    }
119    None
120}
121
122fn expiry_within(parent: Option<u64>, sub: Option<u64>) -> bool {
123    match (parent, sub) {
124        (None, _) => true,             // parent never expires; sub may be anything
125        (Some(_), None) => false,      // parent expires; a forever sub would outlive it
126        (Some(p), Some(s)) => s <= p,  // sub must not outlive parent
127    }
128}
129
130/// Check a sub-grant narrows its parent.
131fn check_narrowing(parent: &Capability, sub: &Capability) -> Option<DelegationError> {
132    if sub.issuer != parent.bearer {
133        return Some(DelegationError::IssuerNotDelegator);
134    }
135    if parent.delegation_depth == 0 {
136        return Some(DelegationError::ParentNotDelegable);
137    }
138    if sub.delegation_depth > parent.delegation_depth - 1 {
139        return Some(DelegationError::DepthExceeded);
140    }
141    if !parent.scope.covers(&sub.scope) {
142        return Some(DelegationError::ScopeEscalation);
143    }
144    if !sub.ops.is_subset_of(parent.ops) {
145        return Some(DelegationError::OpsEscalation);
146    }
147    if !expiry_within(parent.expiry, sub.expiry) {
148        return Some(DelegationError::ExpiryExtension);
149    }
150    None
151}
152
153/// Authorize `request` against a single (root) capability.
154pub fn authorize(
155    signed: &SignedCapability,
156    request: &AccessRequest,
157    directory: &dyn KeyDirectory,
158    now_unix: u64,
159    revocations: &RevocationSet,
160) -> AccessResult<Decision> {
161    match verify_link_signature(signed, directory)? {
162        LinkSig::UnknownIssuer => return Ok(Decision::Deny(DenyReason::UnknownIssuer)),
163        LinkSig::BadSignature => return Ok(Decision::Deny(DenyReason::BadSignature)),
164        LinkSig::Ok => {}
165    }
166    if revocations.is_revoked(&signed.capability.id) {
167        return Ok(Decision::Deny(DenyReason::Revoked));
168    }
169    if let Some(reason) = check_request(&signed.capability, request, now_unix) {
170        return Ok(Decision::Deny(reason));
171    }
172    Ok(Decision::Allow)
173}
174
175/// Authorize `request` against a delegation chain: verify every link's signature,
176/// that each link narrows its parent, that no link is revoked, and that the leaf
177/// satisfies the request.
178pub fn authorize_chain(
179    chain: &CapabilityChain,
180    request: &AccessRequest,
181    directory: &dyn KeyDirectory,
182    now_unix: u64,
183    revocations: &RevocationSet,
184) -> AccessResult<Decision> {
185    let links = chain.links();
186    if links.is_empty() {
187        return Ok(Decision::Deny(DenyReason::EmptyChain));
188    }
189
190    let mut parent: Option<&Capability> = None;
191    for link in links {
192        match verify_link_signature(link, directory)? {
193            LinkSig::UnknownIssuer => return Ok(Decision::Deny(DenyReason::UnknownIssuer)),
194            LinkSig::BadSignature => return Ok(Decision::Deny(DenyReason::BadSignature)),
195            LinkSig::Ok => {}
196        }
197        if revocations.is_revoked(&link.capability.id) {
198            return Ok(Decision::Deny(DenyReason::Revoked));
199        }
200        if let Some(p) = parent {
201            if let Some(err) = check_narrowing(p, &link.capability) {
202                return Ok(Decision::Deny(DenyReason::Delegation(err)));
203            }
204        }
205        parent = Some(&link.capability);
206    }
207
208    // The leaf is the credential actually being exercised.
209    let leaf = &links.last().unwrap().capability;
210    if let Some(reason) = check_request(leaf, request, now_unix) {
211        return Ok(Decision::Deny(reason));
212    }
213    Ok(Decision::Allow)
214}