Skip to main content

hessra_cap_engine/
engine.rs

1//! The capability engine: orchestrates policy evaluation, token minting, and verification.
2
3use hessra_cap_schema::{RESERVED_LABELS, SchemaRegistry};
4use hessra_cap_token::{
5    CapabilityVerifier, DesignationBuilder, HessraCapability, get_capability_revocation_id,
6};
7use hessra_identity_token::{HessraIdentity, IdentityVerifier};
8use hessra_token_core::{KeyPair, PublicKey, TokenTimeConfig};
9
10use crate::context::{self, ContextToken, HessraContext};
11use crate::error::EngineError;
12use crate::facet::{FACET_LABEL, FacetMap, generate_facet_uuid};
13use crate::resolver::{DesignationContext, DesignationResolver, NoopResolver};
14use crate::types::{
15    CapabilityGrant, Designation, ExposureLabel, IdentityConfig, MintOptions, MintResult, ObjectId,
16    Operation, PolicyBackend, PolicyDecision, SessionConfig,
17};
18
19/// The Hessra Capability Engine.
20///
21/// Evaluates policy, orchestrates token minting/verification, and manages
22/// information flow control via context tokens.
23///
24/// The engine is generic over a `PolicyBackend` implementation, allowing
25/// different policy models (CList, RBAC, ABAC, etc.) to be plugged in. An
26/// optional [`SchemaRegistry`] declares per-target `required_designations`
27/// that the engine enforces at mint time. An optional
28/// [`DesignationResolver`] supplies runtime designation values during
29/// `mint_with_context`. The defaults (empty schema, [`NoopResolver`])
30/// preserve the basic mint behavior for use cases that don't need either.
31pub struct CapabilityEngine<P: PolicyBackend> {
32    policy: P,
33    schema: SchemaRegistry,
34    resolver: Box<dyn DesignationResolver>,
35    keypair: KeyPair,
36    facets_enabled: bool,
37    facet_map: FacetMap,
38}
39
40impl<P: PolicyBackend> CapabilityEngine<P> {
41    /// Create a new engine with a policy backend and signing keypair.
42    /// Defaults to an empty schema and a no-op resolver; chain
43    /// [`Self::with_schema`] and [`Self::with_resolver`] to attach them.
44    pub fn new(policy: P, keypair: KeyPair) -> Self {
45        Self {
46            policy,
47            schema: SchemaRegistry::new(),
48            resolver: Box::new(NoopResolver),
49            keypair,
50            facets_enabled: false,
51            facet_map: FacetMap::new(),
52        }
53    }
54
55    /// Create a new engine that generates its own keypair.
56    ///
57    /// Useful for local/development use where the engine manages its own keys.
58    /// Defaults to an empty schema and a no-op resolver; chain
59    /// [`Self::with_schema`] and [`Self::with_resolver`] to attach them.
60    pub fn with_generated_keys(policy: P) -> Self {
61        Self {
62            policy,
63            schema: SchemaRegistry::new(),
64            resolver: Box::new(NoopResolver),
65            keypair: KeyPair::new(),
66            facets_enabled: false,
67            facet_map: FacetMap::new(),
68        }
69    }
70
71    /// Attach a schema registry to this engine. Runs cross-validation against
72    /// the policy backend: every static designation declared in policy must
73    /// appear in the target's schema for the matching operation.
74    ///
75    /// Returns the engine on success or an [`EngineError::UnknownLabelInPolicy`]
76    /// (or other [`EngineError::SchemaPolicyMismatch`] variant) on the first
77    /// label that does not exist in the schema.
78    pub fn with_schema(mut self, schema: SchemaRegistry) -> Result<Self, EngineError> {
79        cross_validate_schema_against_policy(&schema, &self.policy)?;
80        self.schema = schema;
81        Ok(self)
82    }
83
84    /// Attach a designation resolver to this engine. The resolver is consulted
85    /// by [`Self::mint_with_context`] to supply runtime designation values for
86    /// the current `(target, operation)`. Replaces any previously attached
87    /// resolver.
88    pub fn with_resolver<R>(mut self, resolver: R) -> Self
89    where
90        R: DesignationResolver + 'static,
91    {
92        self.resolver = Box::new(resolver);
93        self
94    }
95
96    /// Enable forwarding facets on this engine. Once enabled, every minted
97    /// capability gets a fresh `designation("facet", <uuid>)` attached and
98    /// the engine records `(authority-block revocation id, facet uuid)` in
99    /// its in-memory [`FacetMap`].
100    ///
101    /// The non-consuming verify path
102    /// ([`Self::verify_capability`] / [`Self::verify_designated_capability`])
103    /// auto-supplies the matching fact from the map when present, so existing
104    /// callers continue to work unchanged. The consuming variants
105    /// ([`Self::verify_and_consume_capability`] /
106    /// [`Self::verify_and_consume_designated_capability`]) additionally
107    /// remove the entry on a successful verification, giving single-use-on-ack
108    /// semantics suitable for JIT-mint-at-dispatch.
109    ///
110    /// # Scope: forward-only, per-token
111    ///
112    /// Enabling facets affects capabilities **minted by this engine after
113    /// facets are enabled**. It does not retroactively require existing
114    /// non-faceted capabilities to appear in the facet map. The token itself
115    /// carries its verification requirements:
116    ///
117    /// - A faceted capability has a `designation("facet", _)` check embedded
118    ///   in its biscuit. If the engine's facet map has the entry, the engine
119    ///   auto-supplies the matching fact and verification proceeds. If the
120    ///   entry is absent (consumed, restart-wiped, or never registered) the
121    ///   embedded check cannot be satisfied and verification fails closed.
122    ///   This is the revocation, single-use, and restart-invalidation
123    ///   behavior facets exist to provide.
124    /// - A non-faceted capability has no facet check. Even on a
125    ///   facets-enabled engine with an empty map, verification succeeds for
126    ///   such a token because there is no embedded fact to satisfy. The
127    ///   capability never opted into facet enforcement.
128    ///
129    /// In short: map miss is fine only when the token itself does not
130    /// require a facet fact. This is what makes `with_facets()` safe to
131    /// turn on mid-deployment — it changes future issuance, not the meaning
132    /// of capabilities already in circulation.
133    pub fn with_facets(mut self) -> Self {
134        self.facets_enabled = true;
135        self
136    }
137
138    /// A handle to the facet map. The map is shared by clone, so the returned
139    /// handle observes the same state as the engine.
140    pub fn facet_map(&self) -> FacetMap {
141        self.facet_map.clone()
142    }
143
144    /// Whether forwarding facets are enabled on this engine.
145    pub fn facets_enabled(&self) -> bool {
146        self.facets_enabled
147    }
148
149    /// Get the engine's public key (for token verification).
150    pub fn public_key(&self) -> PublicKey {
151        self.keypair.public()
152    }
153
154    /// Get a reference to the policy backend.
155    pub fn policy(&self) -> &P {
156        &self.policy
157    }
158
159    /// Get a reference to the schema registry.
160    pub fn schema(&self) -> &SchemaRegistry {
161        &self.schema
162    }
163
164    // =========================================================================
165    // Policy evaluation
166    // =========================================================================
167
168    /// Evaluate whether a capability request would be granted, without minting.
169    ///
170    /// Checks both the capability space (does the subject hold this capability?)
171    /// and exposure restrictions (would context exposure block this?).
172    pub fn evaluate(
173        &self,
174        subject: &ObjectId,
175        target: &ObjectId,
176        operation: &Operation,
177        context: Option<&ContextToken>,
178    ) -> PolicyDecision {
179        let exposure_labels: Vec<ExposureLabel> = context
180            .map(|c| c.exposure_labels().to_vec())
181            .unwrap_or_default();
182
183        self.policy
184            .evaluate(subject, target, operation, &exposure_labels)
185    }
186
187    // =========================================================================
188    // Capability tokens
189    // =========================================================================
190
191    /// Mint a capability token for a subject to access a target with an operation.
192    ///
193    /// The engine:
194    /// 1. Evaluates the policy (capability space + exposure restrictions)
195    /// 2. If granted, mints a capability token via `hessra-cap-token`
196    /// 3. If the target has data classifications, auto-applies exposure to the context
197    ///
198    /// Returns a `MintResult` containing the token and optionally an updated context.
199    pub fn mint_capability(
200        &self,
201        subject: &ObjectId,
202        target: &ObjectId,
203        operation: &Operation,
204        context: Option<&ContextToken>,
205    ) -> Result<MintResult, EngineError> {
206        self.mint_designated_capability(subject, target, operation, &[], context)
207    }
208
209    /// Mint a capability, asking the attached [`DesignationResolver`] to
210    /// supply runtime designations from the given [`DesignationContext`].
211    ///
212    /// The full pipeline:
213    /// 1. Evaluate policy. The matched declaration may carry static
214    ///    designations and an anchor.
215    /// 2. Call `resolver.resolve(target, operation, ctx)` to get runtime
216    ///    designations.
217    /// 3. Combine static, resolver-supplied, and an empty caller list. If the
218    ///    target has a schema entry for the operation, the union must cover
219    ///    every `required_designations` label (anchor and other reserved
220    ///    labels excluded; they are handled separately).
221    /// 4. Mint the token with the anchor (if configured) at the authority
222    ///    block, then attenuate with the union of designations.
223    ///
224    /// Use this when the engine should drive resolution. Callers that already
225    /// have designation values can keep using
226    /// [`Self::mint_designated_capability`] and pre-resolve themselves.
227    pub fn mint_with_context(
228        &self,
229        target: &ObjectId,
230        operation: &Operation,
231        ctx: &DesignationContext,
232        context: Option<&ContextToken>,
233    ) -> Result<MintResult, EngineError> {
234        let resolved = self.resolver.resolve(target, operation, ctx)?;
235        self.mint_inner(
236            &ctx.subject,
237            target,
238            operation,
239            &resolved,
240            context,
241            MintOptions::default(),
242        )
243    }
244
245    /// Verify a capability token for a target and operation.
246    ///
247    /// This is capability-first verification: no subject is required.
248    /// The token IS the proof of authorization.
249    ///
250    /// When forwarding facets are enabled on the engine, this method
251    /// auto-supplies the matching `designation("facet", <uuid>)` fact from
252    /// the facet map (if the token's authority-block revocation id is
253    /// registered). This is the non-consuming path; the entry stays in the
254    /// map for subsequent verifications. Use
255    /// [`Self::verify_and_consume_capability`] for single-use semantics.
256    pub fn verify_capability(
257        &self,
258        token: &str,
259        target: &ObjectId,
260        operation: &Operation,
261    ) -> Result<(), EngineError> {
262        self.verify_designated_capability(token, target, operation, &[])
263    }
264
265    /// Verify a capability and atomically remove its facet entry from the
266    /// engine's facet map on success. Single-use-on-ack: a second call sees
267    /// no entry and the cap fails verification.
268    ///
269    /// If forwarding facets are not enabled this method behaves exactly like
270    /// [`Self::verify_capability`].
271    pub fn verify_and_consume_capability(
272        &self,
273        token: &str,
274        target: &ObjectId,
275        operation: &Operation,
276    ) -> Result<(), EngineError> {
277        self.verify_and_consume_designated_capability(token, target, operation, &[])
278    }
279
280    /// Mint a capability token with additional restrictions.
281    ///
282    /// Like `mint_capability`, but supports overriding the policy's anchor
283    /// binding or supplying a custom time config. When `options.anchor` is set,
284    /// it takes precedence over the policy's anchor decision.
285    pub fn mint_capability_with_options(
286        &self,
287        subject: &ObjectId,
288        target: &ObjectId,
289        operation: &Operation,
290        context: Option<&ContextToken>,
291        options: MintOptions,
292    ) -> Result<MintResult, EngineError> {
293        self.mint_inner(subject, target, operation, &[], context, options)
294    }
295
296    // =========================================================================
297    // Direct token issuance (no policy evaluation)
298    // =========================================================================
299
300    /// Issue a capability token directly, without policy evaluation.
301    ///
302    /// Use this when the caller has already performed authorization checks
303    /// through its own mechanisms (e.g., enterprise RBAC, custom domain logic).
304    /// For the fully-managed path that includes policy evaluation, use
305    /// `mint_capability` or `mint_capability_with_options` instead.
306    ///
307    /// Engine-wide invariants still apply: if [`Self::with_facets`] is set,
308    /// the issued capability gets a fresh facet attached and registered in
309    /// the engine's facet map. Policy, schema, and chain checks are
310    /// deliberately bypassed (that is the point of this path); facets are an
311    /// engine-level revocation mechanism rather than a policy-level check,
312    /// so they continue to apply.
313    pub fn issue_capability(
314        &self,
315        subject: &ObjectId,
316        target: &ObjectId,
317        operation: &Operation,
318        options: MintOptions,
319    ) -> Result<String, EngineError> {
320        let time_config = options.time_config.unwrap_or_default();
321        let mut builder = HessraCapability::new(
322            subject.as_str().to_string(),
323            target.as_str().to_string(),
324            operation.as_str().to_string(),
325            time_config,
326        );
327
328        if let Some(anchor) = options.anchor {
329            builder = builder.anchor_bound(anchor.as_str().to_string());
330        }
331
332        let mut token = builder
333            .issue(&self.keypair)
334            .map_err(|e| EngineError::TokenOperation(format!("failed to issue capability: {e}")))?;
335
336        // Engine-level invariant: facets attach to every cap when enabled,
337        // regardless of which mint path produced the cap.
338        if self.facets_enabled {
339            let rev_id = get_capability_revocation_id(token.clone(), self.keypair.public())
340                .map_err(EngineError::Token)?
341                .to_hex();
342            let facet_uuid = generate_facet_uuid();
343            self.facet_map.register(rev_id, facet_uuid.clone());
344            token = self.attenuate_with_designations(
345                &token,
346                &[Designation {
347                    label: FACET_LABEL.to_string(),
348                    value: facet_uuid,
349                }],
350            )?;
351        }
352
353        Ok(token)
354    }
355
356    // =========================================================================
357    // Designation attenuation
358    // =========================================================================
359
360    /// Attenuate a capability token with designations.
361    ///
362    /// Adds designation checks to narrow the token's scope to specific
363    /// object instances. The verifier must provide matching designation facts.
364    pub fn attenuate_with_designations(
365        &self,
366        token: &str,
367        designations: &[Designation],
368    ) -> Result<String, EngineError> {
369        let mut builder = DesignationBuilder::from_base64(token.to_string(), self.keypair.public())
370            .map_err(EngineError::Token)?;
371
372        for d in designations {
373            builder = builder.designate(d.label.clone(), d.value.clone());
374        }
375
376        builder.attenuate_base64().map_err(EngineError::Token)
377    }
378
379    /// Mint a capability with caller-supplied designations attached.
380    ///
381    /// The full pipeline:
382    /// 1. Evaluate policy. The matched declaration may carry static
383    ///    designations (author-time bindings) and an anchor.
384    /// 2. Combine static designations with the caller-supplied ones.
385    /// 3. If the target has a schema entry for the operation, enforce that
386    ///    every `required_designations` label is present in the union.
387    ///    Reserved labels (e.g., `anchor`) are excluded from this check; they
388    ///    are handled through the dedicated anchor path.
389    /// 4. Mint the token, attaching the anchor (if configured) at the
390    ///    authority block, then attenuate with the union of designations.
391    pub fn mint_designated_capability(
392        &self,
393        subject: &ObjectId,
394        target: &ObjectId,
395        operation: &Operation,
396        designations: &[Designation],
397        context: Option<&ContextToken>,
398    ) -> Result<MintResult, EngineError> {
399        self.mint_inner(
400            subject,
401            target,
402            operation,
403            designations,
404            context,
405            MintOptions::default(),
406        )
407    }
408
409    fn mint_inner(
410        &self,
411        subject: &ObjectId,
412        target: &ObjectId,
413        operation: &Operation,
414        caller_designations: &[Designation],
415        context: Option<&ContextToken>,
416        options: MintOptions,
417    ) -> Result<MintResult, EngineError> {
418        // Step 1: Evaluate policy.
419        let decision = self.evaluate(subject, target, operation, context);
420        let (policy_anchor, static_designations) = match decision {
421            PolicyDecision::Granted {
422                anchor,
423                designations,
424            } => (anchor, designations),
425            PolicyDecision::Denied { reason } => {
426                return Err(EngineError::CapabilityDenied {
427                    subject: subject.clone(),
428                    target: target.clone(),
429                    operation: operation.clone(),
430                    reason,
431                });
432            }
433            PolicyDecision::DeniedByExposure {
434                label,
435                blocked_target,
436            } => {
437                return Err(EngineError::ExposureRestriction {
438                    label,
439                    target: blocked_target,
440                });
441            }
442        };
443
444        // Step 2: Compute the union of designations attached at mint.
445        let mut combined: Vec<Designation> =
446            Vec::with_capacity(static_designations.len() + caller_designations.len());
447        combined.extend(static_designations);
448        combined.extend(caller_designations.iter().cloned());
449
450        // Step 3: Delegated identity chain check. Every ancestor of `subject`
451        // must independently hold a grant for `(target, operation)` whose
452        // static designations are all present in the cap being minted. This
453        // encodes the model's "sub-identity capabilities bounded by parent
454        // identity capabilities" property as a structural mint-time check,
455        // giving transitive revocation for free: removing a grant from an
456        // ancestor (or narrowing its designation envelope) invalidates
457        // descendants on the next mint.
458        self.walk_chain(subject, target, operation, &combined)?;
459
460        // Step 4: Enforce required_designations from the schema, excluding
461        // reserved labels (handled separately).
462        if let Some(required) = self
463            .schema
464            .required_designations(target.as_str(), operation.as_str())
465        {
466            for label in required {
467                if RESERVED_LABELS.contains(&label.as_str()) {
468                    continue;
469                }
470                if !combined.iter().any(|d| d.label == *label) {
471                    return Err(EngineError::MissingRequiredDesignation {
472                        target: target.clone(),
473                        operation: operation.clone(),
474                        label: label.clone(),
475                    });
476                }
477            }
478        }
479
480        // Step 5: Build and issue. Caller's options.anchor overrides policy's.
481        let time_config = options.time_config.unwrap_or_default();
482        let mut builder = HessraCapability::new(
483            subject.as_str().to_string(),
484            target.as_str().to_string(),
485            operation.as_str().to_string(),
486            time_config,
487        );
488        let resolved_anchor = options.anchor.or(policy_anchor);
489        if let Some(anchor) = resolved_anchor {
490            builder = builder.anchor_bound(anchor.as_str().to_string());
491        }
492        let mut token = builder
493            .issue(&self.keypair)
494            .map_err(|e| EngineError::TokenOperation(format!("failed to mint capability: {e}")))?;
495
496        // Step 6: Attach the union of designations via attenuation.
497        if !combined.is_empty() {
498            token = self.attenuate_with_designations(&token, &combined)?;
499        }
500
501        // Step 7: If forwarding facets are enabled, attach a fresh facet
502        // designation and register it in the engine's facet map keyed by the
503        // authority-block revocation id.
504        if self.facets_enabled {
505            let rev_id = get_capability_revocation_id(token.clone(), self.keypair.public())
506                .map_err(EngineError::Token)?
507                .to_hex();
508            let facet_uuid = generate_facet_uuid();
509            self.facet_map.register(rev_id, facet_uuid.clone());
510            token = self.attenuate_with_designations(
511                &token,
512                &[Designation {
513                    label: FACET_LABEL.to_string(),
514                    value: facet_uuid,
515                }],
516            )?;
517        }
518
519        // Step 8: Auto-apply exposure if the target has data classifications.
520        let updated_context = if let Some(ctx) = context {
521            let classifications = self.policy.classification(target);
522            if classifications.is_empty() {
523                Some(ctx.clone())
524            } else {
525                Some(context::add_exposure_block(
526                    ctx,
527                    &classifications,
528                    target,
529                    &self.keypair,
530                )?)
531            }
532        } else {
533            None
534        };
535
536        Ok(MintResult {
537            token,
538            context: updated_context,
539        })
540    }
541
542    /// Verify a capability token that includes designation checks.
543    ///
544    /// For anchor-bound capabilities (minted from a declaration with
545    /// `anchor_to_subject` or explicit `anchor` in policy, or via
546    /// `MintOptions.anchor`), the verifier MUST assert its own principal
547    /// identity by including
548    /// `Designation { label: "anchor", value: <its-own-principal-name> }` in
549    /// `designations`. The capability verifies if and only if the anchor
550    /// designation supplied here matches the anchor value embedded at mint
551    /// time. In plain language, the verifier is proving "I am the principal
552    /// this capability is anchored at." Anchor is treated as a regular
553    /// designation at verify time; the engine does not auto-supply the
554    /// verifier's identity.
555    ///
556    /// When forwarding facets are enabled on the engine and the token's
557    /// authority-block revocation id is present in the facet map, the engine
558    /// automatically supplies the matching `designation("facet", <uuid>)`
559    /// fact alongside the caller-supplied designations. This is the
560    /// non-consuming path; the entry stays in the map. Use
561    /// [`Self::verify_and_consume_designated_capability`] for the
562    /// single-use-on-ack variant.
563    pub fn verify_designated_capability(
564        &self,
565        token: &str,
566        target: &ObjectId,
567        operation: &Operation,
568        designations: &[Designation],
569    ) -> Result<(), EngineError> {
570        self.run_verify(token, target, operation, designations, false)?;
571        Ok(())
572    }
573
574    /// Verify a designated capability and atomically remove its facet entry
575    /// from the engine's facet map on success. Single-use-on-ack semantics.
576    /// If forwarding facets are not enabled this is equivalent to
577    /// [`Self::verify_designated_capability`].
578    pub fn verify_and_consume_designated_capability(
579        &self,
580        token: &str,
581        target: &ObjectId,
582        operation: &Operation,
583        designations: &[Designation],
584    ) -> Result<(), EngineError> {
585        self.run_verify(token, target, operation, designations, true)?;
586        Ok(())
587    }
588
589    /// Walk the parent chain of `subject` and verify every ancestor holds a
590    /// grant for `(target, operation)` whose static designations are all
591    /// present in `combined`. Returns [`EngineError::ChainCheckFailed`] on
592    /// the first ancestor that fails either check.
593    ///
594    /// This enforces "sub-identity ⊆ parent" as a structural mint-time check
595    /// per the model's §4.1, covering both target/operation authority and
596    /// the per-grant designation envelope: removing a grant or narrowing
597    /// its designations on any ancestor invalidates all descendants on the
598    /// next mint (transitive revocation, live policy). Cycle safety comes
599    /// from policy load (`PolicyConfigError::ParentCycle`).
600    fn walk_chain(
601        &self,
602        subject: &ObjectId,
603        target: &ObjectId,
604        operation: &Operation,
605        combined: &[Designation],
606    ) -> Result<(), EngineError> {
607        let mut cursor = self.policy.parent(subject);
608        while let Some(ancestor) = cursor {
609            let Some(grant) = self.policy.lookup_grant(&ancestor, target, operation) else {
610                return Err(EngineError::ChainCheckFailed {
611                    subject: subject.clone(),
612                    ancestor: ancestor.clone(),
613                    target: target.clone(),
614                    operation: operation.clone(),
615                    reason: crate::error::ChainCheckFailure::NoGrant,
616                });
617            };
618            // For every static designation the ancestor's grant requires,
619            // the cap being minted must include a matching (label, value).
620            for req in &grant.designations {
621                let covered = combined
622                    .iter()
623                    .any(|d| d.label == req.label && d.value == req.value);
624                if !covered {
625                    return Err(EngineError::ChainCheckFailed {
626                        subject: subject.clone(),
627                        ancestor: ancestor.clone(),
628                        target: target.clone(),
629                        operation: operation.clone(),
630                        reason: crate::error::ChainCheckFailure::DesignationNotCovered {
631                            label: req.label.clone(),
632                            value: req.value.clone(),
633                        },
634                    });
635                }
636            }
637            cursor = self.policy.parent(&ancestor);
638        }
639        Ok(())
640    }
641
642    /// Internal verify driver. Auto-supplies the facet designation from the
643    /// engine's facet map when facets are enabled and the cap's authority
644    /// revocation id is present. When `consume` is true, lookup, verify, and
645    /// removal happen under a single critical section so concurrent
646    /// consumers cannot both succeed against the same facet.
647    ///
648    /// Absent-entry semantics (facets enabled, no map entry for the token):
649    /// - If the token has a `designation("facet", _)` check embedded
650    ///   (because some engine with facets enabled minted it), Biscuit
651    ///   verification fails closed: the engine has no fact to supply, the
652    ///   embedded check has no matching fact, the verify rejects. This is
653    ///   how revocation, single-use, and restart invalidation work.
654    /// - If the token has no facet check (it was minted by an engine
655    ///   without facets, or by a different signer entirely), verification
656    ///   proceeds normally because no fact needs to be supplied. The token
657    ///   itself never opted into facet enforcement.
658    ///
659    /// The engine does not inspect the token's contents to decide which case
660    /// applies; it lets Biscuit's logic decide based on whether the embedded
661    /// checks can be satisfied.
662    fn run_verify(
663        &self,
664        token: &str,
665        target: &ObjectId,
666        operation: &Operation,
667        designations: &[Designation],
668        consume: bool,
669    ) -> Result<(), EngineError> {
670        // Build the verifier shape once: target, operation, caller designations.
671        // The facet designation is auto-supplied at the call site (closure for
672        // the consume path, inline read for the non-consume path).
673        let build_verifier = |facet: Option<&str>| -> CapabilityVerifier {
674            let mut verifier = CapabilityVerifier::new(
675                token.to_string(),
676                self.keypair.public(),
677                target.as_str().to_string(),
678                operation.as_str().to_string(),
679            );
680            for d in designations {
681                verifier = verifier.with_designation(d.label.clone(), d.value.clone());
682            }
683            if let Some(facet_uuid) = facet {
684                verifier =
685                    verifier.with_designation(FACET_LABEL.to_string(), facet_uuid.to_string());
686            }
687            verifier
688        };
689
690        if !self.facets_enabled {
691            // No facet wiring; build and verify without auto-supply.
692            return build_verifier(None).verify().map_err(EngineError::Token);
693        }
694
695        // Facets are enabled. Extract the cap's authority-block revocation id
696        // so the facet map can be consulted.
697        let rev_id = get_capability_revocation_id(token.to_string(), self.keypair.public())
698            .map_err(EngineError::Token)?
699            .to_hex();
700
701        if consume {
702            // Consume path: lookup + verify + remove must be one critical
703            // section so two callers can't both verify successfully against
704            // the same entry. The closure runs under the facet map's lock;
705            // on Ok return, the helper removes the entry atomically. On Err,
706            // the entry is left in place to support retry with corrected
707            // inputs.
708            self.facet_map.verify_and_consume_atomic(&rev_id, |facet| {
709                build_verifier(facet).verify().map_err(EngineError::Token)
710            })
711        } else {
712            // Non-consume path: a stale read is harmless since nothing is
713            // removed. If the entry vanishes between lookup and verify
714            // (because a concurrent consume succeeded), verification will
715            // fail closed and the caller can retry.
716            let facet = self.facet_map.lookup(&rev_id);
717            build_verifier(facet.as_deref())
718                .verify()
719                .map_err(EngineError::Token)
720        }
721    }
722
723    // =========================================================================
724    // Identity tokens
725    // =========================================================================
726
727    /// Mint an identity token for a subject.
728    pub fn mint_identity(
729        &self,
730        subject: &ObjectId,
731        config: IdentityConfig,
732    ) -> Result<String, EngineError> {
733        let time_config = TokenTimeConfig {
734            start_time: None,
735            duration: config.ttl,
736        };
737
738        HessraIdentity::new(subject.as_str().to_string(), time_config)
739            .delegatable(config.delegatable)
740            .issue(&self.keypair)
741            .map_err(|e| EngineError::Identity(format!("failed to mint identity: {e}")))
742    }
743
744    /// Verify an identity token and return the authenticated object ID.
745    ///
746    /// This verifies the token as a bearer token (no specific identity required).
747    pub fn authenticate(&self, token: &str) -> Result<ObjectId, EngineError> {
748        // Verify the token is valid
749        IdentityVerifier::new(token.to_string(), self.keypair.public())
750            .verify()
751            .map_err(|e| EngineError::Identity(format!("authentication failed: {e}")))?;
752
753        // Inspect the token to extract the subject
754        let inspect =
755            hessra_identity_token::inspect_identity_token(token.to_string(), self.keypair.public())
756                .map_err(|e| {
757                    EngineError::Identity(format!("failed to inspect identity token: {e}"))
758                })?;
759
760        Ok(ObjectId::new(inspect.identity))
761    }
762
763    /// Verify an identity token for a specific identity.
764    pub fn verify_identity(
765        &self,
766        token: &str,
767        expected_identity: &ObjectId,
768    ) -> Result<(), EngineError> {
769        IdentityVerifier::new(token.to_string(), self.keypair.public())
770            .with_identity(expected_identity.as_str().to_string())
771            .verify()
772            .map_err(|e| EngineError::Identity(format!("identity verification failed: {e}")))
773    }
774
775    // =========================================================================
776    // Context tokens
777    // =========================================================================
778
779    /// Mint a fresh context token for a subject (new session, no exposure).
780    pub fn mint_context(
781        &self,
782        subject: &ObjectId,
783        session_config: SessionConfig,
784    ) -> Result<ContextToken, EngineError> {
785        HessraContext::new(subject.clone(), session_config).issue(&self.keypair)
786    }
787
788    /// Add exposure to a context token from a specific data source.
789    ///
790    /// Looks up the data source's classification in the policy and adds
791    /// the corresponding exposure labels to the context token.
792    pub fn add_exposure(
793        &self,
794        context: &ContextToken,
795        data_source: &ObjectId,
796    ) -> Result<ContextToken, EngineError> {
797        let labels = self.policy.classification(data_source);
798        if labels.is_empty() {
799            return Ok(context.clone());
800        }
801        context::add_exposure_block(context, &labels, data_source, &self.keypair)
802    }
803
804    /// Add a specific exposure label directly to a context token.
805    pub fn add_exposure_label(
806        &self,
807        context: &ContextToken,
808        label: ExposureLabel,
809        source: &ObjectId,
810    ) -> Result<ContextToken, EngineError> {
811        context::add_exposure_block(context, &[label], source, &self.keypair)
812    }
813
814    /// Fork a context token for a sub-agent, inheriting the parent's exposure.
815    pub fn fork_context(
816        &self,
817        parent: &ContextToken,
818        child_subject: &ObjectId,
819        session_config: SessionConfig,
820    ) -> Result<ContextToken, EngineError> {
821        context::fork_context(parent, child_subject, session_config, &self.keypair)
822    }
823
824    /// Extract exposure labels from a context token by re-parsing the Biscuit.
825    pub fn extract_exposure(
826        &self,
827        context: &ContextToken,
828    ) -> Result<Vec<ExposureLabel>, EngineError> {
829        context::extract_exposure_labels(context.token(), self.keypair.public())
830    }
831
832    // =========================================================================
833    // Introspection
834    // =========================================================================
835
836    /// List all capability grants for a subject.
837    pub fn list_grants(&self, subject: &ObjectId) -> Vec<CapabilityGrant> {
838        self.policy.list_grants(subject)
839    }
840
841    /// Check if a subject can delegate capabilities.
842    pub fn can_delegate(&self, subject: &ObjectId) -> bool {
843        self.policy.can_delegate(subject)
844    }
845}
846
847/// Walk every (subject, grant) pair the policy declares and check that any
848/// static designation labels are declared in the schema for the matching
849/// (target, operation). Returns the first mismatch found.
850fn cross_validate_schema_against_policy<P: PolicyBackend>(
851    schema: &SchemaRegistry,
852    policy: &P,
853) -> Result<(), EngineError> {
854    if schema.is_empty() {
855        // An empty schema disables enforcement; nothing to cross-validate.
856        return Ok(());
857    }
858    for (_subject, grant) in policy.all_grants() {
859        if grant.designations.is_empty() {
860            continue;
861        }
862        for op in &grant.operations {
863            let Some(required) = schema.required_designations(grant.target.as_str(), op.as_str())
864            else {
865                // No schema entry for this (target, op) means no enforcement
866                // runs at mint time, so policy-declared static designations
867                // are unconstrained too. Allow.
868                continue;
869            };
870            for d in &grant.designations {
871                if !required.iter().any(|label| label == &d.label) {
872                    return Err(EngineError::UnknownLabelInPolicy {
873                        target: grant.target.clone(),
874                        operation: op.clone(),
875                        label: d.label.clone(),
876                    });
877                }
878            }
879        }
880    }
881    Ok(())
882}