Skip to main content

hessra_cap_engine/
facet.rs

1//! Forwarding facets: per-engine in-memory revocation map for capabilities.
2//!
3//! When an engine is constructed with [`crate::CapabilityEngine::with_facets`],
4//! every minted capability gets a fresh facet UUID attached as a structural
5//! `designation("facet", <uuid>)`. The engine stores a mapping from each
6//! capability's authority-block revocation id to its facet UUID. At verify
7//! time, the engine consults the map to supply the matching fact, and the
8//! consuming verify variants atomically remove the entry on a successful
9//! verification.
10//!
11//! Lifecycle, paraphrasing the spec: **good until one successful use, until
12//! ack, while not expired.**
13//!
14//! - *One successful use*: [`CapabilityEngine::verify_and_consume_capability`]
15//!   removes the entry on success. A second call sees no entry and the cap
16//!   fails verification (the facet check has no fact to satisfy).
17//! - *Until ack*: removal happens after the verifier returns success, not at
18//!   the moment of lookup. A retry that never reaches the verifier (e.g., a
19//!   network blip in a distributed deployment, or a panic before
20//!   acknowledgment in the in-process case) leaves the entry in the map and
21//!   the next attempt succeeds.
22//! - *While not expired*: the underlying token's expiry is enforced by
23//!   Biscuit's time check; the facet map rides on top.
24//!
25//! The map is in-memory and lost on engine restart. This is intentional: a
26//! restarted engine has no provenance for previously issued capabilities and
27//! cannot honor them.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32/// Built-in designation label used for forwarding facets. Mirrors the entry
33/// in [`hessra_cap_schema::RESERVED_LABELS`].
34pub(crate) const FACET_LABEL: &str = "facet";
35
36/// Per-engine map from authority-block revocation id (hex) to the facet
37/// UUID attached to the corresponding capability.
38///
39/// Cloning the [`FacetMap`] shares the same underlying storage, so handing a
40/// clone to a worker pool, a verify path, or a test harness sees the same
41/// state as the engine that minted the cap. The map is internally
42/// synchronized with a [`Mutex`].
43#[derive(Clone, Default)]
44pub struct FacetMap {
45    inner: Arc<Mutex<HashMap<String, String>>>,
46}
47
48impl FacetMap {
49    /// Build an empty facet map.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Register the (revocation id, facet uuid) pair for a freshly minted
55    /// capability. Called by the engine at mint time.
56    pub(crate) fn register(&self, revocation_id_hex: String, facet_uuid: String) {
57        let mut guard = self.inner.lock().expect("FacetMap mutex poisoned");
58        guard.insert(revocation_id_hex, facet_uuid);
59    }
60
61    /// Look up the facet uuid for a given revocation id. Used by the
62    /// non-consuming verify path.
63    pub(crate) fn lookup(&self, revocation_id_hex: &str) -> Option<String> {
64        let guard = self.inner.lock().expect("FacetMap mutex poisoned");
65        guard.get(revocation_id_hex).cloned()
66    }
67
68    /// Run a verify closure under the map's lock, then atomically remove the
69    /// entry if the closure returned `Ok`. This makes lookup, verify, and
70    /// consume one critical section, which is required for single-use
71    /// semantics under concurrent verifiers.
72    ///
73    /// The closure receives the facet uuid registered for `revocation_id_hex`,
74    /// if any (passed as `Option<&str>` so the closure can choose to verify
75    /// without supplying a facet when the entry is absent). When the closure
76    /// returns `Err`, the entry is left in place to support retry semantics:
77    /// a caller can fix designations or other inputs and try again.
78    ///
79    /// The lock is held for the duration of the verify closure. In v0 this
80    /// serializes all consuming verifies on a single engine; perf-sensitive
81    /// deployments that hit this contention can revisit with a per-entry
82    /// reservation scheme.
83    pub(crate) fn verify_and_consume_atomic<F>(
84        &self,
85        revocation_id_hex: &str,
86        verify: F,
87    ) -> Result<(), crate::EngineError>
88    where
89        F: FnOnce(Option<&str>) -> Result<(), crate::EngineError>,
90    {
91        let mut guard = self.inner.lock().expect("FacetMap mutex poisoned");
92        let facet = guard.get(revocation_id_hex).cloned();
93        let result = verify(facet.as_deref());
94        if result.is_ok() && facet.is_some() {
95            guard.remove(revocation_id_hex);
96        }
97        result
98    }
99
100    /// Number of entries currently in the map. Useful for tests and
101    /// diagnostics.
102    pub fn len(&self) -> usize {
103        self.inner.lock().expect("FacetMap mutex poisoned").len()
104    }
105
106    /// Whether the map is empty.
107    pub fn is_empty(&self) -> bool {
108        self.len() == 0
109    }
110}
111
112impl std::fmt::Debug for FacetMap {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("FacetMap")
115            .field("entries", &self.len())
116            .finish()
117    }
118}
119
120/// Generate a fresh facet uuid. Currently uses UUID v4; the engine's caller
121/// shouldn't depend on the format.
122pub(crate) fn generate_facet_uuid() -> String {
123    uuid::Uuid::new_v4().to_string()
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn lookup_returns_registered_uuid() {
132        let map = FacetMap::new();
133        map.register("rev-a".into(), "uuid-1".into());
134        assert_eq!(map.lookup("rev-a").as_deref(), Some("uuid-1"));
135        assert!(map.lookup("rev-other").is_none());
136    }
137
138    #[test]
139    fn atomic_consume_removes_on_ok() {
140        let map = FacetMap::new();
141        map.register("rev-a".into(), "uuid-1".into());
142        let result = map.verify_and_consume_atomic("rev-a", |facet| {
143            assert_eq!(facet, Some("uuid-1"));
144            Ok(())
145        });
146        assert!(result.is_ok());
147        assert!(map.lookup("rev-a").is_none());
148    }
149
150    #[test]
151    fn atomic_consume_leaves_entry_on_err() {
152        let map = FacetMap::new();
153        map.register("rev-a".into(), "uuid-1".into());
154        let result = map.verify_and_consume_atomic("rev-a", |_facet| {
155            Err(crate::EngineError::TokenOperation("simulated".into()))
156        });
157        assert!(result.is_err());
158        // Entry preserved so retry with corrected inputs can succeed.
159        assert_eq!(map.lookup("rev-a").as_deref(), Some("uuid-1"));
160    }
161
162    #[test]
163    fn atomic_helper_passes_none_when_entry_absent() {
164        // Low-level test: when the map has no entry for the revocation id,
165        // the atomic helper invokes the closure with `None` and propagates
166        // whatever the closure returns. This does NOT mean "map miss is
167        // broadly OK" at the engine level. The engine's verify closure is
168        // the one that decides Ok or Err, and what it does depends on
169        // whether the token itself carries a facet check:
170        //
171        // - For a non-faceted token (no `designation("facet", _)` check
172        //   embedded), the closure runs the biscuit verifier without
173        //   supplying a facet fact. Biscuit has no facet check to satisfy,
174        //   verification succeeds, the closure returns Ok. This is the
175        //   "facets are forward-only" property: pre-existing tokens
176        //   verify normally on a facets-enabled engine.
177        //
178        // - For a faceted token whose entry has been removed (consumed,
179        //   restart-wiped, or never registered) the closure runs the
180        //   verifier without supplying the facet fact. Biscuit's embedded
181        //   facet check fails closed, the closure returns Err, and the
182        //   absent entry stays absent. This is the revocation /
183        //   single-use / restart-invalidation property facets exist to
184        //   provide.
185        //
186        // The helper itself doesn't know which case applies; it only
187        // surfaces `Option<&str>` to the closure. The integration tests in
188        // `tests/facet_tests.rs` cover both engine-level paths end to end.
189        let map = FacetMap::new();
190        let result = map.verify_and_consume_atomic("rev-missing", |facet| {
191            assert!(facet.is_none(), "helper hands None to the closure");
192            Ok(())
193        });
194        assert!(
195            result.is_ok(),
196            "closure controls the outcome, not the helper"
197        );
198    }
199
200    #[test]
201    fn clone_shares_storage() {
202        let a = FacetMap::new();
203        let b = a.clone();
204        a.register("rev-a".into(), "uuid-1".into());
205        assert_eq!(b.lookup("rev-a").as_deref(), Some("uuid-1"));
206        // Atomic consume from one handle is visible through the clone.
207        let _ = b.verify_and_consume_atomic("rev-a", |_| Ok(()));
208        assert!(a.lookup("rev-a").is_none());
209    }
210
211    #[test]
212    fn generate_uuid_is_unique() {
213        let a = generate_facet_uuid();
214        let b = generate_facet_uuid();
215        assert_ne!(a, b);
216    }
217}