1use crate::config::{HarnessConfig, MissionConfig};
7use pv_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 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 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";
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180pub struct IntentActor {
181 pub sub: String,
183}
184
185#[derive(Debug, Serialize, Deserialize)]
187#[allow(missing_docs)]
188pub struct IntentClaims {
189 pub iss: String,
190 pub aud: String,
191 pub sub: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub act: Option<IntentActor>,
197 pub tool: String,
198 pub intent: String,
199 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct CallerFacts<'a> {
210 pub instance_uid: &'a str,
212 pub charter: &'a str,
214 pub subject: Option<&'a str>,
216}
217
218impl CallerFacts<'static> {
219 pub const ANONYMOUS: Self = Self {
221 instance_uid: "anonymous",
222 charter: "anonymous",
223 subject: None,
224 };
225}
226
227pub 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 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 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 pub fn decide(&self, tool: &str, now_s: u64) -> AuthzDecision {
270 self.decide_for(tool, now_s, &CallerFacts::ANONYMOUS)
271 }
272
273 pub fn decide_for(&self, tool: &str, now_s: u64, caller: &CallerFacts<'_>) -> AuthzDecision {
277 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 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 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 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 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}