Skip to main content

pv_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 pv_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 pv_receipts::Signer as _;
24        let receipt_signer = pv_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`.
173///
174/// Wire format: backends verify this `typ` (and `av-tool+jwt` for
175/// exchanged tokens); the `av` names predate ProofVisor. Never rename.
176pub const INTENT_TOKEN_TYP: &str = "av-intent+jwt";
177
178/// The acting agent inside an intent token (RFC 8693 `act` shape).
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct IntentActor {
181    /// The calling agent's `instance_uid`.
182    pub sub: String,
183}
184
185/// Claims inside a signed intent token.
186#[derive(Debug, Serialize, Deserialize)]
187#[allow(missing_docs)]
188pub struct IntentClaims {
189    pub iss: String,
190    pub aud: String,
191    /// The human principal the call is attributable to (the caller token's
192    /// `sub`), or the caller's instance id when no identity was validated.
193    pub sub: String,
194    /// The calling agent, when an identity was validated.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub act: Option<IntentActor>,
197    pub tool: String,
198    pub intent: String,
199    /// Ids of the missions that constrained this call.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub missions: Vec<String>,
202    pub iat: u64,
203    pub exp: u64,
204    pub jti: String,
205}
206
207/// What the policy decision point knows about the caller.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct CallerFacts<'a> {
210    /// The calling agent's `instance_uid` (`anonymous` without identity).
211    pub instance_uid: &'a str,
212    /// The calling agent's charter.
213    pub charter: &'a str,
214    /// The human principal (`sub`), when an identity was validated.
215    pub subject: Option<&'a str>,
216}
217
218impl CallerFacts<'static> {
219    /// Facts for a request without a validated identity.
220    pub const ANONYMOUS: Self = Self {
221        instance_uid: "anonymous",
222        charter: "anonymous",
223        subject: None,
224    };
225}
226
227/// The local policy decision point. Evaluates tool calls against the
228/// intent map, the global mission, and every per-agent mission that
229/// selects the caller.
230pub struct PolicyDecisionPoint {
231    intent_map: BTreeMap<String, String>,
232    require_mapping: bool,
233    mission: Option<MissionConfig>,
234    missions: Vec<MissionConfig>,
235    intent_token_ttl_s: u64,
236    issuer: String,
237    signer: Option<Arc<TokenSigner>>,
238}
239
240impl PolicyDecisionPoint {
241    /// Build from harness config with an optional token signer.
242    pub fn from_config(config: &HarnessConfig, signer: Option<Arc<TokenSigner>>) -> Self {
243        Self {
244            intent_map: config.intent_map.clone(),
245            require_mapping: config.require_intent_mapping,
246            mission: config.mission.clone(),
247            missions: config.missions.clone(),
248            intent_token_ttl_s: config.intent_token_ttl_s,
249            issuer: config.audience.clone(),
250            signer,
251        }
252    }
253
254    /// Every mission that constrains `caller`: the global mission, then
255    /// each `[[missions]]` entry whose selectors match.
256    pub fn applicable_missions<'s>(&'s self, caller: &CallerFacts<'_>) -> Vec<&'s MissionConfig> {
257        self.mission
258            .iter()
259            .chain(
260                self.missions
261                    .iter()
262                    .filter(|mission| mission.selects(caller.instance_uid, caller.charter, caller.subject)),
263            )
264            .collect()
265    }
266
267    /// Decide whether `tool` may run at `now_s` for a caller without a
268    /// validated identity. Signs nothing.
269    pub fn decide(&self, tool: &str, now_s: u64) -> AuthzDecision {
270        self.decide_for(tool, now_s, &CallerFacts::ANONYMOUS)
271    }
272
273    /// Decide whether `tool` may run at `now_s` for `caller`. Signs
274    /// nothing, so the pipeline can run it before the budget is charged.
275    /// Missions only narrow: every applicable mission must allow the call.
276    pub fn decide_for(&self, tool: &str, now_s: u64, caller: &CallerFacts<'_>) -> AuthzDecision {
277        // Step 1: resolve intent from tool name.
278        let intent = match self.intent_map.get(tool) {
279            Some(intent) => intent.clone(),
280            None => {
281                if self.require_mapping {
282                    return AuthzDecision::Deny {
283                        code: DenialCode::UnmappedTool,
284                        reason: format!("tool {tool:?} has no configured intent mapping"),
285                    };
286                }
287                tool.to_owned()
288            }
289        };
290
291        // Step 2: check every applicable mission.
292        for mission in self.applicable_missions(caller) {
293            if now_s >= mission.expires_at {
294                return AuthzDecision::Deny {
295                    code: DenialCode::MissionExpired,
296                    reason: format!(
297                        "mission {:?} expired at {} (now {})",
298                        mission.id, mission.expires_at, now_s
299                    ),
300                };
301            }
302            if !mission.allowed_intents.iter().any(|ai| ai == &intent) {
303                return AuthzDecision::Deny {
304                    code: DenialCode::MissionDenied,
305                    reason: format!("intent {intent:?} not permitted by mission {:?}", mission.id),
306                };
307            }
308        }
309
310        AuthzDecision::Permit { intent }
311    }
312
313    /// The business intent `tool` maps to: its `intent_map` entry, or the
314    /// tool name itself. A plain lookup that never denies.
315    pub fn intent_for(&self, tool: &str) -> String {
316        self.intent_map
317            .get(tool)
318            .cloned()
319            .unwrap_or_else(|| tool.to_owned())
320    }
321
322    /// Sign a short-lived intent token for a permitted call, bound to the
323    /// backend that receives it. `sub` is the human principal and `act.sub`
324    /// the calling agent; the token never outlives an applicable mission.
325    /// `None` when no signer is configured.
326    pub fn mint_intent_token(
327        &self,
328        tool: &str,
329        intent: &str,
330        caller: &CallerFacts<'_>,
331        audience: &str,
332        now_s: u64,
333    ) -> Option<String> {
334        let signer = self.signer.as_ref()?;
335        let missions = self.applicable_missions(caller);
336        let exp = missions
337            .iter()
338            .map(|mission| mission.expires_at)
339            .fold(now_s.saturating_add(self.intent_token_ttl_s), u64::min);
340        let claims = IntentClaims {
341            iss: self.issuer.clone(),
342            aud: audience.to_owned(),
343            sub: caller.subject.unwrap_or(caller.instance_uid).to_owned(),
344            act: caller.subject.map(|_| IntentActor {
345                sub: caller.instance_uid.to_owned(),
346            }),
347            tool: tool.to_owned(),
348            intent: intent.to_owned(),
349            missions: missions.iter().map(|mission| mission.id.clone()).collect(),
350            iat: now_s,
351            exp,
352            jti: pv_core::ids::new_event_uid(),
353        };
354        match signer.sign_with_typ(&claims, INTENT_TOKEN_TYP) {
355            Ok(jwt) => Some(jwt),
356            Err(e) => {
357                tracing::error!(error = %e, "intent token signing failed");
358                None
359            }
360        }
361    }
362
363    /// The configured intent token TTL.
364    pub fn intent_token_ttl_s(&self) -> u64 {
365        self.intent_token_ttl_s
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    #![allow(
372        clippy::unwrap_used,
373        clippy::expect_used,
374        clippy::panic,
375        clippy::indexing_slicing
376    )]
377
378    use super::*;
379    use crate::config::MissionConfig;
380
381    fn pdp(intents: &[(&str, &str)], require: bool, mission: Option<MissionConfig>) -> PolicyDecisionPoint {
382        PolicyDecisionPoint {
383            intent_map: intents
384                .iter()
385                .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
386                .collect(),
387            require_mapping: require,
388            mission,
389            missions: Vec::new(),
390            intent_token_ttl_s: 60,
391            issuer: "proofvisor".into(),
392            signer: None,
393        }
394    }
395
396    fn facts() -> CallerFacts<'static> {
397        CallerFacts {
398            instance_uid: "inst-1",
399            charter: "support",
400            subject: Some("user:alice@corp.com"),
401        }
402    }
403
404    fn agent_mission(id: &str, intents: &[&str], expires_at: u64) -> MissionConfig {
405        MissionConfig {
406            id: id.into(),
407            allowed_intents: intents.iter().map(|intent| (*intent).to_owned()).collect(),
408            expires_at,
409            agents: vec!["inst-1".into()],
410            charters: Vec::new(),
411            subjects: Vec::new(),
412        }
413    }
414
415    #[test]
416    fn per_agent_missions_narrow_only_the_agents_they_select() {
417        let mut p = pdp(
418            &[("db_write", "data.mutate"), ("search", "data.read")],
419            false,
420            None,
421        );
422        p.missions = vec![agent_mission("read-only", &["data.read"], 5000)];
423        let selected = facts();
424        let other = CallerFacts {
425            instance_uid: "inst-2",
426            ..facts()
427        };
428        assert!(matches!(
429            p.decide_for("db_write", 1000, &selected),
430            AuthzDecision::Deny {
431                code: DenialCode::MissionDenied,
432                ..
433            }
434        ));
435        assert!(matches!(
436            p.decide_for("search", 1000, &selected),
437            AuthzDecision::Permit { .. }
438        ));
439        assert!(
440            matches!(
441                p.decide_for("db_write", 1000, &other),
442                AuthzDecision::Permit { .. }
443            ),
444            "an unselected agent is not narrowed by another agent's mission"
445        );
446        assert!(
447            matches!(p.decide("db_write", 1000), AuthzDecision::Permit { .. }),
448            "an anonymous caller matches no per-agent mission"
449        );
450    }
451
452    #[test]
453    fn global_and_per_agent_missions_must_both_allow() {
454        let global = MissionConfig {
455            id: "global".into(),
456            allowed_intents: vec!["data.read".into(), "data.mutate".into()],
457            expires_at: 5000,
458            agents: Vec::new(),
459            charters: Vec::new(),
460            subjects: Vec::new(),
461        };
462        let mut p = pdp(
463            &[("db_write", "data.mutate"), ("search", "data.read")],
464            false,
465            Some(global),
466        );
467        p.missions = vec![agent_mission("expiring", &["data.read"], 1500)];
468        assert!(matches!(
469            p.decide_for("search", 1000, &facts()),
470            AuthzDecision::Permit { .. }
471        ));
472        assert!(matches!(
473            p.decide_for("search", 1500, &facts()),
474            AuthzDecision::Deny {
475                code: DenialCode::MissionExpired,
476                ..
477            }
478        ));
479        let signer = Arc::new(TokenSigner::from_seed(&[93; 32]));
480        p.signer = Some(Arc::clone(&signer));
481        let token = p
482            .mint_intent_token("search", "data.read", &facts(), "db", 1000)
483            .unwrap();
484        let claims = verify_intent_token(&signer, &token).claims;
485        assert_eq!(claims.missions, vec!["global".to_owned(), "expiring".to_owned()]);
486        assert_eq!(
487            claims.exp, 1060,
488            "min(now + ttl, every applicable mission expiry)"
489        );
490    }
491
492    #[test]
493    fn mission_selectors_require_every_set_selector() {
494        let mission = MissionConfig {
495            id: "m".into(),
496            allowed_intents: vec!["x".into()],
497            expires_at: 1,
498            agents: Vec::new(),
499            charters: vec!["support".into()],
500            subjects: vec!["user:alice@corp.com".into()],
501        };
502        assert!(mission.selects("any", "support", Some("user:alice@corp.com")));
503        assert!(!mission.selects("any", "support", Some("user:bob@corp.com")));
504        assert!(!mission.selects("any", "billing", Some("user:alice@corp.com")));
505        assert!(!mission.selects("any", "support", None));
506    }
507
508    fn verify_intent_token(signer: &TokenSigner, jwt: &str) -> jsonwebtoken::TokenData<IntentClaims> {
509        let dk = signer.decoding_key();
510        let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::EdDSA);
511        validation.validate_exp = false;
512        validation.validate_aud = false;
513        validation.required_spec_claims.clear();
514        jsonwebtoken::decode::<IntentClaims>(jwt, &dk, &validation).unwrap()
515    }
516
517    #[test]
518    fn mapped_tool_resolves_intent() {
519        let p = pdp(&[("db_write", "data.mutate")], false, None);
520        match p.decide("db_write", 1000) {
521            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "data.mutate"),
522            AuthzDecision::Deny { .. } => panic!("expected permit"),
523        }
524    }
525
526    #[test]
527    fn unmapped_tool_denied_when_required() {
528        let p = pdp(&[("db_write", "data.mutate")], true, None);
529        match p.decide("unknown_tool", 1000) {
530            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::UnmappedTool),
531            AuthzDecision::Permit { .. } => panic!("expected deny"),
532        }
533    }
534
535    #[test]
536    fn unmapped_tool_passes_when_not_required() {
537        let p = pdp(&[], false, None);
538        match p.decide("any_tool", 1000) {
539            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "any_tool"),
540            AuthzDecision::Deny { .. } => panic!("expected permit"),
541        }
542    }
543
544    #[test]
545    fn expired_mission_denied() {
546        let mission = MissionConfig {
547            id: "m-1".into(),
548            allowed_intents: vec!["data.mutate".into()],
549            expires_at: 500,
550            agents: Vec::new(),
551            charters: Vec::new(),
552            subjects: Vec::new(),
553        };
554        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
555        match p.decide("db_write", 1000) {
556            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::MissionExpired),
557            AuthzDecision::Permit { .. } => panic!("expected deny"),
558        }
559        assert!(matches!(p.decide("db_write", 499), AuthzDecision::Permit { .. }));
560        assert!(matches!(
561            p.decide("db_write", 500),
562            AuthzDecision::Deny {
563                code: DenialCode::MissionExpired,
564                ..
565            }
566        ));
567    }
568
569    #[test]
570    fn mission_denies_unlisted_intent() {
571        let mission = MissionConfig {
572            id: "m-2".into(),
573            allowed_intents: vec!["data.read".into()],
574            expires_at: 2000,
575            agents: Vec::new(),
576            charters: Vec::new(),
577            subjects: Vec::new(),
578        };
579        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
580        match p.decide("db_write", 1000) {
581            AuthzDecision::Deny { code, .. } => assert_eq!(code, DenialCode::MissionDenied),
582            AuthzDecision::Permit { .. } => panic!("expected deny"),
583        }
584    }
585
586    #[test]
587    fn mission_permits_listed_intent() {
588        let mission = MissionConfig {
589            id: "m-3".into(),
590            allowed_intents: vec!["data.mutate".into()],
591            expires_at: 2000,
592            agents: Vec::new(),
593            charters: Vec::new(),
594            subjects: Vec::new(),
595        };
596        let p = pdp(&[("db_write", "data.mutate")], false, Some(mission));
597        match p.decide("db_write", 1000) {
598            AuthzDecision::Permit { intent, .. } => assert_eq!(intent, "data.mutate"),
599            AuthzDecision::Deny { .. } => panic!("expected permit"),
600        }
601    }
602
603    #[test]
604    fn intent_token_cannot_outlive_its_mission() {
605        let mission = MissionConfig {
606            id: "m-ttl".into(),
607            allowed_intents: vec!["data.mutate".into()],
608            expires_at: 1001,
609            agents: Vec::new(),
610            charters: Vec::new(),
611            subjects: Vec::new(),
612        };
613        let mut policy = pdp(&[("db_write", "data.mutate")], false, Some(mission));
614        let signer = Arc::new(TokenSigner::from_seed(&[94; 32]));
615        policy.signer = Some(Arc::clone(&signer));
616        let token = policy
617            .mint_intent_token("db_write", "data.mutate", &CallerFacts::ANONYMOUS, "db", 1000)
618            .unwrap();
619        let claims = verify_intent_token(&signer, &token).claims;
620        assert_eq!(claims.iat, 1000);
621        assert_eq!(claims.exp, 1001);
622    }
623
624    #[test]
625    fn intent_token_none_without_signer() {
626        let p = pdp(&[("search", "data.read")], false, None);
627        assert!(
628            p.mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
629                .is_none(),
630            "no signer → no intent token"
631        );
632    }
633
634    #[test]
635    fn intent_token_is_typed_and_bound_to_issuer_audience_and_subject() {
636        let signer = Arc::new(TokenSigner::from_seed(&[99u8; 32]));
637        let mut p = pdp(&[("search", "data.read")], false, None);
638        p.signer = Some(Arc::clone(&signer));
639        let jwt = p
640            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
641            .expect("signer present → must produce JWT");
642        let header = jsonwebtoken::decode_header(&jwt).unwrap();
643        assert_eq!(header.alg, jsonwebtoken::Algorithm::EdDSA);
644        assert_eq!(header.typ.as_deref(), Some(INTENT_TOKEN_TYP));
645        assert_eq!(header.kid.as_deref(), Some(signer.kid()));
646        let decoded = verify_intent_token(&signer, &jwt);
647        assert_eq!(decoded.claims.iss, "proofvisor");
648        assert_eq!(decoded.claims.aud, "backend-a");
649        assert_eq!(decoded.claims.sub, "user:alice@corp.com", "sub is the human");
650        assert_eq!(
651            decoded.claims.act,
652            Some(IntentActor { sub: "inst-1".into() }),
653            "act.sub is the calling agent"
654        );
655        assert_eq!(decoded.claims.tool, "search");
656        assert_eq!(decoded.claims.intent, "data.read");
657        assert_eq!(decoded.claims.iat, 1000);
658        assert_eq!(decoded.claims.exp - decoded.claims.iat, 60);
659        assert!(!decoded.claims.jti.is_empty());
660    }
661
662    #[test]
663    fn intent_tokens_carry_unique_jtis() {
664        let mut p = pdp(&[("search", "data.read")], false, None);
665        p.signer = Some(Arc::new(TokenSigner::from_seed(&[98u8; 32])));
666        let signer = p.signer.clone().unwrap();
667        let a = p
668            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
669            .unwrap();
670        let b = p
671            .mint_intent_token("search", "data.read", &facts(), "backend-a", 1000)
672            .unwrap();
673        assert_ne!(
674            verify_intent_token(&signer, &a).claims.jti,
675            verify_intent_token(&signer, &b).claims.jti,
676            "each intent token needs a distinct jti so backends can reject replays"
677        );
678    }
679
680    #[test]
681    fn intent_for_is_the_mapping_or_the_tool_name_and_never_denies() {
682        let p = pdp(&[("db_write", "data.mutate")], true, None);
683        assert_eq!(p.intent_for("db_write"), "data.mutate");
684        assert_eq!(p.intent_for("unmapped"), "unmapped");
685    }
686
687    #[test]
688    fn plain_sign_keeps_the_default_jwt_type() {
689        let signer = TokenSigner::from_seed(&[97u8; 32]);
690        let jwt = signer.sign(&serde_json::json!({"sub": "x"})).unwrap();
691        let header = jsonwebtoken::decode_header(&jwt).unwrap();
692        assert_eq!(header.typ.as_deref(), Some("JWT"));
693    }
694
695    #[test]
696    fn authz_eval_request_serializes() {
697        let req = AuthzEvalRequest {
698            subject: AuthzSubject {
699                id: "agent:billing".into(),
700                subject_type: "agent".into(),
701                properties: None,
702            },
703            action: AuthzAction {
704                name: "data.mutate".into(),
705                properties: None,
706            },
707            resource: AuthzResource {
708                id: "db_write".into(),
709                resource_type: "tool".into(),
710                properties: None,
711            },
712            context: None,
713        };
714        let json = serde_json::to_value(&req).unwrap();
715        assert_eq!(json["subject"]["id"], "agent:billing");
716        assert_eq!(json["action"]["name"], "data.mutate");
717    }
718}