Skip to main content

av_harness/
authz.rs

1//! Intent mapping, mission narrowing, and AuthZEN PDP (pillar 2).
2//!
3//! Maps tool names to business intents, enforces mission constraints,
4//! and issues short-lived signed intent tokens per call.
5
6use crate::config::{HarnessConfig, MissionConfig};
7use av_sandbox::DenialCode;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12/// Ed25519 JWT signer for exchange tokens and intent tokens.
13pub struct TokenSigner {
14    key: jsonwebtoken::EncodingKey,
15    kid: String,
16    public_key: [u8; 32],
17}
18
19impl TokenSigner {
20    /// Build from a 32-byte Ed25519 seed. Derives the kid from the
21    /// public key using the same scheme as the receipt signer.
22    pub fn from_seed(seed: &[u8; 32]) -> Self {
23        use av_receipts::Signer as _;
24        let receipt_signer = av_receipts::Ed25519Signer::from_seed(seed);
25        let kid = receipt_signer.key_id().to_owned();
26        let public_key = receipt_signer.public_key_bytes();
27        let pkcs8 = ed25519_seed_to_pkcs8(seed);
28        let key = jsonwebtoken::EncodingKey::from_ed_der(&pkcs8);
29        Self { key, kid, public_key }
30    }
31
32    /// Sign claims as an EdDSA JWT with the configured kid.
33    pub fn sign<T: Serialize>(&self, claims: &T) -> Result<String, String> {
34        self.sign_with_typ(claims, "JWT")
35    }
36
37    /// Sign with an explicit JWT `typ` header (RFC 8725 §3.11).
38    pub fn sign_with_typ<T: Serialize>(&self, claims: &T, typ: &str) -> Result<String, String> {
39        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
40        header.kid = Some(self.kid.clone());
41        header.typ = Some(typ.to_owned());
42        jsonwebtoken::encode(&header, claims, &self.key).map_err(|e| e.to_string())
43    }
44
45    /// The key id (first 32 hex chars of SHA-256 of the public key).
46    pub fn kid(&self) -> &str {
47        &self.kid
48    }
49
50    /// The raw Ed25519 public key, base64url without padding (a JWK `x`).
51    pub fn public_key_b64url(&self) -> String {
52        use base64::Engine as _;
53        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(self.public_key)
54    }
55
56    /// Public verification key for JWTs minted by this signer.
57    pub fn decoding_key(&self) -> jsonwebtoken::DecodingKey {
58        jsonwebtoken::DecodingKey::from_ed_der(&self.public_key)
59    }
60
61    /// Return a JWKS document containing this signer's public key,
62    /// suitable for serving at `/.well-known/jwks.json` so backends
63    /// can verify signed tokens.
64    pub fn jwks_json(&self) -> serde_json::Value {
65        use base64::Engine as _;
66        let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(self.public_key);
67        serde_json::json!({
68            "keys": [{
69                "kty": "OKP",
70                "crv": "Ed25519",
71                "x": x,
72                "kid": self.kid,
73                "alg": "EdDSA",
74                "use": "sig",
75            }]
76        })
77    }
78}
79
80/// Encode a raw 32-byte Ed25519 seed as a PKCS8v1 DER private key
81/// (the format `jsonwebtoken::EncodingKey::from_ed_der` expects).
82fn ed25519_seed_to_pkcs8(seed: &[u8; 32]) -> Vec<u8> {
83    let mut der = Vec::with_capacity(48);
84    der.extend_from_slice(&[0x30, 0x2e]);
85    der.extend_from_slice(&[0x02, 0x01, 0x00]);
86    der.extend_from_slice(&[0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70]);
87    der.extend_from_slice(&[0x04, 0x22, 0x04, 0x20]);
88    der.extend_from_slice(seed);
89    der
90}
91
92/// Result of an authorization decision.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum AuthzDecision {
95    /// The call is permitted under the resolved business intent.
96    Permit {
97        /// The resolved business intent for this tool call.
98        intent: String,
99    },
100    /// The call is denied with a structured code and reason.
101    Deny {
102        /// Machine-readable denial code.
103        code: DenialCode,
104        /// Human-readable explanation.
105        reason: String,
106    },
107}
108
109/// AuthZEN Access Evaluation request
110/// ([Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)).
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AuthzEvalRequest {
113    /// The principal being evaluated.
114    pub subject: AuthzSubject,
115    /// The action being requested.
116    pub action: AuthzAction,
117    /// The resource being acted upon.
118    pub resource: AuthzResource,
119    /// Environment of the request (session, missions, time).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub context: Option<serde_json::Value>,
122}
123
124/// Subject of an authorization evaluation.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AuthzSubject {
127    /// Subject identifier (the human principal, e.g. `user:alice@corp.com`).
128    pub id: String,
129    /// Subject type (e.g. `user`, `agent`).
130    #[serde(rename = "type")]
131    pub subject_type: String,
132    /// Additional subject properties (the acting agent, scopes, issuer).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub properties: Option<serde_json::Value>,
135}
136
137/// Action being evaluated.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AuthzAction {
140    /// Action name (the resolved business intent).
141    pub name: String,
142    /// Additional action properties.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub properties: Option<serde_json::Value>,
145}
146
147/// Resource being acted upon.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct AuthzResource {
150    /// Resource identifier (typically the tool name).
151    pub id: String,
152    /// Resource type (e.g. `tool`).
153    #[serde(rename = "type")]
154    pub resource_type: String,
155    /// Additional resource properties (the backend serving the tool).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub properties: Option<serde_json::Value>,
158}
159
160/// AuthZEN evaluation response.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AuthzEvalResponse {
163    /// `true` if the action is permitted.
164    pub decision: bool,
165    /// Additional context for the decision. Its shape is PDP-specific.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub context: Option<serde_json::Value>,
168}
169
170/// JWT `typ` of intent tokens. A distinct explicit type (RFC 8725 §3.11)
171/// keeps a backend from accepting an intent token as an access token: both
172/// are signed by the same exchange key and carry the backend as `aud`.
173pub const INTENT_TOKEN_TYP: &str = "av-intent+jwt";
174
175/// The acting agent inside an intent token (RFC 8693 `act` shape).
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct IntentActor {
178    /// The calling agent's `instance_uid`.
179    pub sub: String,
180}
181
182/// Claims inside a signed intent token.
183#[derive(Debug, Serialize, Deserialize)]
184#[allow(missing_docs)]
185pub struct IntentClaims {
186    pub iss: String,
187    pub aud: String,
188    /// The human principal the call is attributable to (the caller token's
189    /// `sub`), or the caller's instance id when no identity was validated.
190    pub sub: String,
191    /// The calling agent, when an identity was validated.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub act: Option<IntentActor>,
194    pub tool: String,
195    pub intent: String,
196    /// Ids of the missions that constrained this call.
197    #[serde(default, skip_serializing_if = "Vec::is_empty")]
198    pub missions: Vec<String>,
199    pub iat: u64,
200    pub exp: u64,
201    pub jti: String,
202}
203
204/// What the policy decision point knows about the caller.
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct CallerFacts<'a> {
207    /// The calling agent's `instance_uid` (`anonymous` without identity).
208    pub instance_uid: &'a str,
209    /// The calling agent's charter.
210    pub charter: &'a str,
211    /// The human principal (`sub`), when an identity was validated.
212    pub subject: Option<&'a str>,
213}
214
215impl CallerFacts<'static> {
216    /// Facts for a request without a validated identity.
217    pub const ANONYMOUS: Self = Self {
218        instance_uid: "anonymous",
219        charter: "anonymous",
220        subject: None,
221    };
222}
223
224/// The local policy decision point. Evaluates tool calls against the
225/// intent map, the global mission, and every per-agent mission that
226/// selects the caller.
227pub struct PolicyDecisionPoint {
228    intent_map: BTreeMap<String, String>,
229    require_mapping: bool,
230    mission: Option<MissionConfig>,
231    missions: Vec<MissionConfig>,
232    intent_token_ttl_s: u64,
233    issuer: String,
234    signer: Option<Arc<TokenSigner>>,
235}
236
237impl PolicyDecisionPoint {
238    /// Build from harness config with an optional token signer.
239    pub fn from_config(config: &HarnessConfig, signer: Option<Arc<TokenSigner>>) -> Self {
240        Self {
241            intent_map: config.intent_map.clone(),
242            require_mapping: config.require_intent_mapping,
243            mission: config.mission.clone(),
244            missions: config.missions.clone(),
245            intent_token_ttl_s: config.intent_token_ttl_s,
246            issuer: config.audience.clone(),
247            signer,
248        }
249    }
250
251    /// Every mission that constrains `caller`: the global mission, then
252    /// each `[[missions]]` entry whose selectors match.
253    pub fn applicable_missions<'s>(&'s self, caller: &CallerFacts<'_>) -> Vec<&'s MissionConfig> {
254        self.mission
255            .iter()
256            .chain(
257                self.missions
258                    .iter()
259                    .filter(|mission| mission.selects(caller.instance_uid, caller.charter, caller.subject)),
260            )
261            .collect()
262    }
263
264    /// Decide whether `tool` may run at `now_s` for a caller without a
265    /// validated identity. Signs nothing.
266    pub fn decide(&self, tool: &str, now_s: u64) -> AuthzDecision {
267        self.decide_for(tool, now_s, &CallerFacts::ANONYMOUS)
268    }
269
270    /// Decide whether `tool` may run at `now_s` for `caller`. Signs
271    /// nothing, so the pipeline can run it before the budget is charged.
272    /// Missions only narrow: every applicable mission must allow the call.
273    pub fn decide_for(&self, tool: &str, now_s: u64, caller: &CallerFacts<'_>) -> AuthzDecision {
274        // Step 1: resolve intent from tool name.
275        let intent = match self.intent_map.get(tool) {
276            Some(intent) => intent.clone(),
277            None => {
278                if self.require_mapping {
279                    return AuthzDecision::Deny {
280                        code: DenialCode::UnmappedTool,
281                        reason: format!("tool {tool:?} has no configured intent mapping"),
282                    };
283                }
284                tool.to_owned()
285            }
286        };
287
288        // Step 2: check every applicable mission.
289        for mission in self.applicable_missions(caller) {
290            if now_s >= mission.expires_at {
291                return AuthzDecision::Deny {
292                    code: DenialCode::MissionExpired,
293                    reason: format!(
294                        "mission {:?} expired at {} (now {})",
295                        mission.id, mission.expires_at, now_s
296                    ),
297                };
298            }
299            if !mission.allowed_intents.iter().any(|ai| ai == &intent) {
300                return AuthzDecision::Deny {
301                    code: DenialCode::MissionDenied,
302                    reason: format!("intent {intent:?} not permitted by mission {:?}", mission.id),
303                };
304            }
305        }
306
307        AuthzDecision::Permit { intent }
308    }
309
310    /// The business intent `tool` maps to: its `intent_map` entry, or the
311    /// tool name itself. A plain lookup that never denies.
312    pub fn intent_for(&self, tool: &str) -> String {
313        self.intent_map
314            .get(tool)
315            .cloned()
316            .unwrap_or_else(|| tool.to_owned())
317    }
318
319    /// Sign a short-lived intent token for a permitted call, bound to the
320    /// backend that receives it. `sub` is the human principal and `act.sub`
321    /// the calling agent; the token never outlives an applicable mission.
322    /// `None` when no signer is configured.
323    pub fn mint_intent_token(
324        &self,
325        tool: &str,
326        intent: &str,
327        caller: &CallerFacts<'_>,
328        audience: &str,
329        now_s: u64,
330    ) -> Option<String> {
331        let signer = self.signer.as_ref()?;
332        let missions = self.applicable_missions(caller);
333        let exp = missions
334            .iter()
335            .map(|mission| mission.expires_at)
336            .fold(now_s.saturating_add(self.intent_token_ttl_s), u64::min);
337        let claims = IntentClaims {
338            iss: self.issuer.clone(),
339            aud: audience.to_owned(),
340            sub: caller.subject.unwrap_or(caller.instance_uid).to_owned(),
341            act: caller.subject.map(|_| IntentActor {
342                sub: caller.instance_uid.to_owned(),
343            }),
344            tool: tool.to_owned(),
345            intent: intent.to_owned(),
346            missions: missions.iter().map(|mission| mission.id.clone()).collect(),
347            iat: now_s,
348            exp,
349            jti: av_core::ids::new_event_uid(),
350        };
351        match signer.sign_with_typ(&claims, INTENT_TOKEN_TYP) {
352            Ok(jwt) => Some(jwt),
353            Err(e) => {
354                tracing::error!(error = %e, "intent token signing failed");
355                None
356            }
357        }
358    }
359
360    /// The configured intent token TTL.
361    pub fn intent_token_ttl_s(&self) -> u64 {
362        self.intent_token_ttl_s
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    #![allow(
369        clippy::unwrap_used,
370        clippy::expect_used,
371        clippy::panic,
372        clippy::indexing_slicing
373    )]
374
375    use super::*;
376    use crate::config::MissionConfig;
377
378    fn pdp(intents: &[(&str, &str)], require: bool, mission: Option<MissionConfig>) -> PolicyDecisionPoint {
379        PolicyDecisionPoint {
380            intent_map: intents
381                .iter()
382                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
383                .collect(),
384            require_mapping: require,
385            mission,
386            missions: Vec::new(),
387            intent_token_ttl_s: 60,
388            issuer: "agentvisor-ai".into(),
389            signer: None,
390        }
391    }
392
393    fn facts() -> CallerFacts<'static> {
394        CallerFacts {
395            instance_uid: "inst-1",
396            charter: "support",
397            subject: Some("user:alice@corp.com"),
398        }
399    }
400
401    fn agent_mission(id: &str, intents: &[&str], expires_at: u64) -> MissionConfig {
402        MissionConfig {
403            id: id.into(),
404            allowed_intents: intents.iter().map(|intent| (*intent).to_owned()).collect(),
405            expires_at,
406            agents: vec!["inst-1".into()],
407            charters: Vec::new(),
408            subjects: Vec::new(),
409        }
410    }
411
412    #[test]
413    fn per_agent_missions_narrow_only_the_agents_they_select() {
414        let mut p = pdp(
415            &[("db_write", "data.mutate"), ("search", "data.read")],
416            false,
417            None,
418        );
419        p.missions = vec![agent_mission("read-only", &["data.read"], 5000)];
420        let selected = facts();
421        let other = CallerFacts {
422            instance_uid: "inst-2",
423            ..facts()
424        };
425        assert!(matches!(
426            p.decide_for("db_write", 1000, &selected),
427            AuthzDecision::Deny {
428                code: DenialCode::MissionDenied,
429                ..
430            }
431        ));
432        assert!(matches!(
433            p.decide_for("search", 1000, &selected),
434            AuthzDecision::Permit { .. }
435        ));
436        assert!(
437            matches!(
438                p.decide_for("db_write", 1000, &other),
439                AuthzDecision::Permit { .. }
440            ),
441            "an unselected agent is not narrowed by another agent's mission"
442        );
443        assert!(
444            matches!(p.decide("db_write", 1000), AuthzDecision::Permit { .. }),
445            "an anonymous caller matches no per-agent mission"
446        );
447    }
448
449    #[test]
450    fn global_and_per_agent_missions_must_both_allow() {
451        let global = MissionConfig {
452            id: "global".into(),
453            allowed_intents: vec!["data.read".into(), "data.mutate".into()],
454            expires_at: 5000,
455            agents: Vec::new(),
456            charters: Vec::new(),
457            subjects: Vec::new(),
458        };
459        let mut p = pdp(
460            &[("db_write", "data.mutate"), ("search", "data.read")],
461            false,
462            Some(global),
463        );
464        p.missions = vec![agent_mission("expiring", &["data.read"], 1500)];
465        assert!(matches!(
466            p.decide_for("search", 1000, &facts()),
467            AuthzDecision::Permit { .. }
468        ));
469        assert!(matches!(
470            p.decide_for("search", 1500, &facts()),
471            AuthzDecision::Deny {
472                code: DenialCode::MissionExpired,
473                ..
474            }
475        ));
476        let signer = Arc::new(TokenSigner::from_seed(&[93; 32]));
477        p.signer = Some(Arc::clone(&signer));
478        let token = p
479            .mint_intent_token("search", "data.read", &facts(), "db", 1000)
480            .unwrap();
481        let claims = verify_intent_token(&signer, &token).claims;
482        assert_eq!(claims.missions, vec!["global".to_owned(), "expiring".to_owned()]);
483        assert_eq!(
484            claims.exp, 1060,
485            "min(now + ttl, every applicable mission expiry)"
486        );
487    }
488
489    #[test]
490    fn mission_selectors_require_every_set_selector() {
491        let mission = MissionConfig {
492            id: "m".into(),
493            allowed_intents: vec!["x".into()],
494            expires_at: 1,
495            agents: Vec::new(),
496            charters: vec!["support".into()],
497            subjects: vec!["user:alice@corp.com".into()],
498        };
499        assert!(mission.selects("any", "support", Some("user:alice@corp.com")));
500        assert!(!mission.selects("any", "support", Some("user:bob@corp.com")));
501        assert!(!mission.selects("any", "billing", Some("user:alice@corp.com")));
502        assert!(!mission.selects("any", "support", None));
503    }
504
505    fn verify_intent_token(signer: &TokenSigner, jwt: &str) -> jsonwebtoken::TokenData<IntentClaims> {
506        let dk = signer.decoding_key();
507        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
508        validation.validate_exp = false;
509        validation.validate_aud = false;
510        validation.required_spec_claims.clear();
511        jsonwebtoken::decode::<IntentClaims>(jwt, &dk, &validation).unwrap()
512    }
513
514    #[test]
515    fn mapped_tool_resolves_intent() {
516        let p = pdp(&[("db_write", "data.mutate")], false, None);
517        match p.decide("db_write", 1000) {
518            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "data.mutate"),
519            AuthzDecision::Deny { .. } => panic!("expected permit"),
520        }
521    }
522
523    #[test]
524    fn unmapped_tool_denied_when_required() {
525        let p = pdp(&[("db_write", "data.mutate")], true, None);
526        match p.decide("unknown_tool", 1000) {
527            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::UnmappedTool),
528            AuthzDecision::Permit { .. } => panic!("expected deny"),
529        }
530    }
531
532    #[test]
533    fn unmapped_tool_passes_when_not_required() {
534        let p = pdp(&[], false, None);
535        match p.decide("any_tool", 1000) {
536            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "any_tool"),
537            AuthzDecision::Deny { .. } => panic!("expected permit"),
538        }
539    }
540
541    #[test]
542    fn expired_mission_denied() {
543        let mission = MissionConfig {
544            id: "m-1".into(),
545            allowed_intents: vec!["data.mutate".into()],
546            expires_at: 500,
547            agents: Vec::new(),
548            charters: Vec::new(),
549            subjects: Vec::new(),
550        };
551        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
552        match p.decide("db_write", 1000) {
553            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::MissionExpired),
554            AuthzDecision::Permit { .. } => panic!("expected deny"),
555        }
556        assert!(matches!(p.decide("db_write", 499), AuthzDecision::Permit { .. }));
557        assert!(matches!(
558            p.decide("db_write", 500),
559            AuthzDecision::Deny {
560                code: DenialCode::MissionExpired,
561                ..
562            }
563        ));
564    }
565
566    #[test]
567    fn mission_denies_unlisted_intent() {
568        let mission = MissionConfig {
569            id: "m-2".into(),
570            allowed_intents: vec!["data.read".into()],
571            expires_at: 2000,
572            agents: Vec::new(),
573            charters: Vec::new(),
574            subjects: Vec::new(),
575        };
576        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
577        match p.decide("db_write", 1000) {
578            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::MissionDenied),
579            AuthzDecision::Permit { .. } => panic!("expected deny"),
580        }
581    }
582
583    #[test]
584    fn mission_permits_listed_intent() {
585        let mission = MissionConfig {
586            id: "m-3".into(),
587            allowed_intents: vec!["data.mutate".into()],
588            expires_at: 2000,
589            agents: Vec::new(),
590            charters: Vec::new(),
591            subjects: Vec::new(),
592        };
593        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
594        match p.decide("db_write", 1000) {
595            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "data.mutate"),
596            AuthzDecision::Deny { .. } => panic!("expected permit"),
597        }
598    }
599
600    #[test]
601    fn intent_token_cannot_outlive_its_mission() {
602        let mission = MissionConfig {
603            id: "m-ttl".into(),
604            allowed_intents: vec!["data.mutate".into()],
605            expires_at: 1001,
606            agents: Vec::new(),
607            charters: Vec::new(),
608            subjects: Vec::new(),
609        };
610        let mut policy = pdp(&[("db_write", "data.mutate")], false, Some(mission));
611        let signer = Arc::new(TokenSigner::from_seed(&[94; 32]));
612        policy.signer = Some(Arc::clone(&signer));
613        let token = policy
614            .mint_intent_token("db_write", "data.mutate", &CallerFacts::ANONYMOUS, "db", 1000)
615            .unwrap();
616        let claims = verify_intent_token(&signer, &token).claims;
617        assert_eq!(claims.iat, 1000);
618        assert_eq!(claims.exp, 1001);
619    }
620
621    #[test]
622    fn intent_token_none_without_signer() {
623        let p = pdp(&[("search", "data.read")], false, None);
624        assert!(
625            p.mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
626                .is_none(),
627            "no signer → no intent token"
628        );
629    }
630
631    #[test]
632    fn intent_token_is_typed_and_bound_to_issuer_audience_and_subject() {
633        let signer = Arc::new(TokenSigner::from_seed(&[99u8; 32]));
634        let mut p = pdp(&[("search", "data.read")], false, None);
635        p.signer = Some(Arc::clone(&signer));
636        let jwt = p
637            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
638            .expect("signer present → must produce JWT");
639        let header = jsonwebtoken::decode_header(&jwt).unwrap();
640        assert_eq!(header.alg, jsonwebtoken::Algorithm::EdDSA);
641        assert_eq!(header.typ.as_deref(), Some(INTENT_TOKEN_TYP));
642        assert_eq!(header.kid.as_deref(), Some(signer.kid()));
643        let decoded = verify_intent_token(&signer, &jwt);
644        assert_eq!(decoded.claims.iss, "agentvisor-ai");
645        assert_eq!(decoded.claims.aud, "backend-a");
646        assert_eq!(decoded.claims.sub, "user:alice@corp.com", "sub is the human");
647        assert_eq!(
648            decoded.claims.act,
649            Some(IntentActor { sub: "inst-1".into() }),
650            "act.sub is the calling agent"
651        );
652        assert_eq!(decoded.claims.tool, "search");
653        assert_eq!(decoded.claims.intent, "data.read");
654        assert_eq!(decoded.claims.iat, 1000);
655        assert_eq!(decoded.claims.exp - decoded.claims.iat, 60);
656        assert!(!decoded.claims.jti.is_empty());
657    }
658
659    #[test]
660    fn intent_tokens_carry_unique_jtis() {
661        let mut p = pdp(&[("search", "data.read")], false, None);
662        p.signer = Some(Arc::new(TokenSigner::from_seed(&[98u8; 32])));
663        let signer = p.signer.clone().unwrap();
664        let a = p
665            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
666            .unwrap();
667        let b = p
668            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
669            .unwrap();
670        assert_ne!(
671            verify_intent_token(&signer, &a).claims.jti,
672            verify_intent_token(&signer, &b).claims.jti,
673            "each intent token needs a distinct jti so backends can reject replays"
674        );
675    }
676
677    #[test]
678    fn intent_for_is_the_mapping_or_the_tool_name_and_never_denies() {
679        let p = pdp(&[("db_write", "data.mutate")], true, None);
680        assert_eq!(p.intent_for("db_write"), "data.mutate");
681        assert_eq!(p.intent_for("unmapped"), "unmapped");
682    }
683
684    #[test]
685    fn plain_sign_keeps_the_default_jwt_type() {
686        let signer = TokenSigner::from_seed(&[97u8; 32]);
687        let jwt = signer.sign(&serde_json::json!({"sub": "x"})).unwrap();
688        let header = jsonwebtoken::decode_header(&jwt).unwrap();
689        assert_eq!(header.typ.as_deref(), Some("JWT"));
690    }
691
692    #[test]
693    fn authz_eval_request_serializes() {
694        let req = AuthzEvalRequest {
695            subject: AuthzSubject {
696                id: "agent:billing".into(),
697                subject_type: "agent".into(),
698                properties: None,
699            },
700            action: AuthzAction {
701                name: "data.mutate".into(),
702                properties: None,
703            },
704            resource: AuthzResource {
705                id: "db_write".into(),
706                resource_type: "tool".into(),
707                properties: None,
708            },
709            context: None,
710        };
711        let json = serde_json::to_value(&req).unwrap();
712        assert_eq!(json["subject"]["id"], "agent:billing");
713        assert_eq!(json["action"]["name"], "data.mutate");
714    }
715}