1use crate::config::{HarnessConfig, MissionConfig};
7use av_sandbox::DenialCode;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12pub struct TokenSigner {
14 key: jsonwebtoken::EncodingKey,
15 kid: String,
16 public_key: [u8; 32],
17}
18
19impl TokenSigner {
20 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 pub fn sign<T: Serialize>(&self, claims: &T) -> Result<String, String> {
34 self.sign_with_typ(claims, "JWT")
35 }
36
37 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 pub fn kid(&self) -> &str {
47 &self.kid
48 }
49
50 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 pub fn decoding_key(&self) -> jsonwebtoken::DecodingKey {
58 jsonwebtoken::DecodingKey::from_ed_der(&self.public_key)
59 }
60
61 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
80fn 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#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum AuthzDecision {
95 Permit {
97 intent: String,
99 },
100 Deny {
102 code: DenialCode,
104 reason: String,
106 },
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct AuthzEvalRequest {
113 pub subject: AuthzSubject,
115 pub action: AuthzAction,
117 pub resource: AuthzResource,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub context: Option<serde_json::Value>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct AuthzSubject {
127 pub id: String,
129 #[serde(rename = "type")]
131 pub subject_type: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub properties: Option<serde_json::Value>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct AuthzAction {
140 pub name: String,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub properties: Option<serde_json::Value>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct AuthzResource {
150 pub id: String,
152 #[serde(rename = "type")]
154 pub resource_type: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub properties: Option<serde_json::Value>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct AuthzEvalResponse {
163 pub decision: bool,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub context: Option<serde_json::Value>,
168}
169
170pub const INTENT_TOKEN_TYP: &str = "av-intent+jwt";
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177pub struct IntentActor {
178 pub sub: String,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
184#[allow(missing_docs)]
185pub struct IntentClaims {
186 pub iss: String,
187 pub aud: String,
188 pub sub: String,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub act: Option<IntentActor>,
194 pub tool: String,
195 pub intent: String,
196 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct CallerFacts<'a> {
207 pub instance_uid: &'a str,
209 pub charter: &'a str,
211 pub subject: Option<&'a str>,
213}
214
215impl CallerFacts<'static> {
216 pub const ANONYMOUS: Self = Self {
218 instance_uid: "anonymous",
219 charter: "anonymous",
220 subject: None,
221 };
222}
223
224pub 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 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 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 pub fn decide(&self, tool: &str, now_s: u64) -> AuthzDecision {
267 self.decide_for(tool, now_s, &CallerFacts::ANONYMOUS)
268 }
269
270 pub fn decide_for(&self, tool: &str, now_s: u64, caller: &CallerFacts<'_>) -> AuthzDecision {
274 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 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 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 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 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}