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: &[String],
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).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    } = presentation
275    else {
276        return Err(PolicyBridgeError::NoHolderProof);
277    };
278
279    let mut ctx = EvalContext::try_from_strings(now, issuer.as_str(), subject.as_str())?;
280    ctx = ctx.revoked(false);
281
282    let caps: Vec<CanonicalCapability> = caps
283        .iter()
284        .filter_map(|c| CanonicalCapability::parse(c.as_str()).ok())
285        .collect();
286    ctx = ctx.capabilities(caps);
287
288    if let Some(role) = role {
289        ctx = ctx.role(role.clone());
290    }
291    if let Some(expires_at) = expires_at {
292        ctx = ctx.expires_at(*expires_at);
293    }
294    if let Ok(did) = CanonicalDid::parse(issuer.as_str()) {
295        ctx = ctx.delegated_by(did);
296    }
297
298    Ok(ctx)
299}
300
301/// Evaluate a compiled policy against an attestation.
302///
303/// This is a **pure function** with no side effects.
304///
305/// # Pure Function Guarantees
306///
307/// - **Deterministic**: Same inputs always produce same `Decision`
308/// - **No I/O**: No filesystem, network, or global state access
309/// - **Time is injected**: `now` parameter, never `Utc::now()`
310/// - **No storage assumptions**: All state passed as parameters
311///
312/// # Arguments
313///
314/// * `att` - The device attestation being evaluated
315/// * `policy` - The compiled policy to evaluate
316/// * `now` - The current time (injected for determinism)
317///
318/// # Returns
319///
320/// A `Decision` indicating whether the action is allowed, denied, or indeterminate.
321///
322/// # Example
323///
324/// ```rust,ignore
325/// use auths_id::policy::{evaluate_compiled, PolicyBuilder};
326/// use chrono::Utc;
327///
328/// let policy = PolicyBuilder::new()
329///     .not_revoked()
330///     .not_expired()
331///     .require_capability("sign_commit")
332///     .build();
333///
334/// let decision = evaluate_compiled(&device_attestation, &policy, Utc::now());
335///
336/// if decision.outcome == Outcome::Allow {
337///     println!("Access granted");
338/// }
339/// ```
340pub fn evaluate_compiled(
341    att: &Attestation,
342    policy: &CompiledPolicy,
343    now: DateTime<Utc>,
344) -> Result<Decision, DidParseError> {
345    let ctx = context_from_attestation(att, now)?;
346    Ok(evaluate_strict(policy, &ctx))
347}
348
349/// Evaluate policy with optional witness consistency checks.
350///
351/// This function extends [`evaluate_compiled`] by first checking that the
352/// local identity head matches what witnesses have observed. This helps
353/// detect split-view attacks where a malicious node shows different KELs
354/// to different peers.
355///
356/// # Witness Checking
357///
358/// 1. If no witnesses are provided or all return `None`, proceed with normal policy evaluation
359/// 2. If witnesses have opinions, count how many agree with local_head
360/// 3. If quorum is not met, return `Indeterminate`
361/// 4. If quorum is met, proceed with normal policy evaluation
362///
363/// # Arguments
364///
365/// * `identity` - The identity's current key state
366/// * `att` - The device attestation being evaluated
367/// * `policy` - The compiled policy to evaluate
368/// * `now` - Current time (injected for determinism)
369/// * `local_head` - The local identity KEL head (from our storage)
370/// * `witnesses` - Witness providers to check for consistency
371///
372/// # Returns
373///
374/// - `Indeterminate` if witness quorum not met
375/// - Otherwise, result of `evaluate_compiled`
376pub fn evaluate_with_witness(
377    identity: &KeyState,
378    att: &Attestation,
379    policy: &CompiledPolicy,
380    now: DateTime<Utc>,
381    local_head: EventHash,
382    witnesses: &[&dyn WitnessProvider],
383) -> Result<Decision, DidParseError> {
384    if witnesses.is_empty() {
385        return evaluate_compiled(att, policy, now);
386    }
387
388    let required_quorum = witnesses.first().map(|w| w.quorum()).unwrap_or(1);
389
390    if required_quorum == 0 {
391        return evaluate_compiled(att, policy, now);
392    }
393
394    let mut matching = 0;
395    let mut total_opinions = 0;
396
397    for witness in witnesses {
398        if let Some(head) = witness.observe_identity_head(&identity.prefix) {
399            total_opinions += 1;
400            if head == local_head {
401                matching += 1;
402            }
403        }
404    }
405
406    if total_opinions == 0 {
407        return evaluate_compiled(att, policy, now);
408    }
409
410    if matching < required_quorum {
411        return Ok(Decision::deny(
412            ReasonCode::WitnessQuorumNotMet,
413            format!(
414                "Witness quorum not met: {}/{} matching, {} required",
415                matching, total_opinions, required_quorum
416            ),
417        ));
418    }
419
420    evaluate_compiled(att, policy, now)
421}
422
423/// Result of receipt verification.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub enum ReceiptVerificationResult {
426    /// Receipts are valid and meet threshold
427    Valid,
428    /// Not enough receipts to meet threshold
429    InsufficientReceipts { required: usize, got: usize },
430    /// Duplicity detected (conflicting SAIDs)
431    Duplicity { event_a: Said, event_b: Said },
432    /// Invalid receipt signature
433    InvalidSignature { witness_did: CanonicalDid },
434}
435
436/// Witness public key resolver.
437///
438/// Implementations provide public keys for witnesses by their DID.
439pub trait WitnessKeyResolver: Send + Sync {
440    /// Get the Ed25519 public key (32 bytes) for a witness DID.
441    fn get_public_key(&self, witness_did: &str) -> Option<Vec<u8>>;
442}
443
444/// Evaluate policy with receipt verification.
445///
446/// This function extends [`evaluate_compiled`] by verifying that:
447/// 1. Sufficient receipts are present (meets threshold from event's `bt` field)
448/// 2. All receipts are for the same event SAID (no duplicity)
449/// 3. Optionally, all receipt signatures are valid
450///
451/// # Arguments
452///
453/// * `att` - The device attestation being evaluated
454/// * `policy` - The compiled policy to evaluate
455/// * `now` - Current time (injected for determinism)
456/// * `receipts` - The collected receipts for the event
457/// * `threshold` - Required number of receipts (from event's `bt` field)
458/// * `key_resolver` - Optional resolver for verifying receipt signatures
459///
460/// # Returns
461///
462/// - `ReceiptVerificationResult::InsufficientReceipts` if threshold not met
463/// - `ReceiptVerificationResult::Duplicity` if conflicting SAIDs detected
464/// - `ReceiptVerificationResult::InvalidSignature` if signature verification fails
465/// - Otherwise, proceeds to policy evaluation and returns `ReceiptVerificationResult::Valid`
466///   if policy allows, or the policy's `Decision` otherwise
467#[cfg(feature = "git-storage")]
468pub fn verify_receipts(
469    receipts: &EventReceipts,
470    threshold: usize,
471    key_resolver: Option<&dyn WitnessKeyResolver>,
472) -> ReceiptVerificationResult {
473    // 1. Check threshold met (using unique witness count, not raw receipt count)
474    let unique = receipts.unique_witness_count();
475    if unique < threshold {
476        return ReceiptVerificationResult::InsufficientReceipts {
477            required: threshold,
478            got: unique,
479        };
480    }
481
482    // 2. Check for duplicity (all receipts should have same SAID)
483    if let Err(e) = check_receipt_consistency(&receipts.receipts) {
484        return ReceiptVerificationResult::Duplicity {
485            event_a: receipts.event_said.clone(),
486            event_b: Said::new_unchecked(format!("conflicting: {}", e)),
487        };
488    }
489
490    // 3. Verify receipt signatures if key resolver provided.
491    //    Provenance comes from the stored witness AID, not the receipt body's
492    //    controller `i`. Collection-time verification (witness_integration) is
493    //    the authoritative gate; this resolver path is verifier-side scaffolding.
494    if let Some(resolver) = key_resolver {
495        for stored in &receipts.receipts {
496            let witness = stored.witness.as_str();
497            if let Some(public_key) = resolver.get_public_key(witness) {
498                let witness_curve = auths_crypto::did_key_decode(witness)
499                    .map(|d| d.curve())
500                    .unwrap_or_default();
501                let typed_pk =
502                    match auths_verifier::decode_public_key_bytes(&public_key, witness_curve) {
503                        Ok(pk) => pk,
504                        Err(_) => {
505                            #[allow(clippy::disallowed_methods)]
506                            // INVARIANT: witness is a CESR AID from a deserialized stored receipt
507                            return ReceiptVerificationResult::InvalidSignature {
508                                witness_did: CanonicalDid::new_unchecked(witness),
509                            };
510                        }
511                    };
512                match verify_receipt_signature(&stored.signed.receipt, &typed_pk) {
513                    Ok(true) => continue,
514                    Ok(false) | Err(_) => {
515                        return ReceiptVerificationResult::InvalidSignature {
516                            #[allow(clippy::disallowed_methods)] // INVARIANT: witness is a CESR AID from a deserialized stored receipt
517                            witness_did: CanonicalDid::new_unchecked(witness),
518                        };
519                    }
520                }
521            }
522            // If no key found for witness, skip signature verification for that receipt
523            // (In production, you might want to fail instead)
524        }
525    }
526
527    ReceiptVerificationResult::Valid
528}
529
530/// Evaluate policy with both witness head checks and receipt verification.
531///
532/// This is the most comprehensive policy evaluation function, combining:
533/// - Witness head consistency checks (split-view detection)
534/// - Receipt threshold verification
535/// - Receipt signature verification
536/// - Standard policy evaluation
537///
538/// # Arguments
539///
540/// * `identity` - The identity's current key state
541/// * `att` - The device attestation being evaluated
542/// * `policy` - The compiled policy to evaluate
543/// * `now` - Current time (injected for determinism)
544/// * `local_head` - The local identity KEL head
545/// * `witnesses` - Witness providers for head consistency checks
546/// * `receipts` - The collected receipts for the event
547/// * `threshold` - Required number of receipts
548/// * `key_resolver` - Optional resolver for verifying receipt signatures
549///
550/// # Returns
551///
552/// - `Deny` with appropriate reason if any verification fails
553/// - Otherwise, result of `evaluate_compiled`
554#[cfg(feature = "git-storage")]
555#[allow(clippy::too_many_arguments)]
556pub fn evaluate_with_receipts(
557    identity: &KeyState,
558    att: &Attestation,
559    policy: &CompiledPolicy,
560    now: DateTime<Utc>,
561    local_head: EventHash,
562    witnesses: &[&dyn WitnessProvider],
563    receipts: &EventReceipts,
564    threshold: usize,
565    key_resolver: Option<&dyn WitnessKeyResolver>,
566) -> Result<Decision, DidParseError> {
567    match verify_receipts(receipts, threshold, key_resolver) {
568        ReceiptVerificationResult::Valid => {}
569        ReceiptVerificationResult::InsufficientReceipts { required, got } => {
570            return Ok(Decision::deny(
571                ReasonCode::WitnessQuorumNotMet,
572                format!(
573                    "Insufficient receipts: {} required, {} present",
574                    required, got
575                ),
576            ));
577        }
578        ReceiptVerificationResult::Duplicity { event_a, event_b } => {
579            return Ok(Decision::deny(
580                ReasonCode::WitnessQuorumNotMet,
581                format!("Duplicity detected: {} vs {}", event_a, event_b),
582            ));
583        }
584        ReceiptVerificationResult::InvalidSignature { witness_did } => {
585            return Ok(Decision::deny(
586                ReasonCode::WitnessQuorumNotMet,
587                format!("Invalid receipt signature from witness: {}", witness_did),
588            ));
589        }
590    }
591
592    evaluate_with_witness(identity, att, policy, now, local_head, witnesses)
593}
594
595#[cfg(test)]
596#[allow(clippy::disallowed_methods)]
597mod tests {
598    use super::*;
599    use auths_core::witness::NoOpWitness;
600    use auths_keri::{CesrKey, Prefix, Said, Threshold};
601    use auths_verifier::AttestationBuilder;
602    use chrono::Duration;
603
604    /// Mock witness for testing
605    struct MockWitness {
606        head: Option<EventHash>,
607        quorum: usize,
608    }
609
610    impl WitnessProvider for MockWitness {
611        fn observe_identity_head(&self, _prefix: &Prefix) -> Option<EventHash> {
612            self.head
613        }
614
615        fn quorum(&self) -> usize {
616            self.quorum
617        }
618    }
619
620    fn make_key_state(prefix: &str) -> KeyState {
621        KeyState::from_inception(
622            Prefix::new_unchecked(prefix.to_string()),
623            vec![CesrKey::new_unchecked("DTestKey".to_string())],
624            vec![Said::new_unchecked("ENextCommitment".to_string())],
625            Threshold::Simple(1),
626            Threshold::Simple(1),
627            Said::new_unchecked("ETestSaid".to_string()),
628            vec![],
629            Threshold::Simple(0),
630            vec![],
631        )
632    }
633
634    fn make_attestation(
635        issuer: &str,
636        revoked_at: Option<DateTime<Utc>>,
637        expires_at: Option<DateTime<Utc>>,
638    ) -> Attestation {
639        AttestationBuilder::default()
640            .rid("test")
641            .issuer(issuer)
642            .subject("did:key:zSubject")
643            .revoked_at(revoked_at)
644            .expires_at(expires_at)
645            .build()
646    }
647
648    fn default_policy() -> CompiledPolicy {
649        PolicyBuilder::new().not_revoked().not_expired().build()
650    }
651
652    #[test]
653    fn context_from_attestation_basic() {
654        let att = make_attestation("did:keri:ETest", None, None);
655        let now = Utc::now();
656        let ctx = context_from_attestation(&att, now).unwrap();
657
658        assert_eq!(ctx.issuer.as_str(), "did:keri:ETest");
659        assert_eq!(ctx.subject.as_str(), "did:key:zSubject");
660        assert!(!ctx.revoked);
661    }
662
663    #[test]
664    fn context_from_attestation_has_no_capabilities_or_role() {
665        // The attestation no longer carries caps/role at all — credential-grade
666        // authority comes only from `context_from_credential` (a holder-verified
667        // ACDC presentation), org role from `context_from_delegated_member`.
668        let att = make_attestation("did:keri:ETest", None, None);
669        let now = Utc::now();
670        let ctx = context_from_attestation(&att, now).unwrap();
671
672        assert!(
673            ctx.capabilities.is_empty(),
674            "attestation caps must not enter the policy context"
675        );
676        assert_eq!(
677            ctx.role, None,
678            "attestation role must not enter the policy context"
679        );
680    }
681
682    #[test]
683    fn caps_absent_without_valid_credential() {
684        // Fail-closed: with no holder-verified credential, a capability-gated policy
685        // denies — authority can only arrive via a credential, never the attestation.
686        let att = make_attestation("did:keri:ETestPrefix", None, None);
687        let policy = PolicyBuilder::new()
688            .not_revoked()
689            .require_capability("sign_commit")
690            .build();
691        let now = Utc::now();
692
693        let decision = evaluate_compiled(&att, &policy, now).unwrap();
694        assert_eq!(decision.outcome, Outcome::Deny);
695        assert_eq!(decision.reason, ReasonCode::CapabilityMissing);
696    }
697
698    // =========================================================================
699    // F.6 — context_from_credential holder-proof bridge
700    // =========================================================================
701
702    const CRED_ISSUER: &str = "did:keri:EIssuerCredential";
703    const CRED_SUBJECT: &str = "did:keri:ESubjectCredential";
704
705    /// A holder-verified `Valid` presentation carrying the given grant facts.
706    fn valid_presentation(
707        caps: &[&str],
708        role: Option<&str>,
709        expires_at: Option<DateTime<Utc>>,
710    ) -> PresentationVerdict {
711        PresentationVerdict::Valid {
712            issuer: auths_verifier::IdentityDID::parse(CRED_ISSUER).expect("valid test issuer"),
713            subject: auths_verifier::CanonicalDid::parse(CRED_SUBJECT).expect("valid test subject"),
714            caps: caps
715                .iter()
716                .map(|c| auths_verifier::Capability::parse(c).expect("valid test capability"))
717                .collect(),
718            role: role.map(str::to_string),
719            expires_at,
720        }
721    }
722
723    #[test]
724    fn policy_reads_caps_from_credential() {
725        let presentation = valid_presentation(&["sign_commit"], Some("deployer"), None);
726        let now = Utc::now();
727
728        let ctx = context_from_credential(&presentation, now).unwrap();
729        assert_eq!(ctx.issuer.as_str(), CRED_ISSUER);
730        assert_eq!(ctx.subject.as_str(), CRED_SUBJECT);
731        assert!(!ctx.revoked);
732        assert_eq!(ctx.capabilities.len(), 1);
733        assert_eq!(ctx.capabilities[0].as_str(), "sign_commit");
734        assert_eq!(ctx.role.as_deref(), Some("deployer"));
735
736        let policy = PolicyBuilder::new()
737            .not_revoked()
738            .require_capability("sign_commit")
739            .build();
740        assert_eq!(evaluate_strict(&policy, &ctx).outcome, Outcome::Allow);
741    }
742
743    #[test]
744    fn raw_acdc_without_presentation_yields_no_authority() {
745        // The bridge takes a verdict, not an ACDC: a non-`Valid` verdict (here a possessed
746        // credential that failed the holder-proof gate) yields NO authority-bearing
747        // context. Mere possession of a raw ACDC cannot even be passed in.
748        let now = Utc::now();
749        for verdict in [
750            PresentationVerdict::HolderNotCurrentKey,
751            PresentationVerdict::WrongAudience,
752            PresentationVerdict::NonceMismatchOrConsumed,
753            PresentationVerdict::Expired,
754            PresentationVerdict::SubjectKelInvalid,
755            PresentationVerdict::CredentialNotValid(
756                auths_verifier::CredentialVerdict::SaidMismatch,
757            ),
758        ] {
759            let result = context_from_credential(&verdict, now);
760            assert!(
761                matches!(result, Err(PolicyBridgeError::NoHolderProof)),
762                "non-Valid verdict {verdict:?} must fail closed, got {result:?}"
763            );
764        }
765    }
766
767    #[test]
768    fn capability_round_trips_into_acdc() {
769        use auths_keri::{AgentScope, decode_agent_scope, encode_agent_scope};
770        use auths_verifier::Capability;
771
772        // A capability that exercises the `:`-allowed constraint (forbidden: `,`).
773        let raw = "repo:foo-bar_baz";
774
775        // 1. Legacy agentscope: CSV seal → decode back → unchanged.
776        let scope = AgentScope {
777            capabilities: vec![raw.to_string()],
778            expires_at: Some(99),
779        };
780        let encoded = encode_agent_scope("Eagent", &scope);
781        let (prefix, decoded) = decode_agent_scope(&encoded).unwrap();
782        assert_eq!(prefix, "Eagent");
783        assert_eq!(decoded.capabilities, vec![raw.to_string()]);
784
785        // 2. agentscope CSV → ACDC `a.capability` JSON (the F.4 `,`-join encoding).
786        let acdc_capability_json = decoded.capabilities.join(",");
787        assert!(
788            !acdc_capability_json.contains(','),
789            "single cap stays comma-free; the join separator must not appear inside a cap"
790        );
791
792        // 3. ACDC `a.capability` (split back on `,`) → CanonicalCapability::parse, lossless.
793        for cap in acdc_capability_json.split(',') {
794            let canonical = CanonicalCapability::parse(cap).unwrap();
795            assert_eq!(canonical.as_str(), raw);
796        }
797
798        // 4. The attestation `Capability` encoding round-trips through the same parse.
799        let att_cap = Capability::parse(raw).unwrap();
800        let canonical = CanonicalCapability::parse(&att_cap.to_string()).unwrap();
801        assert_eq!(canonical.as_str(), raw);
802
803        // The `,` separator is forbidden inside a single capability (CanonicalCapability rejects it).
804        assert!(CanonicalCapability::parse("a,b").is_err());
805    }
806
807    #[test]
808    fn agentscope_seal_vs_acdc_precedence_documented() {
809        // The ACDC is the authoritative caps/role source; the agentscope: seal is
810        // commit-time advisory. When both exist for one grant, the ACDC governs.
811        assert_eq!(CapsSource::governing(true, true), Some(CapsSource::Acdc));
812        assert_eq!(CapsSource::governing(false, true), Some(CapsSource::Acdc));
813        assert_eq!(
814            CapsSource::governing(true, false),
815            Some(CapsSource::AgentScopeSeal)
816        );
817        assert_eq!(CapsSource::governing(false, false), None);
818    }
819
820    #[test]
821    fn evaluate_compiled_allows_valid_attestation() {
822        let att = make_attestation("did:keri:ETestPrefix", None, None);
823        let policy = default_policy();
824        let now = Utc::now();
825
826        let decision = evaluate_compiled(&att, &policy, now).unwrap();
827        assert_eq!(decision.outcome, Outcome::Allow);
828    }
829
830    #[test]
831    fn evaluate_compiled_denies_revoked() {
832        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), 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::Deny);
838        assert_eq!(decision.reason, ReasonCode::Revoked);
839    }
840
841    #[test]
842    fn evaluate_compiled_denies_expired() {
843        let past = Utc::now() - Duration::hours(1);
844        let att = make_attestation("did:keri:ETestPrefix", None, Some(past));
845        let policy = default_policy();
846        let now = Utc::now();
847
848        let decision = evaluate_compiled(&att, &policy, now).unwrap();
849        assert_eq!(decision.outcome, Outcome::Deny);
850        assert_eq!(decision.reason, ReasonCode::Expired);
851    }
852
853    #[test]
854    fn evaluate_compiled_allows_not_yet_expired() {
855        let future = Utc::now() + Duration::hours(1);
856        let att = make_attestation("did:keri:ETestPrefix", None, Some(future));
857        let policy = default_policy();
858        let now = Utc::now();
859
860        let decision = evaluate_compiled(&att, &policy, now).unwrap();
861        assert_eq!(decision.outcome, Outcome::Allow);
862    }
863
864    #[test]
865    fn evaluate_compiled_denies_issuer_mismatch() {
866        let att = make_attestation("did:keri:EWrongPrefix", None, None);
867        let policy = PolicyBuilder::new()
868            .not_revoked()
869            .require_issuer("did:keri:ETestPrefix")
870            .build();
871        let now = Utc::now();
872
873        let decision = evaluate_compiled(&att, &policy, now).unwrap();
874        assert_eq!(decision.outcome, Outcome::Deny);
875        assert_eq!(decision.reason, ReasonCode::IssuerMismatch);
876    }
877
878    #[test]
879    fn evaluate_compiled_denies_missing_capability() {
880        let att = make_attestation("did:keri:ETestPrefix", None, None);
881        let policy = PolicyBuilder::new()
882            .not_revoked()
883            .require_capability("sign_commit")
884            .build();
885        let now = Utc::now();
886
887        let decision = evaluate_compiled(&att, &policy, now).unwrap();
888        assert_eq!(decision.outcome, Outcome::Deny);
889        assert_eq!(decision.reason, ReasonCode::CapabilityMissing);
890    }
891
892    #[test]
893    fn evaluate_compiled_allows_with_capability_from_credential() {
894        // Capability authority now arrives only via a holder-verified credential
895        // presentation: the same require-capability policy that an attestation can no
896        // longer satisfy is allowed once the credential context supplies the cap.
897        let presentation = valid_presentation(&["sign_commit"], None, None);
898        let policy = PolicyBuilder::new()
899            .not_revoked()
900            .require_capability("sign_commit")
901            .build();
902        let now = Utc::now();
903
904        let ctx = context_from_credential(&presentation, now).unwrap();
905        assert_eq!(evaluate_strict(&policy, &ctx).outcome, Outcome::Allow);
906    }
907
908    #[test]
909    fn evaluate_compiled_is_deterministic() {
910        let att = make_attestation("did:keri:ETestPrefix", None, None);
911        let policy = default_policy();
912        let now = Utc::now();
913
914        let decision1 = evaluate_compiled(&att, &policy, now).unwrap();
915        let decision2 = evaluate_compiled(&att, &policy, now).unwrap();
916
917        assert_eq!(decision1, decision2);
918    }
919
920    // =========================================================================
921    // Tests for evaluate_with_witness
922    // =========================================================================
923
924    #[test]
925    fn evaluate_with_witness_no_witnesses_delegates() {
926        let identity = make_key_state("ETestPrefix");
927        let att = make_attestation("did:keri:ETestPrefix", None, None);
928        let policy = default_policy();
929        let now = Utc::now();
930        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
931
932        let decision =
933            evaluate_with_witness(&identity, &att, &policy, now, local_head, &[]).unwrap();
934
935        assert_eq!(decision.outcome, Outcome::Allow);
936    }
937
938    #[test]
939    fn evaluate_with_witness_noop_delegates() {
940        let identity = make_key_state("ETestPrefix");
941        let att = make_attestation("did:keri:ETestPrefix", None, None);
942        let policy = default_policy();
943        let now = Utc::now();
944        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
945
946        let noop = NoOpWitness;
947        let witnesses: &[&dyn WitnessProvider] = &[&noop];
948
949        let decision =
950            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
951
952        assert_eq!(decision.outcome, Outcome::Allow);
953    }
954
955    #[test]
956    fn evaluate_with_witness_mismatch_denies() {
957        let identity = make_key_state("ETestPrefix");
958        let att = make_attestation("did:keri:ETestPrefix", None, None);
959        let policy = default_policy();
960        let now = Utc::now();
961        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
962        let different_head =
963            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
964
965        let witness = MockWitness {
966            head: Some(different_head),
967            quorum: 1,
968        };
969        let witnesses: &[&dyn WitnessProvider] = &[&witness];
970
971        let decision =
972            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
973
974        assert_eq!(decision.outcome, Outcome::Deny);
975        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
976    }
977
978    #[test]
979    fn evaluate_with_witness_quorum_met_allows() {
980        let identity = make_key_state("ETestPrefix");
981        let att = make_attestation("did:keri:ETestPrefix", None, None);
982        let policy = default_policy();
983        let now = Utc::now();
984        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
985
986        let witness = MockWitness {
987            head: Some(local_head),
988            quorum: 1,
989        };
990        let witnesses: &[&dyn WitnessProvider] = &[&witness];
991
992        let decision =
993            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
994
995        assert_eq!(decision.outcome, Outcome::Allow);
996    }
997
998    #[test]
999    fn evaluate_with_witness_quorum_met_denies_when_policy_denies() {
1000        let identity = make_key_state("ETestPrefix");
1001        let att = make_attestation("did:keri:ETestPrefix", Some(Utc::now()), None); // revoked
1002        let policy = default_policy();
1003        let now = Utc::now();
1004        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1005
1006        let witness = MockWitness {
1007            head: Some(local_head),
1008            quorum: 1,
1009        };
1010        let witnesses: &[&dyn WitnessProvider] = &[&witness];
1011
1012        let decision =
1013            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1014
1015        assert_eq!(decision.outcome, Outcome::Deny);
1016        assert_eq!(decision.reason, ReasonCode::Revoked);
1017    }
1018
1019    #[test]
1020    fn evaluate_with_witness_multiple_witnesses_quorum() {
1021        let identity = make_key_state("ETestPrefix");
1022        let att = make_attestation("did:keri:ETestPrefix", None, None);
1023        let policy = default_policy();
1024        let now = Utc::now();
1025        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1026        let different_head =
1027            EventHash::from_hex("0000000000000000000000000000000000000002").unwrap();
1028
1029        let w1 = MockWitness {
1030            head: Some(local_head),
1031            quorum: 2,
1032        };
1033        let w2 = MockWitness {
1034            head: Some(local_head),
1035            quorum: 2,
1036        };
1037        let w3 = MockWitness {
1038            head: Some(different_head),
1039            quorum: 2,
1040        };
1041        let witnesses: &[&dyn WitnessProvider] = &[&w1, &w2, &w3];
1042
1043        let decision =
1044            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1045
1046        assert_eq!(decision.outcome, Outcome::Allow);
1047    }
1048
1049    #[test]
1050    fn evaluate_with_witness_no_opinions_delegates() {
1051        let identity = make_key_state("ETestPrefix");
1052        let att = make_attestation("did:keri:ETestPrefix", None, None);
1053        let policy = default_policy();
1054        let now = Utc::now();
1055        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1056
1057        let witness = MockWitness {
1058            head: None,
1059            quorum: 1,
1060        };
1061        let witnesses: &[&dyn WitnessProvider] = &[&witness];
1062
1063        let decision =
1064            evaluate_with_witness(&identity, &att, &policy, now, local_head, witnesses).unwrap();
1065
1066        assert_eq!(decision.outcome, Outcome::Allow);
1067    }
1068
1069    // =========================================================================
1070    // Tests for receipt verification
1071    // =========================================================================
1072
1073    fn make_test_receipt(
1074        event_said: &str,
1075        witness_did: &str,
1076        seq: u128,
1077    ) -> auths_core::witness::StoredReceipt {
1078        auths_core::witness::StoredReceipt {
1079            signed: auths_core::witness::SignedReceipt {
1080                receipt: auths_core::witness::Receipt {
1081                    v: auths_keri::VersionString::placeholder(),
1082                    t: auths_core::witness::ReceiptTag,
1083                    d: Said::new_unchecked(event_said.to_string()),
1084                    i: Prefix::new_unchecked("EController".to_string()),
1085                    s: auths_keri::KeriSequence::new(seq),
1086                },
1087                signature: vec![],
1088            },
1089            witness: Prefix::new_unchecked(witness_did.to_string()),
1090        }
1091    }
1092
1093    #[test]
1094    fn verify_receipts_meets_threshold() {
1095        let receipts = EventReceipts::new(
1096            "ESAID123",
1097            vec![
1098                make_test_receipt("ESAID123", "did:key:w1", 0),
1099                make_test_receipt("ESAID123", "did:key:w2", 0),
1100            ],
1101        );
1102
1103        let result = verify_receipts(&receipts, 2, None);
1104        assert_eq!(result, ReceiptVerificationResult::Valid);
1105    }
1106
1107    #[test]
1108    fn verify_receipts_insufficient() {
1109        let receipts = EventReceipts::new(
1110            "ESAID123",
1111            vec![make_test_receipt("ESAID123", "did:key:w1", 0)],
1112        );
1113
1114        let result = verify_receipts(&receipts, 2, None);
1115        assert!(matches!(
1116            result,
1117            ReceiptVerificationResult::InsufficientReceipts {
1118                required: 2,
1119                got: 1
1120            }
1121        ));
1122    }
1123
1124    #[test]
1125    fn verify_receipts_duplicity() {
1126        let receipts = EventReceipts {
1127            event_said: Said::new_unchecked("ESAID_A".to_string()),
1128            receipts: vec![
1129                make_test_receipt("ESAID_A", "did:key:w1", 0),
1130                make_test_receipt("ESAID_B", "did:key:w2", 0), // Different SAID!
1131            ],
1132        };
1133
1134        let result = verify_receipts(&receipts, 1, None);
1135        assert!(matches!(
1136            result,
1137            ReceiptVerificationResult::Duplicity { .. }
1138        ));
1139    }
1140
1141    #[test]
1142    fn evaluate_with_receipts_valid() {
1143        let identity = make_key_state("ETestPrefix");
1144        let att = make_attestation("did:keri:ETestPrefix", None, None);
1145        let policy = default_policy();
1146        let now = Utc::now();
1147        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1148        let receipts = EventReceipts::new(
1149            "ESAID",
1150            vec![
1151                make_test_receipt("ESAID", "did:key:w1", 0),
1152                make_test_receipt("ESAID", "did:key:w2", 0),
1153            ],
1154        );
1155
1156        let decision = evaluate_with_receipts(
1157            &identity,
1158            &att,
1159            &policy,
1160            now,
1161            local_head,
1162            &[],
1163            &receipts,
1164            2,
1165            None,
1166        )
1167        .unwrap();
1168
1169        assert_eq!(decision.outcome, Outcome::Allow);
1170    }
1171
1172    #[test]
1173    fn evaluate_with_receipts_insufficient_denies() {
1174        let identity = make_key_state("ETestPrefix");
1175        let att = make_attestation("did:keri:ETestPrefix", None, None);
1176        let policy = default_policy();
1177        let now = Utc::now();
1178        let local_head = EventHash::from_hex("0000000000000000000000000000000000000001").unwrap();
1179        let receipts =
1180            EventReceipts::new("ESAID", vec![make_test_receipt("ESAID", "did:key:w1", 0)]);
1181
1182        let decision = evaluate_with_receipts(
1183            &identity,
1184            &att,
1185            &policy,
1186            now,
1187            local_head,
1188            &[],
1189            &receipts,
1190            2, // Threshold 2, but only 1 receipt
1191            None,
1192        )
1193        .unwrap();
1194
1195        assert_eq!(decision.outcome, Outcome::Deny);
1196        assert_eq!(decision.reason, ReasonCode::WitnessQuorumNotMet);
1197    }
1198}