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}