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}