Skip to main content

auths_id/policy/
mod.rs

1//! Policy engine for authorization decisions.
2//!
3//! This module provides pure functions for evaluating authorization policies.
4//! It centralizes all "should this be trusted?" logic.
5//!
6//! ## Core Entrypoints (Pure Functions)
7//!
8//! - [`evaluate_compiled`]: Evaluates a compiled policy against an attestation
9//! - [`evaluate_with_witness`]: Adds witness consistency checks before evaluation
10//!
11//! **What "pure" means for these functions:**
12//! - **Deterministic**: Same inputs always produce same outputs
13//! - **No side effects**: No filesystem, network, or global state access
14//! - **No storage assumptions**: All state passed as parameters
15//! - **Time is injected**: `DateTime<Utc>` passed in, never `Utc::now()`
16//! - **Errors are values**: Returns `Decision`, never panics
17//!
18//! ## Design Principle
19//!
20//! The policy engine consumes identity state and attestations but never
21//! accesses storage directly. All inputs are passed explicitly.
22//!
23//! ```text
24//! ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
25//! │  Attestation │────▶│  context_    │     │              │
26//! │  (device)    │     │  from_       │────▶│  EvalContext │
27//! │              │     │  attestation │     │              │
28//! └──────────────┘     └──────────────┘     └──────────────┘
29//!                                                  │
30//!                                                  ▼
31//! ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
32//! │  Compiled    │────▶│  evaluate_   │────▶│   Decision   │
33//! │  Policy      │     │  compiled    │     │              │
34//! │              │     │              │     │ Allow/Deny/  │
35//! └──────────────┘     └──────────────┘     │ Indeterminate│
36//!                                           └──────────────┘
37//! ```
38
39use auths_core::witness::{EventHash, WitnessProvider};
40use auths_policy::{CanonicalCapability, DidParseError, evaluate_strict};
41use auths_verifier::core::Attestation;
42use auths_verifier::types::DeviceDID;
43use chrono::{DateTime, Utc};
44
45use crate::keri::KeyState;
46use crate::keri::event::EventReceipts;
47use crate::keri::types::Said;
48#[cfg(feature = "git-storage")]
49use crate::storage::receipts::{check_receipt_consistency, verify_receipt_signature};
50
51// Re-export policy types for convenience
52pub use auths_policy::{
53    CompileError, CompiledPolicy, Decision, EvalContext, Expr, Outcome, PolicyBuilder,
54    PolicyLimits, ReasonCode, compile, compile_from_json,
55};
56
57/// Convert an attestation to an evaluation context.
58///
59/// This is the bridge between the attestation data model and the
60/// policy engine's typed context.
61///
62/// # Arguments
63///
64/// * `att` - The device attestation to convert
65/// * `now` - The current time (injected for determinism)
66///
67/// # Returns
68///
69/// An `EvalContext` populated with the attestation's fields.
70///
71pub fn context_from_attestation(
72    att: &Attestation,
73    now: DateTime<Utc>,
74) -> Result<EvalContext, DidParseError> {
75    let mut ctx = EvalContext::try_from_strings(now, &att.issuer, att.subject.as_ref())?;
76
77    ctx = ctx.revoked(att.is_revoked());
78
79    // Parse capabilities, silently ignoring invalid ones
80    let caps: Vec<CanonicalCapability> = att
81        .capabilities
82        .iter()
83        .filter_map(|c| CanonicalCapability::parse(&c.to_string()).ok())
84        .collect();
85    ctx = ctx.capabilities(caps);
86
87    if let Some(expires_at) = att.expires_at {
88        ctx = ctx.expires_at(expires_at);
89    }
90
91    if let Some(ref role) = att.role {
92        ctx = ctx.role(role.to_string());
93    }
94
95    if let Some(ref delegated_by) = att.delegated_by {
96        // Parse delegated_by DID, ignoring if invalid
97        if let Ok(did) = auths_policy::CanonicalDid::parse(delegated_by) {
98            ctx = ctx.delegated_by(did);
99        }
100    }
101
102    // Bridge signer_type from verifier to policy domain
103    if let Some(ref st) = att.signer_type {
104        let policy_st = match st {
105            auths_verifier::core::SignerType::Human => auths_policy::SignerType::Human,
106            auths_verifier::core::SignerType::Agent => auths_policy::SignerType::Agent,
107            auths_verifier::core::SignerType::Workload => auths_policy::SignerType::Workload,
108            _ => auths_policy::SignerType::Workload,
109        };
110        ctx = ctx.signer_type(policy_st);
111    }
112
113    Ok(ctx)
114}
115
116/// Evaluate a compiled policy against an attestation.
117///
118/// This is a **pure function** with no side effects.
119///
120/// # Pure Function Guarantees
121///
122/// - **Deterministic**: Same inputs always produce same `Decision`
123/// - **No I/O**: No filesystem, network, or global state access
124/// - **Time is injected**: `now` parameter, never `Utc::now()`
125/// - **No storage assumptions**: All state passed as parameters
126///
127/// # Arguments
128///
129/// * `att` - The device attestation being evaluated
130/// * `policy` - The compiled policy to evaluate
131/// * `now` - The current time (injected for determinism)
132///
133/// # Returns
134///
135/// A `Decision` indicating whether the action is allowed, denied, or indeterminate.
136///
137/// # Example
138///
139/// ```rust,ignore
140/// use auths_id::policy::{evaluate_compiled, PolicyBuilder};
141/// use chrono::Utc;
142///
143/// let policy = PolicyBuilder::new()
144///     .not_revoked()
145///     .not_expired()
146///     .require_capability("sign_commit")
147///     .build();
148///
149/// let decision = evaluate_compiled(&device_attestation, &policy, Utc::now());
150///
151/// if decision.outcome == Outcome::Allow {
152///     println!("Access granted");
153/// }
154/// ```
155pub fn evaluate_compiled(
156    att: &Attestation,
157    policy: &CompiledPolicy,
158    now: DateTime<Utc>,
159) -> Result<Decision, DidParseError> {
160    let ctx = context_from_attestation(att, now)?;
161    Ok(evaluate_strict(policy, &ctx))
162}
163
164/// Evaluate policy with optional witness consistency checks.
165///
166/// This function extends [`evaluate_compiled`] by first checking that the
167/// local identity head matches what witnesses have observed. This helps
168/// detect split-view attacks where a malicious node shows different KELs
169/// to different peers.
170///
171/// # Witness Checking
172///
173/// 1. If no witnesses are provided or all return `None`, proceed with normal policy evaluation
174/// 2. If witnesses have opinions, count how many agree with local_head
175/// 3. If quorum is not met, return `Indeterminate`
176/// 4. If quorum is met, proceed with normal policy evaluation
177///
178/// # Arguments
179///
180/// * `identity` - The identity's current key state
181/// * `att` - The device attestation being evaluated
182/// * `policy` - The compiled policy to evaluate
183/// * `now` - Current time (injected for determinism)
184/// * `local_head` - The local identity KEL head (from our storage)
185/// * `witnesses` - Witness providers to check for consistency
186///
187/// # Returns
188///
189/// - `Indeterminate` if witness quorum not met
190/// - Otherwise, result of `evaluate_compiled`
191pub fn evaluate_with_witness(
192    identity: &KeyState,
193    att: &Attestation,
194    policy: &CompiledPolicy,
195    now: DateTime<Utc>,
196    local_head: EventHash,
197    witnesses: &[&dyn WitnessProvider],
198) -> Result<Decision, DidParseError> {
199    if witnesses.is_empty() {
200        return evaluate_compiled(att, policy, now);
201    }
202
203    let required_quorum = witnesses.first().map(|w| w.quorum()).unwrap_or(1);
204
205    if required_quorum == 0 {
206        return evaluate_compiled(att, policy, now);
207    }
208
209    let mut matching = 0;
210    let mut total_opinions = 0;
211
212    for witness in witnesses {
213        if let Some(head) = witness.observe_identity_head(&identity.prefix) {
214            total_opinions += 1;
215            if head == local_head {
216                matching += 1;
217            }
218        }
219    }
220
221    if total_opinions == 0 {
222        return evaluate_compiled(att, policy, now);
223    }
224
225    if matching < required_quorum {
226        return Ok(Decision::deny(
227            ReasonCode::WitnessQuorumNotMet,
228            format!(
229                "Witness quorum not met: {}/{} matching, {} required",
230                matching, total_opinions, required_quorum
231            ),
232        ));
233    }
234
235    evaluate_compiled(att, policy, now)
236}
237
238/// Result of receipt verification.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum ReceiptVerificationResult {
241    /// Receipts are valid and meet threshold
242    Valid,
243    /// Not enough receipts to meet threshold
244    InsufficientReceipts { required: usize, got: usize },
245    /// Duplicity detected (conflicting SAIDs)
246    Duplicity { event_a: Said, event_b: Said },
247    /// Invalid receipt signature
248    InvalidSignature { witness_did: DeviceDID },
249}
250
251/// Witness public key resolver.
252///
253/// Implementations provide public keys for witnesses by their DID.
254pub trait WitnessKeyResolver: Send + Sync {
255    /// Get the Ed25519 public key (32 bytes) for a witness DID.
256    fn get_public_key(&self, witness_did: &str) -> Option<Vec<u8>>;
257}
258
259/// Evaluate policy with receipt verification.
260///
261/// This function extends [`evaluate_compiled`] by verifying that:
262/// 1. Sufficient receipts are present (meets threshold from event's `bt` field)
263/// 2. All receipts are for the same event SAID (no duplicity)
264/// 3. Optionally, all receipt signatures are valid
265///
266/// # Arguments
267///
268/// * `att` - The device attestation being evaluated
269/// * `policy` - The compiled policy to evaluate
270/// * `now` - Current time (injected for determinism)
271/// * `receipts` - The collected receipts for the event
272/// * `threshold` - Required number of receipts (from event's `bt` field)
273/// * `key_resolver` - Optional resolver for verifying receipt signatures
274///
275/// # Returns
276///
277/// - `ReceiptVerificationResult::InsufficientReceipts` if threshold not met
278/// - `ReceiptVerificationResult::Duplicity` if conflicting SAIDs detected
279/// - `ReceiptVerificationResult::InvalidSignature` if signature verification fails
280/// - Otherwise, proceeds to policy evaluation and returns `ReceiptVerificationResult::Valid`
281///   if policy allows, or the policy's `Decision` otherwise
282#[cfg(feature = "git-storage")]
283pub fn verify_receipts(
284    receipts: &EventReceipts,
285    threshold: usize,
286    key_resolver: Option<&dyn WitnessKeyResolver>,
287) -> ReceiptVerificationResult {
288    // 1. Check threshold met (using unique witness count, not raw receipt count)
289    let unique = receipts.unique_witness_count();
290    if unique < threshold {
291        return ReceiptVerificationResult::InsufficientReceipts {
292            required: threshold,
293            got: unique,
294        };
295    }
296
297    // 2. Check for duplicity (all receipts should have same SAID)
298    if let Err(e) = check_receipt_consistency(&receipts.receipts) {
299        return ReceiptVerificationResult::Duplicity {
300            event_a: receipts.event_said.clone(),
301            event_b: Said::new_unchecked(format!("conflicting: {}", e)),
302        };
303    }
304
305    // 3. Verify receipt signatures if key resolver provided
306    if let Some(resolver) = key_resolver {
307        for receipt in &receipts.receipts {
308            if let Some(public_key) = resolver.get_public_key(&receipt.i) {
309                match verify_receipt_signature(receipt, &public_key) {
310                    Ok(true) => continue,
311                    Ok(false) => {
312                        return ReceiptVerificationResult::InvalidSignature {
313                            #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt
314                            witness_did: DeviceDID::new_unchecked(&receipt.i),
315                        };
316                    }
317                    Err(_) => {
318                        return ReceiptVerificationResult::InvalidSignature {
319                            #[allow(clippy::disallowed_methods)] // INVARIANT: receipt.i is a witness DID from a deserialized KERI receipt
320                            witness_did: DeviceDID::new_unchecked(&receipt.i),
321                        };
322                    }
323                }
324            }
325            // If no key found for witness, skip signature verification for that receipt
326            // (In production, you might want to fail instead)
327        }
328    }
329
330    ReceiptVerificationResult::Valid
331}
332
333/// Evaluate policy with both witness head checks and receipt verification.
334///
335/// This is the most comprehensive policy evaluation function, combining:
336/// - Witness head consistency checks (split-view detection)
337/// - Receipt threshold verification
338/// - Receipt signature verification
339/// - Standard policy evaluation
340///
341/// # Arguments
342///
343/// * `identity` - The identity's current key state
344/// * `att` - The device attestation being evaluated
345/// * `policy` - The compiled policy to evaluate
346/// * `now` - Current time (injected for determinism)
347/// * `local_head` - The local identity KEL head
348/// * `witnesses` - Witness providers for head consistency checks
349/// * `receipts` - The collected receipts for the event
350/// * `threshold` - Required number of receipts
351/// * `key_resolver` - Optional resolver for verifying receipt signatures
352///
353/// # Returns
354///
355/// - `Deny` with appropriate reason if any verification fails
356/// - Otherwise, result of `evaluate_compiled`
357#[cfg(feature = "git-storage")]
358#[allow(clippy::too_many_arguments)]
359pub fn evaluate_with_receipts(
360    identity: &KeyState,
361    att: &Attestation,
362    policy: &CompiledPolicy,
363    now: DateTime<Utc>,
364    local_head: EventHash,
365    witnesses: &[&dyn WitnessProvider],
366    receipts: &EventReceipts,
367    threshold: usize,
368    key_resolver: Option<&dyn WitnessKeyResolver>,
369) -> Result<Decision, DidParseError> {
370    match verify_receipts(receipts, threshold, key_resolver) {
371        ReceiptVerificationResult::Valid => {}
372        ReceiptVerificationResult::InsufficientReceipts { required, got } => {
373            return Ok(Decision::deny(
374                ReasonCode::WitnessQuorumNotMet,
375                format!(
376                    "Insufficient receipts: {} required, {} present",
377                    required, got
378                ),
379            ));
380        }
381        ReceiptVerificationResult::Duplicity { event_a, event_b } => {
382            return Ok(Decision::deny(
383                ReasonCode::WitnessQuorumNotMet,
384                format!("Duplicity detected: {} vs {}", event_a, event_b),
385            ));
386        }
387        ReceiptVerificationResult::InvalidSignature { witness_did } => {
388            return Ok(Decision::deny(
389                ReasonCode::WitnessQuorumNotMet,
390                format!("Invalid receipt signature from witness: {}", witness_did),
391            ));
392        }
393    }
394
395    evaluate_with_witness(identity, att, policy, now, local_head, witnesses)
396}
397
398#[cfg(test)]
399#[allow(clippy::disallowed_methods)]
400mod tests {
401    use super::*;
402    use auths_core::witness::NoOpWitness;
403    use auths_verifier::AttestationBuilder;
404    use auths_verifier::core::Capability;
405    use auths_verifier::keri::{Prefix, Said};
406    use chrono::Duration;
407
408    /// Mock witness for testing
409    struct MockWitness {
410        head: Option<EventHash>,
411        quorum: usize,
412    }
413
414    impl WitnessProvider for MockWitness {
415        fn observe_identity_head(&self, _prefix: &Prefix) -> Option<EventHash> {
416            self.head
417        }
418
419        fn quorum(&self) -> usize {
420            self.quorum
421        }
422    }
423
424    fn make_key_state(prefix: &str) -> KeyState {
425        KeyState {
426            prefix: Prefix::new_unchecked(prefix.to_string()),
427            sequence: 0,
428            current_keys: vec!["DTestKey".to_string()],
429            next_commitment: vec![],
430            last_event_said: Said::new_unchecked("ETestSaid".to_string()),
431            is_abandoned: false,
432            threshold: 1,
433            next_threshold: 1,
434        }
435    }
436
437    fn make_attestation(
438        issuer: &str,
439        revoked_at: Option<DateTime<Utc>>,
440        expires_at: Option<DateTime<Utc>>,
441    ) -> Attestation {
442        AttestationBuilder::default()
443            .rid("test")
444            .issuer(issuer)
445            .subject("did:key:zSubject")
446            .revoked_at(revoked_at)
447            .expires_at(expires_at)
448            .build()
449    }
450
451    fn default_policy() -> CompiledPolicy {
452        PolicyBuilder::new().not_revoked().not_expired().build()
453    }
454
455    #[test]
456    fn context_from_attestation_basic() {
457        let att = make_attestation("did:keri:ETest", None, None);
458        let now = Utc::now();
459        let ctx = context_from_attestation(&att, now).unwrap();
460
461        assert_eq!(ctx.issuer.as_str(), "did:keri:ETest");
462        assert_eq!(ctx.subject.as_str(), "did:key:zSubject");
463        assert!(!ctx.revoked);
464    }
465
466    #[test]
467    fn context_from_attestation_with_capabilities() {
468        let mut att = make_attestation("did:keri:ETest", None, None);
469        att.capabilities = vec![Capability::sign_commit()];
470        let now = Utc::now();
471        let ctx = context_from_attestation(&att, now).unwrap();
472
473        assert_eq!(ctx.capabilities.len(), 1);
474        assert_eq!(ctx.capabilities[0].as_str(), "sign_commit");
475    }
476
477    #[test]
478    fn context_from_attestation_with_role() {
479        let mut att = make_attestation("did:keri:ETest", None, None);
480        att.role = Some(auths_verifier::core::Role::Member);
481        let now = Utc::now();
482        let ctx = context_from_attestation(&att, now).unwrap();
483
484        assert_eq!(ctx.role.as_deref(), Some("member"));
485    }
486
487    #[test]
488    fn evaluate_compiled_allows_valid_attestation() {
489        let att = make_attestation("did:keri:ETestPrefix", None, None);
490        let policy = default_policy();
491        let now = Utc::now();
492
493        let decision = evaluate_compiled(&att, &policy, now).unwrap();
494        assert_eq!(decision.outcome, Outcome::Allow);
495    }
496
497    #[test]
498    fn evaluate_compiled_denies_revoked() {
499        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), None);
500        let policy = default_policy();
501        let now = Utc::now();
502
503        let decision = evaluate_compiled(&att, &policy, now).unwrap();
504        assert_eq!(decision.outcome, Outcome::Deny);
505        assert_eq!(decision.reason, ReasonCode::Revoked);
506    }
507
508    #[test]
509    fn evaluate_compiled_denies_expired() {
510        let past = Utc::now() - Duration::hours(1);
511        let att = make_attestation("did:keri:ETestPrefix", None, Some(past));
512        let policy = default_policy();
513        let now = Utc::now();
514
515        let decision = evaluate_compiled(&att, &policy, now).unwrap();
516        assert_eq!(decision.outcome, Outcome::Deny);
517        assert_eq!(decision.reason, ReasonCode::Expired);
518    }
519
520    #[test]
521    fn evaluate_compiled_allows_not_yet_expired() {
522        let future = Utc::now() + Duration::hours(1);
523        let att = make_attestation("did:keri:ETestPrefix", None, Some(future));
524        let policy = default_policy();
525        let now = Utc::now();
526
527        let decision = evaluate_compiled(&att, &policy, now).unwrap();
528        assert_eq!(decision.outcome, Outcome::Allow);
529    }
530
531    #[test]
532    fn evaluate_compiled_denies_issuer_mismatch() {
533        let att = make_attestation("did:keri:EWrongPrefix", None, None);
534        let policy = PolicyBuilder::new()
535            .not_revoked()
536            .require_issuer("did:keri:ETestPrefix")
537            .build();
538        let now = Utc::now();
539
540        let decision = evaluate_compiled(&att, &policy, now).unwrap();
541        assert_eq!(decision.outcome, Outcome::Deny);
542        assert_eq!(decision.reason, ReasonCode::IssuerMismatch);
543    }
544
545    #[test]
546    fn evaluate_compiled_denies_missing_capability() {
547        let att = make_attestation("did:keri:ETestPrefix", None, None);
548        let policy = PolicyBuilder::new()
549            .not_revoked()
550            .require_capability("sign_commit")
551            .build();
552        let now = Utc::now();
553
554        let decision = evaluate_compiled(&att, &policy, now).unwrap();
555        assert_eq!(decision.outcome, Outcome::Deny);
556        assert_eq!(decision.reason, ReasonCode::CapabilityMissing);
557    }
558
559    #[test]
560    fn evaluate_compiled_allows_with_capability() {
561        let mut att = make_attestation("did:keri:ETestPrefix", None, None);
562        att.capabilities = vec![Capability::sign_commit()];
563        let policy = PolicyBuilder::new()
564            .not_revoked()
565            .require_capability("sign_commit")
566            .build();
567        let now = Utc::now();
568
569        let decision = evaluate_compiled(&att, &policy, now).unwrap();
570        assert_eq!(decision.outcome, Outcome::Allow);
571    }
572
573    #[test]
574    fn evaluate_compiled_is_deterministic() {
575        let att = make_attestation("did:keri:ETestPrefix", None, None);
576        let policy = default_policy();
577        let now = Utc::now();
578
579        let decision1 = evaluate_compiled(&att, &policy, now).unwrap();
580        let decision2 = evaluate_compiled(&att, &policy, now).unwrap();
581
582        assert_eq!(decision1, decision2);
583    }
584
585    // =========================================================================
586    // Tests for evaluate_with_witness
587    // =========================================================================
588
589    #[test]
590    fn evaluate_with_witness_no_witnesses_delegates() {
591        let identity = make_key_state("ETestPrefix");
592        let att = make_attestation("did:keri:ETestPrefix", None, None);
593        let policy = default_policy();
594        let now = Utc::now();
595        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
596
597        let decision =
598            evaluate_with_witness(&identity, &att, &policy, now, local_head, &[]).unwrap();
599
600        assert_eq!(decision.outcome, Outcome::Allow);
601    }
602
603    #[test]
604    fn evaluate_with_witness_noop_delegates() {
605        let identity = make_key_state("ETestPrefix");
606        let att = make_attestation("did:keri:ETestPrefix", None, None);
607        let policy = default_policy();
608        let now = Utc::now();
609        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
610
611        let noop = NoOpWitness;
612        let witnesses: &[&dyn WitnessProvider] = &[&noop];
613
614        let decision =
615            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
616
617        assert_eq!(decision.outcome, Outcome::Allow);
618    }
619
620    #[test]
621    fn evaluate_with_witness_mismatch_denies() {
622        let identity = make_key_state("ETestPrefix");
623        let att = make_attestation("did:keri:ETestPrefix", None, None);
624        let policy = default_policy();
625        let now = Utc::now();
626        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
627        let different_head =
628            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
629
630        let witness = MockWitness {
631            head: Some(different_head),
632            quorum: 1,
633        };
634        let witnesses: &[&dyn WitnessProvider] = &[&witness];
635
636        let decision =
637            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
638
639        assert_eq!(decision.outcome, Outcome::Deny);
640        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
641    }
642
643    #[test]
644    fn evaluate_with_witness_quorum_met_allows() {
645        let identity = make_key_state("ETestPrefix");
646        let att = make_attestation("did:keri:ETestPrefix", None, None);
647        let policy = default_policy();
648        let now = Utc::now();
649        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
650
651        let witness = MockWitness {
652            head: Some(local_head),
653            quorum: 1,
654        };
655        let witnesses: &[&dyn WitnessProvider] = &[&witness];
656
657        let decision =
658            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
659
660        assert_eq!(decision.outcome, Outcome::Allow);
661    }
662
663    #[test]
664    fn evaluate_with_witness_quorum_met_denies_when_policy_denies() {
665        let identity = make_key_state("ETestPrefix");
666        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), None); // revoked
667        let policy = default_policy();
668        let now = Utc::now();
669        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
670
671        let witness = MockWitness {
672            head: Some(local_head),
673            quorum: 1,
674        };
675        let witnesses: &[&dyn WitnessProvider] = &[&witness];
676
677        let decision =
678            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
679
680        assert_eq!(decision.outcome, Outcome::Deny);
681        assert_eq!(decision.reason, ReasonCode::Revoked);
682    }
683
684    #[test]
685    fn evaluate_with_witness_multiple_witnesses_quorum() {
686        let identity = make_key_state("ETestPrefix");
687        let att = make_attestation("did:keri:ETestPrefix", None, None);
688        let policy = default_policy();
689        let now = Utc::now();
690        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
691        let different_head =
692            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
693
694        let w1 = MockWitness {
695            head: Some(local_head),
696            quorum: 2,
697        };
698        let w2 = MockWitness {
699            head: Some(local_head),
700            quorum: 2,
701        };
702        let w3 = MockWitness {
703            head: Some(different_head),
704            quorum: 2,
705        };
706        let witnesses: &[&dyn WitnessProvider] = &[&w1, &w2, &w3];
707
708        let decision =
709            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
710
711        assert_eq!(decision.outcome, Outcome::Allow);
712    }
713
714    #[test]
715    fn evaluate_with_witness_no_opinions_delegates() {
716        let identity = make_key_state("ETestPrefix");
717        let att = make_attestation("did:keri:ETestPrefix", None, None);
718        let policy = default_policy();
719        let now = Utc::now();
720        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
721
722        let witness = MockWitness {
723            head: None,
724            quorum: 1,
725        };
726        let witnesses: &[&dyn WitnessProvider] = &[&witness];
727
728        let decision =
729            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
730
731        assert_eq!(decision.outcome, Outcome::Allow);
732    }
733
734    // =========================================================================
735    // Tests for receipt verification
736    // =========================================================================
737
738    fn make_test_receipt(
739        event_said: &str,
740        witness_did: &str,
741        seq: u64,
742    ) -> auths_core::witness::Receipt {
743        auths_core::witness::Receipt {
744            v: auths_core::witness::KERI_VERSION.into(),
745            t: auths_core::witness::RECEIPT_TYPE.into(),
746            d: Said::new_unchecked(format!(
747                "E{}",
748                &event_said.chars().skip(1).take(10).collect::<String>()
749            )),
750            i: witness_did.to_string(),
751            s: seq,
752            a: Said::new_unchecked(event_said.to_string()),
753            sig: vec![0; 64],
754        }
755    }
756
757    #[test]
758    fn verify_receipts_meets_threshold() {
759        let receipts = EventReceipts::new(
760            "ESAID123",
761            vec![
762                make_test_receipt("ESAID123", "did:key:w1", 0),
763                make_test_receipt("ESAID123", "did:key:w2", 0),
764            ],
765        );
766
767        let result = verify_receipts(&receipts, 2, None);
768        assert_eq!(result, ReceiptVerificationResult::Valid);
769    }
770
771    #[test]
772    fn verify_receipts_insufficient() {
773        let receipts = EventReceipts::new(
774            "ESAID123",
775            vec![make_test_receipt("ESAID123", "did:key:w1", 0)],
776        );
777
778        let result = verify_receipts(&receipts, 2, None);
779        assert!(matches!(
780            result,
781            ReceiptVerificationResult::InsufficientReceipts {
782                required: 2,
783                got: 1
784            }
785        ));
786    }
787
788    #[test]
789    fn verify_receipts_duplicity() {
790        let receipts = EventReceipts {
791            event_said: Said::new_unchecked("ESAID_A".to_string()),
792            receipts: vec![
793                make_test_receipt("ESAID_A", "did:key:w1", 0),
794                make_test_receipt("ESAID_B", "did:key:w2", 0), // Different SAID!
795            ],
796        };
797
798        let result = verify_receipts(&receipts, 1, None);
799        assert!(matches!(
800            result,
801            ReceiptVerificationResult::Duplicity { .. }
802        ));
803    }
804
805    #[test]
806    fn evaluate_with_receipts_valid() {
807        let identity = make_key_state("ETestPrefix");
808        let att = make_attestation("did:keri:ETestPrefix", None, None);
809        let policy = default_policy();
810        let now = Utc::now();
811        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
812        let receipts = EventReceipts::new(
813            "ESAID",
814            vec![
815                make_test_receipt("ESAID", "did:key:w1", 0),
816                make_test_receipt("ESAID", "did:key:w2", 0),
817            ],
818        );
819
820        let decision = evaluate_with_receipts(
821            &identity,
822            &att,
823            &policy,
824            now,
825            local_head,
826            &[],
827            &receipts,
828            2,
829            None,
830        )
831        .unwrap();
832
833        assert_eq!(decision.outcome, Outcome::Allow);
834    }
835
836    #[test]
837    fn evaluate_with_receipts_insufficient_denies() {
838        let identity = make_key_state("ETestPrefix");
839        let att = make_attestation("did:keri:ETestPrefix", None, None);
840        let policy = default_policy();
841        let now = Utc::now();
842        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
843        let receipts =
844            EventReceipts::new("ESAID", vec![make_test_receipt("ESAID", "did:key:w1", 0)]);
845
846        let decision = evaluate_with_receipts(
847            &identity,
848            &att,
849            &policy,
850            now,
851            local_head,
852            &[],
853            &receipts,
854            2, // Threshold 2, but only 1 receipt
855            None,
856        )
857        .unwrap();
858
859        assert_eq!(decision.outcome, Outcome::Deny);
860        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
861    }
862}