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};
41use auths_verifier::PresentationVerdict;
42use auths_verifier::core::Attestation;
43use auths_verifier::types::CanonicalDid;
44use chrono::{DateTime, Utc};
45
46use crate::keri::KeyState;
47use crate::keri::event::EventReceipts;
48use crate::keri::types::Said;
49#[cfg(feature = "git-storage")]
50use crate::storage::receipts::{check_receipt_consistency, verify_receipt_signature};
51
52// Re-export policy types for convenience
53pub use auths_policy::{
54    CompileError, CompiledPolicy, Decision, EvalContext, Expr, Outcome, PolicyBuilder,
55    PolicyLimits, ReasonCode, SignerType, compile, compile_from_json, evaluate_strict,
56};
57
58/// Convert an attestation to an evaluation context.
59///
60/// This is the bridge between the attestation data model and the
61/// policy engine's typed context.
62///
63/// # Authority source (fail-closed)
64///
65/// This context carries identity facts only — issuer, subject, revocation,
66/// expiry, timestamp, delegator, and signer type. It does **not** read
67/// `capabilities`/`role` from the attestation: credential-grade authority
68/// flows exclusively through [`context_from_credential`] (holder-verified ACDC),
69/// and org-membership role/caps through [`context_from_delegated_member`]
70/// (delegator-anchored scope seal). An attestation alone therefore yields an
71/// empty capability set and no role, so any caps/role policy condition fails
72/// closed unless a credential/membership context supplies them.
73///
74/// # Arguments
75///
76/// * `att` - The device attestation to convert
77/// * `now` - The current time (injected for determinism)
78///
79/// # Returns
80///
81/// An `EvalContext` populated with the attestation's identity facts.
82///
83pub fn context_from_attestation(
84    att: &Attestation,
85    now: DateTime<Utc>,
86) -> Result<EvalContext, DidParseError> {
87    let mut ctx = EvalContext::try_from_strings(now, &att.issuer, att.subject.as_ref())?;
88
89    ctx = ctx.revoked(att.is_revoked());
90
91    if let Some(expires_at) = att.expires_at {
92        ctx = ctx.expires_at(expires_at);
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/// Build an evaluation context from a delegator-anchored (KEL-authoritative) org
117/// membership, fail-closed.
118///
119/// This is the KERI-native counterpart to [`context_from_attestation`]: org
120/// authority is read from the org's KEL (the member is a `dip` the org anchored;
121/// role/capabilities ride a delegator-anchored scope seal), **never** from an
122/// attestation `delegated_by` field. The caller resolves the membership against the
123/// KEL — a revoked-on-KEL member yields `revoked = true` here, so policy denies it
124/// even if a stale attestation says otherwise.
125///
126/// Args:
127/// * `org_did`: The delegating org's `did:keri:` — populates both `issuer` and `delegated_by`.
128/// * `member_did`: The member's `did:keri:` (derived from its `dip` SAID).
129/// * `revoked`: Whether the org has revoked the member on its KEL.
130/// * `role`: The member's role string from the scope seal (if any).
131/// * `capabilities`: Capability strings granted by the scope seal.
132/// * `expires_at`: Optional delegator-anchored expiry.
133/// * `now`: The current time (injected for determinism).
134///
135/// Usage:
136/// ```ignore
137/// let ctx = context_from_delegated_member(&org_did, &member_did, revoked, role, &caps, expires, now)?;
138/// let decision = evaluate_strict(&policy, &ctx);
139/// ```
140#[allow(clippy::too_many_arguments)]
141pub fn context_from_delegated_member(
142    org_did: &str,
143    member_did: &str,
144    revoked: bool,
145    role: Option<&str>,
146    capabilities: &[auths_keri::Capability],
147    expires_at: Option<DateTime<Utc>>,
148    now: DateTime<Utc>,
149) -> Result<EvalContext, DidParseError> {
150    let mut ctx = EvalContext::try_from_strings(now, org_did, member_did)?;
151    ctx = ctx.revoked(revoked);
152
153    let caps: Vec<CanonicalCapability> = capabilities
154        .iter()
155        .filter_map(|c| CanonicalCapability::parse(c.as_str()).ok())
156        .collect();
157    ctx = ctx.capabilities(caps);
158
159    if let Some(role) = role {
160        ctx = ctx.role(role.to_string());
161    }
162    if let Some(expires_at) = expires_at {
163        ctx = ctx.expires_at(expires_at);
164    }
165    if let Ok(did) = auths_policy::CanonicalDid::parse(org_did) {
166        ctx = ctx.delegated_by(did);
167    }
168
169    Ok(ctx)
170}
171
172/// The authoritative source of a grant's capabilities + role for a policy decision.
173///
174/// There are two on-chain encodings of a capability/role grant, and they serve
175/// different decision grades:
176///
177/// - [`CapsSource::AgentScopeSeal`] — the Epic-E `agentscope:` `Seal::Digest` anchored in
178///   the delegator's `ixn`. It is **commit-time advisory**: the offline fast path a
179///   verifier can read straight off the KEL without a live presentation. It is the
180///   low-latency convenience source, not an authority of record.
181/// - [`CapsSource::Acdc`] — the F.4 capability credential. It is the **authoritative**
182///   caps/role source for credential-grade decisions, and authority derived from it is
183///   honored only through a *holder-verified presentation* (F.8) at the policy seam
184///   ([`context_from_credential`]).
185///
186/// **Anti-divergence rule:** the same grant MUST NOT be authored into both encodings with
187/// diverging caps/role. When both exist for one grant, [`CapsSource::governing`] selects
188/// the ACDC — the credential governs the credential-grade decision. The agentscope seal
189/// remains valid only as the advisory commit-time fast path. (Full ADR text is F.7.)
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum CapsSource {
192    /// The Epic-E `agentscope:` delegator-anchored scope seal (commit-time advisory).
193    AgentScopeSeal,
194    /// The F.4 ACDC capability credential (authoritative for credential-grade decisions).
195    Acdc,
196}
197
198impl CapsSource {
199    /// Select the source that governs a credential-grade decision when both encodings
200    /// exist for one grant: the ACDC always wins.
201    ///
202    /// Args:
203    /// * `agentscope_present`: Whether an `agentscope:` seal exists for the grant.
204    /// * `acdc_present`: Whether an F.4 ACDC credential exists for the grant.
205    ///
206    /// Usage:
207    /// ```ignore
208    /// assert_eq!(CapsSource::governing(true, true), CapsSource::Acdc);
209    /// ```
210    pub fn governing(agentscope_present: bool, acdc_present: bool) -> Option<CapsSource> {
211        match (acdc_present, agentscope_present) {
212            (true, _) => Some(CapsSource::Acdc),
213            (false, true) => Some(CapsSource::AgentScopeSeal),
214            (false, false) => None,
215        }
216    }
217}
218
219/// Failure to build an authority-bearing policy context from a credential presentation.
220///
221/// The bearer hole is closed at this seam: only a holder-verified presentation
222/// ([`PresentationVerdict::Valid`]) yields authority. Every other verdict — and mere
223/// possession of a raw ACDC, which is not even an accepted input — fails closed here, so
224/// capabilities can never enter a decision without proof the presenter controls the
225/// subject AID.
226#[derive(Debug, thiserror::Error)]
227pub enum PolicyBridgeError {
228    /// The presentation did not carry holder proof: it was not [`PresentationVerdict::Valid`],
229    /// so no authority-bearing context is produced (fail-closed).
230    #[error("no holder proof: presentation is not Valid, refusing to grant authority")]
231    NoHolderProof,
232    /// The verified presentation's issuer/subject DID failed to parse into the policy domain.
233    #[error("credential DID parse failed: {0}")]
234    Did(#[from] DidParseError),
235}
236
237/// Build a policy [`EvalContext`] from a **holder-verified credential presentation** (F.8),
238/// fail-closed.
239///
240/// This is the credential-grade counterpart to [`context_from_delegated_member`]: it is the
241/// single seam where ACDC-borne capabilities/role enter a policy decision, and it closes the
242/// bearer hole by construction. It consumes a [`PresentationVerdict`], **never a raw `Acdc`** —
243/// so authority cannot flow from mere *possession* of a credential. Only
244/// [`PresentationVerdict::Valid`] (the credential is valid per F.5 AND the presenter proved
245/// current control of the subject AID) yields a context; every other verdict returns
246/// [`PolicyBridgeError::NoHolderProof`].
247///
248/// Caps-source precedence: see [`CapsSource`] — the ACDC behind a `Valid` presentation is the
249/// authoritative caps/role source, governing over any commit-time advisory `agentscope:` seal.
250///
251/// The spec's vestigial `tel_state` parameter is intentionally dropped: a `Valid`
252/// presentation is by construction not-revoked at the verified `as_of` (F.5 already ran the
253/// TEL revocation math), so this maps `revoked = false` unconditionally.
254///
255/// Args:
256/// * `presentation`: The holder-binding verdict from `auths_verifier::verify_presentation`.
257/// * `now`: The current time (injected for determinism; no wall clock here).
258///
259/// Usage:
260/// ```ignore
261/// let ctx = context_from_credential(&verdict, now)?;
262/// let decision = evaluate_strict(&policy, &ctx);
263/// ```
264pub fn context_from_credential(
265    presentation: &PresentationVerdict,
266    now: DateTime<Utc>,
267) -> Result<EvalContext, PolicyBridgeError> {
268    let PresentationVerdict::Valid {
269        issuer,
270        subject,
271        caps,
272        role,
273        expires_at,
274        ..
275    } = presentation
276    else {
277        return Err(PolicyBridgeError::NoHolderProof);
278    };
279
280    let mut ctx = EvalContext::try_from_strings(now, issuer.as_str(), subject.as_str())?;
281    ctx = ctx.revoked(false);
282
283    let caps: Vec<CanonicalCapability> = caps
284        .iter()
285        .filter_map(|c| CanonicalCapability::parse(c.as_str()).ok())
286        .collect();
287    ctx = ctx.capabilities(caps);
288
289    if let Some(role) = role {
290        ctx = ctx.role(role.clone());
291    }
292    if let Some(expires_at) = expires_at {
293        ctx = ctx.expires_at(*expires_at);
294    }
295    if let Ok(did) = CanonicalDid::parse(issuer.as_str()) {
296        ctx = ctx.delegated_by(did);
297    }
298
299    Ok(ctx)
300}
301
302/// Evaluate a compiled policy against an attestation.
303///
304/// This is a **pure function** with no side effects.
305///
306/// # Pure Function Guarantees
307///
308/// - **Deterministic**: Same inputs always produce same `Decision`
309/// - **No I/O**: No filesystem, network, or global state access
310/// - **Time is injected**: `now` parameter, never `Utc::now()`
311/// - **No storage assumptions**: All state passed as parameters
312///
313/// # Arguments
314///
315/// * `att` - The device attestation being evaluated
316/// * `policy` - The compiled policy to evaluate
317/// * `now` - The current time (injected for determinism)
318///
319/// # Returns
320///
321/// A `Decision` indicating whether the action is allowed, denied, or indeterminate.
322///
323/// # Example
324///
325/// ```rust,ignore
326/// use auths_id::policy::{evaluate_compiled, PolicyBuilder};
327/// use chrono::Utc;
328///
329/// let policy = PolicyBuilder::new()
330///     .not_revoked()
331///     .not_expired()
332///     .require_capability("sign_commit")
333///     .build();
334///
335/// let decision = evaluate_compiled(&device_attestation, &policy, Utc::now());
336///
337/// if decision.outcome == Outcome::Allow {
338///     println!("Access granted");
339/// }
340/// ```
341pub fn evaluate_compiled(
342    att: &Attestation,
343    policy: &CompiledPolicy,
344    now: DateTime<Utc>,
345) -> Result<Decision, DidParseError> {
346    let ctx = context_from_attestation(att, now)?;
347    Ok(evaluate_strict(policy, &ctx))
348}
349
350/// Evaluate policy with optional witness consistency checks.
351///
352/// This function extends [`evaluate_compiled`] by first checking that the
353/// local identity head matches what witnesses have observed. This helps
354/// detect split-view attacks where a malicious node shows different KELs
355/// to different peers.
356///
357/// # Witness Checking
358///
359/// 1. If no witnesses are provided or all return `None`, proceed with normal policy evaluation
360/// 2. If witnesses have opinions, count how many agree with local_head
361/// 3. If quorum is not met, return `Indeterminate`
362/// 4. If quorum is met, proceed with normal policy evaluation
363///
364/// # Arguments
365///
366/// * `identity` - The identity's current key state
367/// * `att` - The device attestation being evaluated
368/// * `policy` - The compiled policy to evaluate
369/// * `now` - Current time (injected for determinism)
370/// * `local_head` - The local identity KEL head (from our storage)
371/// * `witnesses` - Witness providers to check for consistency
372///
373/// # Returns
374///
375/// - `Indeterminate` if witness quorum not met
376/// - Otherwise, result of `evaluate_compiled`
377pub fn evaluate_with_witness(
378    identity: &KeyState,
379    att: &Attestation,
380    policy: &CompiledPolicy,
381    now: DateTime<Utc>,
382    local_head: EventHash,
383    witnesses: &[&dyn WitnessProvider],
384) -> Result<Decision, DidParseError> {
385    if witnesses.is_empty() {
386        return evaluate_compiled(att, policy, now);
387    }
388
389    let required_quorum = witnesses.first().map(|w| w.quorum()).unwrap_or(1);
390
391    if required_quorum == 0 {
392        return evaluate_compiled(att, policy, now);
393    }
394
395    let mut matching = 0;
396    let mut total_opinions = 0;
397
398    for witness in witnesses {
399        if let Some(head) = witness.observe_identity_head(&identity.prefix) {
400            total_opinions += 1;
401            if head == local_head {
402                matching += 1;
403            }
404        }
405    }
406
407    if total_opinions == 0 {
408        return evaluate_compiled(att, policy, now);
409    }
410
411    if matching < required_quorum {
412        return Ok(Decision::deny(
413            ReasonCode::WitnessQuorumNotMet,
414            format!(
415                "Witness quorum not met: {}/{} matching, {} required",
416                matching, total_opinions, required_quorum
417            ),
418        ));
419    }
420
421    evaluate_compiled(att, policy, now)
422}
423
424/// Result of receipt verification.
425#[derive(Debug, Clone, PartialEq, Eq)]
426pub enum ReceiptVerificationResult {
427    /// Receipts are valid and meet threshold
428    Valid,
429    /// Not enough receipts to meet threshold
430    InsufficientReceipts { required: usize, got: usize },
431    /// Duplicity detected (conflicting SAIDs)
432    Duplicity { event_a: Said, event_b: Said },
433    /// Invalid receipt signature
434    InvalidSignature { witness_did: CanonicalDid },
435}
436
437/// Witness public key resolver.
438///
439/// Implementations provide public keys for witnesses by their DID.
440pub trait WitnessKeyResolver: Send + Sync {
441    /// Get the Ed25519 public key (32 bytes) for a witness DID.
442    fn get_public_key(&self, witness_did: &str) -> Option<Vec<u8>>;
443}
444
445/// Evaluate policy with receipt verification.
446///
447/// This function extends [`evaluate_compiled`] by verifying that:
448/// 1. Sufficient receipts are present (meets threshold from event's `bt` field)
449/// 2. All receipts are for the same event SAID (no duplicity)
450/// 3. Optionally, all receipt signatures are valid
451///
452/// # Arguments
453///
454/// * `att` - The device attestation being evaluated
455/// * `policy` - The compiled policy to evaluate
456/// * `now` - Current time (injected for determinism)
457/// * `receipts` - The collected receipts for the event
458/// * `threshold` - Required number of receipts (from event's `bt` field)
459/// * `key_resolver` - Optional resolver for verifying receipt signatures
460///
461/// # Returns
462///
463/// - `ReceiptVerificationResult::InsufficientReceipts` if threshold not met
464/// - `ReceiptVerificationResult::Duplicity` if conflicting SAIDs detected
465/// - `ReceiptVerificationResult::InvalidSignature` if signature verification fails
466/// - Otherwise, proceeds to policy evaluation and returns `ReceiptVerificationResult::Valid`
467///   if policy allows, or the policy's `Decision` otherwise
468#[cfg(feature = "git-storage")]
469pub fn verify_receipts(
470    receipts: &EventReceipts,
471    threshold: usize,
472    key_resolver: Option<&dyn WitnessKeyResolver>,
473) -> ReceiptVerificationResult {
474    // 1. Check threshold met (using unique witness count, not raw receipt count)
475    let unique = receipts.unique_witness_count();
476    if unique < threshold {
477        return ReceiptVerificationResult::InsufficientReceipts {
478            required: threshold,
479            got: unique,
480        };
481    }
482
483    // 2. Check for duplicity (all receipts should have same SAID)
484    if let Err(e) = check_receipt_consistency(&receipts.receipts) {
485        return ReceiptVerificationResult::Duplicity {
486            event_a: receipts.event_said.clone(),
487            event_b: Said::new_unchecked(format!("conflicting: {}", e)),
488        };
489    }
490
491    // 3. Verify receipt signatures if key resolver provided.
492    //    Provenance comes from the stored witness AID, not the receipt body's
493    //    controller `i`. Collection-time verification (witness_integration) is
494    //    the authoritative gate; this resolver path is verifier-side scaffolding.
495    if let Some(resolver) = key_resolver {
496        for stored in &receipts.receipts {
497            let witness = stored.witness.as_str();
498            if let Some(public_key) = resolver.get_public_key(witness) {
499                let witness_curve = auths_crypto::did_key_decode(witness)
500                    .map(|d| d.curve())
501                    .unwrap_or_default();
502                let typed_pk =
503                    match auths_verifier::decode_public_key_bytes(&public_key, witness_curve) {
504                        Ok(pk) => pk,
505                        Err(_) => {
506                            #[allow(clippy::disallowed_methods)]
507                            // INVARIANT: witness is a CESR AID from a deserialized stored receipt
508                            return ReceiptVerificationResult::InvalidSignature {
509                                witness_did: CanonicalDid::new_unchecked(witness),
510                            };
511                        }
512                    };
513                match verify_receipt_signature(&stored.signed.receipt, &typed_pk) {
514                    Ok(true) => continue,
515                    Ok(false) | Err(_) => {
516                        return ReceiptVerificationResult::InvalidSignature {
517                            #[allow(clippy::disallowed_methods)] // INVARIANT: witness is a CESR AID from a deserialized stored receipt
518                            witness_did: CanonicalDid::new_unchecked(witness),
519                        };
520                    }
521                }
522            }
523            // If no key found for witness, skip signature verification for that receipt
524            // (In production, you might want to fail instead)
525        }
526    }
527
528    ReceiptVerificationResult::Valid
529}
530
531/// Evaluate policy with both witness head checks and receipt verification.
532///
533/// This is the most comprehensive policy evaluation function, combining:
534/// - Witness head consistency checks (split-view detection)
535/// - Receipt threshold verification
536/// - Receipt signature verification
537/// - Standard policy evaluation
538///
539/// # Arguments
540///
541/// * `identity` - The identity's current key state
542/// * `att` - The device attestation being evaluated
543/// * `policy` - The compiled policy to evaluate
544/// * `now` - Current time (injected for determinism)
545/// * `local_head` - The local identity KEL head
546/// * `witnesses` - Witness providers for head consistency checks
547/// * `receipts` - The collected receipts for the event
548/// * `threshold` - Required number of receipts
549/// * `key_resolver` - Optional resolver for verifying receipt signatures
550///
551/// # Returns
552///
553/// - `Deny` with appropriate reason if any verification fails
554/// - Otherwise, result of `evaluate_compiled`
555#[cfg(feature = "git-storage")]
556#[allow(clippy::too_many_arguments)]
557pub fn evaluate_with_receipts(
558    identity: &KeyState,
559    att: &Attestation,
560    policy: &CompiledPolicy,
561    now: DateTime<Utc>,
562    local_head: EventHash,
563    witnesses: &[&dyn WitnessProvider],
564    receipts: &EventReceipts,
565    threshold: usize,
566    key_resolver: Option<&dyn WitnessKeyResolver>,
567) -> Result<Decision, DidParseError> {
568    match verify_receipts(receipts, threshold, key_resolver) {
569        ReceiptVerificationResult::Valid => {}
570        ReceiptVerificationResult::InsufficientReceipts { required, got } => {
571            return Ok(Decision::deny(
572                ReasonCode::WitnessQuorumNotMet,
573                format!(
574                    "Insufficient receipts: {} required, {} present",
575                    required, got
576                ),
577            ));
578        }
579        ReceiptVerificationResult::Duplicity { event_a, event_b } => {
580            return Ok(Decision::deny(
581                ReasonCode::WitnessQuorumNotMet,
582                format!("Duplicity detected: {} vs {}", event_a, event_b),
583            ));
584        }
585        ReceiptVerificationResult::InvalidSignature { witness_did } => {
586            return Ok(Decision::deny(
587                ReasonCode::WitnessQuorumNotMet,
588                format!("Invalid receipt signature from witness: {}", witness_did),
589            ));
590        }
591    }
592
593    evaluate_with_witness(identity, att, policy, now, local_head, witnesses)
594}
595
596#[cfg(test)]
597#[allow(clippy::disallowed_methods)]
598mod tests {
599    use super::*;
600    use auths_core::witness::NoOpWitness;
601    use auths_keri::{CesrKey, Prefix, Said, Threshold};
602    use auths_verifier::AttestationBuilder;
603    use chrono::Duration;
604
605    /// Mock witness for testing
606    struct MockWitness {
607        head: Option<EventHash>,
608        quorum: usize,
609    }
610
611    impl WitnessProvider for MockWitness {
612        fn observe_identity_head(&self, _prefix: &Prefix) -> Option<EventHash> {
613            self.head
614        }
615
616        fn quorum(&self) -> usize {
617            self.quorum
618        }
619    }
620
621    fn make_key_state(prefix: &str) -> KeyState {
622        KeyState::from_inception(
623            Prefix::new_unchecked(prefix.to_string()),
624            vec![CesrKey::new_unchecked("DTestKey".to_string())],
625            vec![Said::new_unchecked("ENextCommitment".to_string())],
626            Threshold::Simple(1),
627            Threshold::Simple(1),
628            Said::new_unchecked("ETestSaid".to_string()),
629            vec![],
630            Threshold::Simple(0),
631            vec![],
632        )
633    }
634
635    fn make_attestation(
636        issuer: &str,
637        revoked_at: Option<DateTime<Utc>>,
638        expires_at: Option<DateTime<Utc>>,
639    ) -> Attestation {
640        AttestationBuilder::default()
641            .rid("test")
642            .issuer(issuer)
643            .subject("did:key:zSubject")
644            .revoked_at(revoked_at)
645            .expires_at(expires_at)
646            .build()
647    }
648
649    fn default_policy() -> CompiledPolicy {
650        PolicyBuilder::new().not_revoked().not_expired().build()
651    }
652
653    #[test]
654    fn context_from_attestation_basic() {
655        let att = make_attestation("did:keri:ETest", None, None);
656        let now = Utc::now();
657        let ctx = context_from_attestation(&att, now).unwrap();
658
659        assert_eq!(ctx.issuer.as_str(), "did:keri:ETest");
660        assert_eq!(ctx.subject.as_str(), "did:key:zSubject");
661        assert!(!ctx.revoked);
662    }
663
664    #[test]
665    fn context_from_attestation_has_no_capabilities_or_role() {
666        // The attestation no longer carries caps/role at all — credential-grade
667        // authority comes only from `context_from_credential` (a holder-verified
668        // ACDC presentation), org role from `context_from_delegated_member`.
669        let att = make_attestation("did:keri:ETest", None, None);
670        let now = Utc::now();
671        let ctx = context_from_attestation(&att, now).unwrap();
672
673        assert!(
674            ctx.capabilities.is_empty(),
675            "attestation caps must not enter the policy context"
676        );
677        assert_eq!(
678            ctx.role, None,
679            "attestation role must not enter the policy context"
680        );
681    }
682
683    #[test]
684    fn caps_absent_without_valid_credential() {
685        // Fail-closed: with no holder-verified credential, a capability-gated policy
686        // denies — authority can only arrive via a credential, never the attestation.
687        let att = make_attestation("did:keri:ETestPrefix", None, None);
688        let policy = PolicyBuilder::new()
689            .not_revoked()
690            .require_capability("sign_commit")
691            .build();
692        let now = Utc::now();
693
694        let decision = evaluate_compiled(&att, &policy, now).unwrap();
695        assert_eq!(decision.outcome, Outcome::Deny);
696        assert_eq!(decision.reason, ReasonCode::CapabilityMissing);
697    }
698
699    // =========================================================================
700    // F.6 — context_from_credential holder-proof bridge
701    // =========================================================================
702
703    const CRED_ISSUER: &str = "did:keri:EIssuerCredential";
704    const CRED_SUBJECT: &str = "did:keri:ESubjectCredential";
705
706    /// A holder-verified `Valid` presentation carrying the given grant facts.
707    fn valid_presentation(
708        caps: &[&str],
709        role: Option<&str>,
710        expires_at: Option<DateTime<Utc>>,
711    ) -> PresentationVerdict {
712        PresentationVerdict::Valid {
713            issuer: auths_verifier::IdentityDID::parse(CRED_ISSUER).expect("valid test issuer"),
714            subject: auths_verifier::CanonicalDid::parse(CRED_SUBJECT).expect("valid test subject"),
715            subject_root: auths_verifier::CanonicalDid::parse(CRED_SUBJECT)
716                .expect("valid test subject"),
717            caps: caps
718                .iter()
719                .map(|c| auths_verifier::Capability::parse(c).expect("valid test capability"))
720                .collect(),
721            role: role.map(str::to_string),
722            expires_at,
723            freshness: auths_verifier::Freshness::Unknown,
724            as_of: 0,
725        }
726    }
727
728    #[test]
729    fn policy_reads_caps_from_credential() {
730        let presentation = valid_presentation(&["sign_commit"], Some("deployer"), None);
731        let now = Utc::now();
732
733        let ctx = context_from_credential(&presentation, now).unwrap();
734        assert_eq!(ctx.issuer.as_str(), CRED_ISSUER);
735        assert_eq!(ctx.subject.as_str(), CRED_SUBJECT);
736        assert!(!ctx.revoked);
737        assert_eq!(ctx.capabilities.len(), 1);
738        assert_eq!(ctx.capabilities[0].as_str(), "sign_commit");
739        assert_eq!(ctx.role.as_deref(), Some("deployer"));
740
741        let policy = PolicyBuilder::new()
742            .not_revoked()
743            .require_capability("sign_commit")
744            .build();
745        assert_eq!(evaluate_strict(&policy, &ctx).outcome, Outcome::Allow);
746    }
747
748    #[test]
749    fn raw_acdc_without_presentation_yields_no_authority() {
750        // The bridge takes a verdict, not an ACDC: a non-`Valid` verdict (here a possessed
751        // credential that failed the holder-proof gate) yields NO authority-bearing
752        // context. Mere possession of a raw ACDC cannot even be passed in.
753        let now = Utc::now();
754        for verdict in [
755            PresentationVerdict::HolderNotCurrentKey,
756            PresentationVerdict::WrongAudience,
757            PresentationVerdict::NonceMismatchOrConsumed,
758            PresentationVerdict::Expired,
759            PresentationVerdict::SubjectKelInvalid,
760            PresentationVerdict::CredentialNotValid(
761                auths_verifier::CredentialVerdict::SaidMismatch,
762            ),
763        ] {
764            let result = context_from_credential(&verdict, now);
765            assert!(
766                matches!(result, Err(PolicyBridgeError::NoHolderProof)),
767                "non-Valid verdict {verdict:?} must fail closed, got {result:?}"
768            );
769        }
770    }
771
772    #[test]
773    fn capability_round_trips_into_acdc() {
774        use auths_keri::{AgentScope, decode_agent_scope, encode_agent_scope};
775        use auths_verifier::Capability;
776
777        // A capability that exercises the `:`-allowed constraint (forbidden: `,`).
778        let raw = "repo:foo-bar_baz";
779
780        // 1. Legacy agentscope: CSV seal → decode back → unchanged.
781        let scope = AgentScope {
782            capabilities: vec![Capability::parse(raw).unwrap()],
783            expires_at: Some(99),
784        };
785        let encoded = encode_agent_scope("Eagent", &scope);
786        let (prefix, decoded) = decode_agent_scope(&encoded).unwrap();
787        assert_eq!(prefix, "Eagent");
788        assert_eq!(decoded.capabilities, vec![Capability::parse(raw).unwrap()]);
789
790        // 2. agentscope CSV → ACDC `a.capability` JSON (the F.4 `,`-join encoding).
791        let acdc_capability_json = decoded
792            .capabilities
793            .iter()
794            .map(|c| c.as_str())
795            .collect::<Vec<_>>()
796            .join(",");
797        assert!(
798            !acdc_capability_json.contains(','),
799            "single cap stays comma-free; the join separator must not appear inside a cap"
800        );
801
802        // 3. ACDC `a.capability` (split back on `,`) → CanonicalCapability::parse, lossless.
803        for cap in acdc_capability_json.split(',') {
804            let canonical = CanonicalCapability::parse(cap).unwrap();
805            assert_eq!(canonical.as_str(), raw);
806        }
807
808        // 4. The attestation `Capability` encoding round-trips through the same parse.
809        let att_cap = Capability::parse(raw).unwrap();
810        let canonical = CanonicalCapability::parse(&att_cap.to_string()).unwrap();
811        assert_eq!(canonical.as_str(), raw);
812
813        // The `,` separator is forbidden inside a single capability (CanonicalCapability rejects it).
814        assert!(CanonicalCapability::parse("a,b").is_err());
815    }
816
817    #[test]
818    fn agentscope_seal_vs_acdc_precedence_documented() {
819        // The ACDC is the authoritative caps/role source; the agentscope: seal is
820        // commit-time advisory. When both exist for one grant, the ACDC governs.
821        assert_eq!(CapsSource::governing(true, true), Some(CapsSource::Acdc));
822        assert_eq!(CapsSource::governing(false, true), Some(CapsSource::Acdc));
823        assert_eq!(
824            CapsSource::governing(true, false),
825            Some(CapsSource::AgentScopeSeal)
826        );
827        assert_eq!(CapsSource::governing(false, false), None);
828    }
829
830    #[test]
831    fn evaluate_compiled_allows_valid_attestation() {
832        let att = make_attestation("did:keri:ETestPrefix", None, None);
833        let policy = default_policy();
834        let now = Utc::now();
835
836        let decision = evaluate_compiled(&att, &policy, now).unwrap();
837        assert_eq!(decision.outcome, Outcome::Allow);
838    }
839
840    #[test]
841    fn evaluate_compiled_denies_revoked() {
842        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), None);
843        let policy = default_policy();
844        let now = Utc::now();
845
846        let decision = evaluate_compiled(&att, &policy, now).unwrap();
847        assert_eq!(decision.outcome, Outcome::Deny);
848        assert_eq!(decision.reason, ReasonCode::Revoked);
849    }
850
851    #[test]
852    fn evaluate_compiled_denies_expired() {
853        let past = Utc::now() - Duration::hours(1);
854        let att = make_attestation("did:keri:ETestPrefix", None, Some(past));
855        let policy = default_policy();
856        let now = Utc::now();
857
858        let decision = evaluate_compiled(&att, &policy, now).unwrap();
859        assert_eq!(decision.outcome, Outcome::Deny);
860        assert_eq!(decision.reason, ReasonCode::Expired);
861    }
862
863    #[test]
864    fn evaluate_compiled_allows_not_yet_expired() {
865        let future = Utc::now() + Duration::hours(1);
866        let att = make_attestation("did:keri:ETestPrefix", None, Some(future));
867        let policy = default_policy();
868        let now = Utc::now();
869
870        let decision = evaluate_compiled(&att, &policy, now).unwrap();
871        assert_eq!(decision.outcome, Outcome::Allow);
872    }
873
874    #[test]
875    fn evaluate_compiled_denies_issuer_mismatch() {
876        let att = make_attestation("did:keri:EWrongPrefix", None, None);
877        let policy = PolicyBuilder::new()
878            .not_revoked()
879            .require_issuer("did:keri:ETestPrefix")
880            .build();
881        let now = Utc::now();
882
883        let decision = evaluate_compiled(&att, &policy, now).unwrap();
884        assert_eq!(decision.outcome, Outcome::Deny);
885        assert_eq!(decision.reason, ReasonCode::IssuerMismatch);
886    }
887
888    #[test]
889    fn evaluate_compiled_denies_missing_capability() {
890        let att = make_attestation("did:keri:ETestPrefix", None, None);
891        let policy = PolicyBuilder::new()
892            .not_revoked()
893            .require_capability("sign_commit")
894            .build();
895        let now = Utc::now();
896
897        let decision = evaluate_compiled(&att, &policy, now).unwrap();
898        assert_eq!(decision.outcome, Outcome::Deny);
899        assert_eq!(decision.reason, ReasonCode::CapabilityMissing);
900    }
901
902    #[test]
903    fn evaluate_compiled_allows_with_capability_from_credential() {
904        // Capability authority now arrives only via a holder-verified credential
905        // presentation: the same require-capability policy that an attestation can no
906        // longer satisfy is allowed once the credential context supplies the cap.
907        let presentation = valid_presentation(&["sign_commit"], None, None);
908        let policy = PolicyBuilder::new()
909            .not_revoked()
910            .require_capability("sign_commit")
911            .build();
912        let now = Utc::now();
913
914        let ctx = context_from_credential(&presentation, now).unwrap();
915        assert_eq!(evaluate_strict(&policy, &ctx).outcome, Outcome::Allow);
916    }
917
918    #[test]
919    fn evaluate_compiled_is_deterministic() {
920        let att = make_attestation("did:keri:ETestPrefix", None, None);
921        let policy = default_policy();
922        let now = Utc::now();
923
924        let decision1 = evaluate_compiled(&att, &policy, now).unwrap();
925        let decision2 = evaluate_compiled(&att, &policy, now).unwrap();
926
927        assert_eq!(decision1, decision2);
928    }
929
930    // =========================================================================
931    // Tests for evaluate_with_witness
932    // =========================================================================
933
934    #[test]
935    fn evaluate_with_witness_no_witnesses_delegates() {
936        let identity = make_key_state("ETestPrefix");
937        let att = make_attestation("did:keri:ETestPrefix", None, None);
938        let policy = default_policy();
939        let now = Utc::now();
940        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
941
942        let decision =
943            evaluate_with_witness(&identity, &att, &policy, now, local_head, &[]).unwrap();
944
945        assert_eq!(decision.outcome, Outcome::Allow);
946    }
947
948    #[test]
949    fn evaluate_with_witness_noop_delegates() {
950        let identity = make_key_state("ETestPrefix");
951        let att = make_attestation("did:keri:ETestPrefix", None, None);
952        let policy = default_policy();
953        let now = Utc::now();
954        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
955
956        let noop = NoOpWitness;
957        let witnesses: &[&dyn WitnessProvider] = &[&noop];
958
959        let decision =
960            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
961
962        assert_eq!(decision.outcome, Outcome::Allow);
963    }
964
965    #[test]
966    fn evaluate_with_witness_mismatch_denies() {
967        let identity = make_key_state("ETestPrefix");
968        let att = make_attestation("did:keri:ETestPrefix", None, None);
969        let policy = default_policy();
970        let now = Utc::now();
971        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
972        let different_head =
973            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
974
975        let witness = MockWitness {
976            head: Some(different_head),
977            quorum: 1,
978        };
979        let witnesses: &[&dyn WitnessProvider] = &[&witness];
980
981        let decision =
982            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
983
984        assert_eq!(decision.outcome, Outcome::Deny);
985        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
986    }
987
988    #[test]
989    fn evaluate_with_witness_quorum_met_allows() {
990        let identity = make_key_state("ETestPrefix");
991        let att = make_attestation("did:keri:ETestPrefix", None, None);
992        let policy = default_policy();
993        let now = Utc::now();
994        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
995
996        let witness = MockWitness {
997            head: Some(local_head),
998            quorum: 1,
999        };
1000        let witnesses: &[&dyn WitnessProvider] = &[&witness];
1001
1002        let decision =
1003            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1004
1005        assert_eq!(decision.outcome, Outcome::Allow);
1006    }
1007
1008    #[test]
1009    fn evaluate_with_witness_quorum_met_denies_when_policy_denies() {
1010        let identity = make_key_state("ETestPrefix");
1011        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), None); // revoked
1012        let policy = default_policy();
1013        let now = Utc::now();
1014        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1015
1016        let witness = MockWitness {
1017            head: Some(local_head),
1018            quorum: 1,
1019        };
1020        let witnesses: &[&dyn WitnessProvider] = &[&witness];
1021
1022        let decision =
1023            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1024
1025        assert_eq!(decision.outcome, Outcome::Deny);
1026        assert_eq!(decision.reason, ReasonCode::Revoked);
1027    }
1028
1029    #[test]
1030    fn evaluate_with_witness_multiple_witnesses_quorum() {
1031        let identity = make_key_state("ETestPrefix");
1032        let att = make_attestation("did:keri:ETestPrefix", None, None);
1033        let policy = default_policy();
1034        let now = Utc::now();
1035        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1036        let different_head =
1037            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
1038
1039        let w1 = MockWitness {
1040            head: Some(local_head),
1041            quorum: 2,
1042        };
1043        let w2 = MockWitness {
1044            head: Some(local_head),
1045            quorum: 2,
1046        };
1047        let w3 = MockWitness {
1048            head: Some(different_head),
1049            quorum: 2,
1050        };
1051        let witnesses: &[&dyn WitnessProvider] = &[&w1, &w2, &w3];
1052
1053        let decision =
1054            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1055
1056        assert_eq!(decision.outcome, Outcome::Allow);
1057    }
1058
1059    #[test]
1060    fn evaluate_with_witness_no_opinions_delegates() {
1061        let identity = make_key_state("ETestPrefix");
1062        let att = make_attestation("did:keri:ETestPrefix", None, None);
1063        let policy = default_policy();
1064        let now = Utc::now();
1065        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1066
1067        let witness = MockWitness {
1068            head: None,
1069            quorum: 1,
1070        };
1071        let witnesses: &[&dyn WitnessProvider] = &[&witness];
1072
1073        let decision =
1074            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1075
1076        assert_eq!(decision.outcome, Outcome::Allow);
1077    }
1078
1079    // =========================================================================
1080    // Tests for receipt verification
1081    // =========================================================================
1082
1083    fn make_test_receipt(
1084        event_said: &str,
1085        witness_did: &str,
1086        seq: u128,
1087    ) -> auths_core::witness::StoredReceipt {
1088        auths_core::witness::StoredReceipt {
1089            signed: auths_core::witness::SignedReceipt {
1090                receipt: auths_core::witness::Receipt {
1091                    v: auths_keri::VersionString::placeholder(),
1092                    t: auths_core::witness::ReceiptTag,
1093                    d: Said::new_unchecked(event_said.to_string()),
1094                    i: Prefix::new_unchecked("EController".to_string()),
1095                    s: auths_keri::KeriSequence::new(seq),
1096                },
1097                signature: vec![],
1098            },
1099            witness: Prefix::new_unchecked(witness_did.to_string()),
1100        }
1101    }
1102
1103    #[test]
1104    fn verify_receipts_meets_threshold() {
1105        let receipts = EventReceipts::new(
1106            "ESAID123",
1107            vec![
1108                make_test_receipt("ESAID123", "did:key:w1", 0),
1109                make_test_receipt("ESAID123", "did:key:w2", 0),
1110            ],
1111        );
1112
1113        let result = verify_receipts(&receipts, 2, None);
1114        assert_eq!(result, ReceiptVerificationResult::Valid);
1115    }
1116
1117    #[test]
1118    fn verify_receipts_insufficient() {
1119        let receipts = EventReceipts::new(
1120            "ESAID123",
1121            vec![make_test_receipt("ESAID123", "did:key:w1", 0)],
1122        );
1123
1124        let result = verify_receipts(&receipts, 2, None);
1125        assert!(matches!(
1126            result,
1127            ReceiptVerificationResult::InsufficientReceipts {
1128                required: 2,
1129                got: 1
1130            }
1131        ));
1132    }
1133
1134    #[test]
1135    fn verify_receipts_duplicity() {
1136        let receipts = EventReceipts {
1137            event_said: Said::new_unchecked("ESAID_A".to_string()),
1138            receipts: vec![
1139                make_test_receipt("ESAID_A", "did:key:w1", 0),
1140                make_test_receipt("ESAID_B", "did:key:w2", 0), // Different SAID!
1141            ],
1142        };
1143
1144        let result = verify_receipts(&receipts, 1, None);
1145        assert!(matches!(
1146            result,
1147            ReceiptVerificationResult::Duplicity { .. }
1148        ));
1149    }
1150
1151    #[test]
1152    fn evaluate_with_receipts_valid() {
1153        let identity = make_key_state("ETestPrefix");
1154        let att = make_attestation("did:keri:ETestPrefix", None, None);
1155        let policy = default_policy();
1156        let now = Utc::now();
1157        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1158        let receipts = EventReceipts::new(
1159            "ESAID",
1160            vec![
1161                make_test_receipt("ESAID", "did:key:w1", 0),
1162                make_test_receipt("ESAID", "did:key:w2", 0),
1163            ],
1164        );
1165
1166        let decision = evaluate_with_receipts(
1167            &identity,
1168            &att,
1169            &policy,
1170            now,
1171            local_head,
1172            &[],
1173            &receipts,
1174            2,
1175            None,
1176        )
1177        .unwrap();
1178
1179        assert_eq!(decision.outcome, Outcome::Allow);
1180    }
1181
1182    #[test]
1183    fn evaluate_with_receipts_insufficient_denies() {
1184        let identity = make_key_state("ETestPrefix");
1185        let att = make_attestation("did:keri:ETestPrefix", None, None);
1186        let policy = default_policy();
1187        let now = Utc::now();
1188        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1189        let receipts =
1190            EventReceipts::new("ESAID", vec![make_test_receipt("ESAID", "did:key:w1", 0)]);
1191
1192        let decision = evaluate_with_receipts(
1193            &identity,
1194            &att,
1195            &policy,
1196            now,
1197            local_head,
1198            &[],
1199            &receipts,
1200            2, // Threshold 2, but only 1 receipt
1201            None,
1202        )
1203        .unwrap();
1204
1205        assert_eq!(decision.outcome, Outcome::Deny);
1206        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
1207    }
1208}