1use std::collections::{HashMap, HashSet};
49
50use ppe::praxis_policy_core::delegation::DelegationPayload;
51use ppe::praxis_policy_core::extensions::Extensions;
52use ppe::praxis_policy_core::extensions::raw_credentials::{
53 DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
54};
55use ppe::praxis_policy_core::hooks::payload::PluginPayload;
56use ppe::praxis_policy_core::identity::{IdentityPayload, TokenSource};
57use serde::Serialize;
58use serde_json::Value;
59
60use crate::PayloadKind;
61use crate::conversion::payload_kind_for_hook;
62use crate::error::HostError;
63
64pub const CREDENTIAL_FIELD: &str = "credential";
67
68pub const CAP_READ_INBOUND: &str = "read_inbound_credentials";
70
71pub const CAP_READ_DELEGATED: &str = "read_delegated_tokens";
73
74const REDACTED: &str = "<redacted>";
76
77#[derive(Clone, Default, Serialize)]
82pub struct CredentialDto {
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub inbound: Option<InboundCredential>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub delegated: Option<DelegatedCredential>,
88}
89
90impl std::fmt::Debug for CredentialDto {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("CredentialDto")
94 .field("inbound", &self.inbound.as_ref().map(|_| REDACTED))
95 .field("delegated", &self.delegated.as_ref().map(|_| REDACTED))
96 .finish()
97 }
98}
99
100#[derive(Clone, Serialize)]
106pub struct InboundCredential {
107 pub token: String,
109
110 pub source_header: String,
112
113 pub kind: String,
115
116 #[serde(skip_serializing_if = "Option::is_none")]
120 pub headers: Option<HashMap<String, String>>,
121}
122
123impl std::fmt::Debug for InboundCredential {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("InboundCredential")
126 .field("token", &REDACTED)
127 .field("source_header", &self.source_header)
128 .field("kind", &self.kind)
129 .field(
131 "header_names",
132 &self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
133 )
134 .finish()
135 }
136}
137
138#[derive(Clone, Serialize)]
144pub struct DelegatedCredential {
145 pub token: String,
147
148 pub outbound_header: String,
150
151 pub audience: String,
153
154 pub scopes: Vec<String>,
156}
157
158impl std::fmt::Debug for DelegatedCredential {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 f.debug_struct("DelegatedCredential")
161 .field("token", &REDACTED)
162 .field("outbound_header", &self.outbound_header)
163 .field("audience", &self.audience)
164 .field("scopes", &self.scopes)
165 .finish()
166 }
167}
168
169fn token_kind_wire_name(kind: &TokenKind) -> &'static str {
176 match kind {
177 TokenKind::Jwt => "jwt",
178 TokenKind::Opaque => "opaque",
179 TokenKind::SpiffeJwt => "spiffe_jwt",
180 TokenKind::Ucan => "ucan",
181 TokenKind::TxnToken => "txn_token",
182 _ => "opaque",
187 }
188}
189
190pub fn attach_credential(
196 task: &mut Value,
197 hook_name: &str,
198 extensions: &Extensions,
199 capabilities: &HashSet<String>,
200) -> Result<(), HostError> {
201 let kind = payload_kind_for_hook(hook_name);
202
203 let dto = match kind {
204 PayloadKind::IdentityResolve => {
205 if !capabilities.contains(CAP_READ_INBOUND) {
206 return Ok(());
207 }
208 CredentialDto {
209 inbound: Some(build_inbound(extensions, hook_name)?),
210 delegated: None,
211 }
212 },
213 PayloadKind::TokenDelegate => {
214 if !capabilities.contains(CAP_READ_DELEGATED) {
215 return Ok(());
216 }
217 let requested_audience = requested_audience(task);
223 CredentialDto {
224 inbound: None,
225 delegated: Some(build_delegated(
226 extensions,
227 hook_name,
228 requested_audience.as_deref(),
229 )?),
230 }
231 },
232 _ => return Ok(()),
234 };
235
236 insert_dto(task, &dto)
237}
238
239pub fn attach_credential_for_payload(
252 task: &mut Value,
253 hook_name: &str,
254 payload: &dyn PluginPayload,
255 extensions: &Extensions,
256 capabilities: &HashSet<String>,
257) -> Result<(), HostError> {
258 let any = payload.as_any();
259 let dto = match payload_kind_for_hook(hook_name) {
260 PayloadKind::IdentityResolve => match any.downcast_ref::<IdentityPayload>() {
261 Some(identity) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
262 inbound: Some(inbound_from_identity(identity, hook_name)?),
263 delegated: None,
264 },
265 Some(_) => return Ok(()),
266 None => return attach_credential(task, hook_name, extensions, capabilities),
267 },
268 PayloadKind::TokenDelegate => match any.downcast_ref::<DelegationPayload>() {
275 Some(delegation) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
276 inbound: None,
277 delegated: Some(delegated_from_payload(delegation, hook_name)?),
278 },
279 Some(_) => return Ok(()),
280 None => return attach_credential(task, hook_name, extensions, capabilities),
281 },
282 _ => return attach_credential(task, hook_name, extensions, capabilities),
283 };
284 insert_dto(task, &dto)
285}
286
287fn inbound_from_identity(
294 identity: &IdentityPayload,
295 hook_name: &str,
296) -> Result<InboundCredential, HostError> {
297 let token = crate::credential_payloads::inbound_token(identity).ok_or_else(|| {
298 HostError::Credential {
299 message: format!(
300 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the identity \
301 payload carries no credential, in `raw_token` or its source header. Refusing \
302 to dispatch without the token it asked for"
303 ),
304 }
305 })?;
306 let source_header = crate::credential_payloads::credential_header_name(identity);
307 let mut headers = identity.headers().clone();
308 if !headers
309 .keys()
310 .any(|name| name.eq_ignore_ascii_case(&source_header))
311 {
312 headers.insert(source_header.clone(), token.clone());
313 }
314 Ok(InboundCredential {
315 token,
316 source_header,
317 kind: token_source_kind(identity.source()).to_string(),
318 headers: Some(headers),
319 })
320}
321
322fn token_source_kind(source: &TokenSource) -> &'static str {
328 match source {
329 TokenSource::SpiffeJwtSvid => "spiffe_jwt",
330 _ => "opaque",
331 }
332}
333
334fn delegated_from_payload(
339 delegation: &DelegationPayload,
340 hook_name: &str,
341) -> Result<DelegatedCredential, HostError> {
342 let token = delegation.bearer_token();
343 if token.trim().is_empty() {
344 return Err(HostError::Credential {
345 message: format!(
346 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the delegation \
347 payload carries no bearer token. Refusing to dispatch without the token it \
348 asked for"
349 ),
350 });
351 }
352 Ok(DelegatedCredential {
353 token: token.to_string(),
354 outbound_header: "Authorization".into(),
355 audience: delegation.target_audience().unwrap_or_default().to_string(),
356 scopes: delegation.required_permissions().to_vec(),
357 })
358}
359
360fn insert_dto(task: &mut Value, dto: &CredentialDto) -> Result<(), HostError> {
361 let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
362 message: "task must be a JSON object to carry a credential".into(),
363 })?;
364
365 let serialized = serde_json::to_value(dto).map_err(|e| HostError::Credential {
366 message: format!("could not serialize the credential DTO: {e}"),
368 })?;
369 object.insert(CREDENTIAL_FIELD.to_string(), serialized);
370
371 Ok(())
372}
373
374fn build_inbound(extensions: &Extensions, hook_name: &str) -> Result<InboundCredential, HostError> {
380 let raw = extensions
381 .raw_credentials
382 .as_ref()
383 .ok_or_else(|| HostError::Credential {
384 message: format!(
385 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the request \
386 carries no raw-credentials extension — refusing to dispatch without the token \
387 it asked for"
388 ),
389 })?;
390
391 let token = [
396 TokenRole::User,
397 TokenRole::Client,
398 TokenRole::CallerWorkload,
399 ]
400 .iter()
401 .find_map(|role| raw.inbound_tokens.get(role))
402 .or_else(|| raw.inbound_tokens.values().next())
403 .ok_or_else(|| HostError::Credential {
404 message: format!(
405 "plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but no inbound token \
406 is present — refusing to dispatch without it"
407 ),
408 })?;
409
410 inbound_from_token(token, hook_name)
411}
412
413fn inbound_from_token(
415 token: &RawInboundToken,
416 hook_name: &str,
417) -> Result<InboundCredential, HostError> {
418 let plaintext = token.token.as_str();
421
422 if plaintext.trim().is_empty() {
426 return Err(HostError::Credential {
427 message: format!(
428 "the inbound token for hook '{hook_name}' is empty — refusing to send an empty \
429 credential (an empty bearer authenticates nowhere, and a plugin may read it as \
430 'no authentication required')"
431 ),
432 });
433 }
434
435 Ok(InboundCredential {
436 token: plaintext.to_string(),
437 source_header: token.source_header.clone(),
438 kind: token_kind_wire_name(&token.kind).to_string(),
439 headers: Some(HashMap::from([(
443 token.source_header.clone(),
444 plaintext.to_string(),
445 )])),
446 })
447}
448
449fn requested_audience(task: &Value) -> Option<String> {
455 task.get("payload")?
456 .get("target_audience")?
457 .as_str()
458 .map(str::to_string)
459}
460
461fn build_delegated(
485 extensions: &Extensions,
486 hook_name: &str,
487 requested_audience: Option<&str>,
488) -> Result<DelegatedCredential, HostError> {
489 let raw = extensions
490 .raw_credentials
491 .as_ref()
492 .ok_or_else(|| HostError::Credential {
493 message: format!(
494 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but the request \
495 carries no raw-credentials extension — refusing to dispatch without the token \
496 it asked for"
497 ),
498 })?;
499
500 let mut candidates: Vec<(&DelegationKey, &RawDelegatedToken)> = raw
503 .delegated_tokens
504 .iter()
505 .filter(|(key, token)| match requested_audience {
506 Some(audience) => key.audience == audience && token.audience == audience,
507 None => true,
508 })
509 .collect();
510
511 candidates.sort_by(|(left, _), (right, _)| {
514 mode_rank(&left.mode)
515 .cmp(&mode_rank(&right.mode))
516 .then_with(|| left.subject_id.cmp(&right.subject_id))
517 .then_with(|| left.scopes.cmp(&right.scopes))
518 });
519
520 let (_, token) = candidates.first().ok_or_else(|| {
521 let scope = match requested_audience {
525 Some(audience) => format!(" for audience '{audience}'"),
526 None => String::new(),
527 };
528 HostError::Credential {
529 message: format!(
530 "plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but no delegated \
531 token{scope} is present — refusing to dispatch without it, and refusing to \
532 substitute a token minted for a different audience"
533 ),
534 }
535 })?;
536
537 delegated_from_token(token, hook_name)
538}
539
540fn mode_rank(mode: &DelegationMode) -> u8 {
546 match mode {
547 DelegationMode::OnBehalfOfUser => 0,
548 DelegationMode::AsThisWorkload => 1,
549 _ => 2,
550 }
551}
552
553fn delegated_from_token(
555 token: &RawDelegatedToken,
556 hook_name: &str,
557) -> Result<DelegatedCredential, HostError> {
558 let plaintext = token.token.as_str();
559
560 if plaintext.trim().is_empty() {
561 return Err(HostError::Credential {
562 message: format!(
563 "the delegated token for hook '{hook_name}' is empty — refusing to send an empty \
564 credential"
565 ),
566 });
567 }
568
569 Ok(DelegatedCredential {
570 token: plaintext.to_string(),
571 outbound_header: token.outbound_header.clone(),
572 audience: token.audience.clone(),
573 scopes: token.scopes.clone(),
574 })
575}
576
577#[cfg(test)]
578mod tests {
579 use std::sync::Arc;
580
581 use ppe::praxis_policy_core::extensions::raw_credentials::{
582 DelegationKey, RawCredentialsExtension,
583 };
584
585 use super::*;
586
587 const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
588 const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
589
590 fn caps(names: &[&str]) -> HashSet<String> {
591 names.iter().map(|s| (*s).to_string()).collect()
592 }
593
594 fn extensions_with_credentials() -> Extensions {
596 let mut raw = RawCredentialsExtension::default();
597 raw.inbound_tokens.insert(
598 TokenRole::User,
599 RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
600 );
601 raw.delegated_tokens.insert(
602 DelegationKey::new(
603 DelegationMode::OnBehalfOfUser,
604 "https://billing.example.com",
605 vec!["read".into()],
606 )
607 .with_subject_id("alice"),
608 RawDelegatedToken::new(
609 DELEGATED_SECRET,
610 "Authorization",
611 "https://billing.example.com",
612 vec!["read".into()],
613 chrono::Utc::now(),
614 ),
615 );
616
617 Extensions {
618 raw_credentials: Some(Arc::new(raw)),
619 ..Default::default()
620 }
621 }
622
623 fn task() -> Value {
624 serde_json::json!({ "task_type": "load_and_run_hook" })
625 }
626
627 fn delegate_task(target_audience: &str) -> Value {
630 serde_json::json!({
631 "task_type": "load_and_run_hook",
632 "hook_type": "token.delegate",
633 "payload": {
634 "target_name": "billing",
635 "target_type": "tool",
636 "target_audience": target_audience,
637 },
638 })
639 }
640
641 fn delegated(
643 subject: &str,
644 audience: &str,
645 mode: DelegationMode,
646 secret: &str,
647 ) -> (DelegationKey, RawDelegatedToken) {
648 (
649 DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
650 RawDelegatedToken::new(
651 secret,
652 "Authorization",
653 audience,
654 vec![],
655 chrono::Utc::now(),
656 ),
657 )
658 }
659
660 fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
661 let mut raw = RawCredentialsExtension::default();
662 for (key, token) in tokens {
663 raw.delegated_tokens.insert(key, token);
664 }
665 Extensions {
666 raw_credentials: Some(Arc::new(raw)),
667 ..Default::default()
668 }
669 }
670
671 fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
674 let mut task = delegate_task(target_audience);
675 attach_credential(
676 &mut task,
677 "token.delegate",
678 extensions,
679 &caps(&[CAP_READ_DELEGATED]),
680 )?;
681 Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
682 .as_str()
683 .expect("a delegated token was attached")
684 .to_string())
685 }
686
687 #[test]
690 fn a_declaring_plugin_receives_the_inbound_token() {
691 let mut task = task();
692 attach_credential(
693 &mut task,
694 "identity.resolve",
695 &extensions_with_credentials(),
696 &caps(&[CAP_READ_INBOUND]),
697 )
698 .expect("a declared capability is honored");
699
700 let credential = &task[CREDENTIAL_FIELD];
701 assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
702 assert_eq!(credential["inbound"]["source_header"], "Authorization");
703 assert_eq!(credential["inbound"]["kind"], "jwt");
704 assert!(
705 credential.get("delegated").is_none(),
706 "an identity hook must not receive delegated material"
707 );
708 }
709
710 #[test]
711 fn a_non_declaring_plugin_receives_no_token_material() {
712 let mut task = task();
713 attach_credential(
714 &mut task,
715 "identity.resolve",
716 &extensions_with_credentials(),
717 &caps(&[]),
718 )
719 .expect("declaring nothing is not an error");
720
721 assert!(
722 task.get(CREDENTIAL_FIELD).is_none(),
723 "no credential field at all"
724 );
725 assert!(
726 !serde_json::to_string(&task)
727 .unwrap()
728 .contains("INBOUND-SECRET-BYTES"),
729 "no token bytes anywhere in the task"
730 );
731 }
732
733 #[test]
734 fn two_plugins_on_one_hook_are_gated_independently() {
735 let extensions = extensions_with_credentials();
738
739 let mut declaring = task();
740 attach_credential(
741 &mut declaring,
742 "identity.resolve",
743 &extensions,
744 &caps(&[CAP_READ_INBOUND]),
745 )
746 .unwrap();
747
748 let mut non_declaring = task();
749 attach_credential(
750 &mut non_declaring,
751 "identity.resolve",
752 &extensions,
753 &caps(&[]),
754 )
755 .unwrap();
756
757 assert!(
758 serde_json::to_string(&declaring)
759 .unwrap()
760 .contains(INBOUND_SECRET)
761 );
762 assert!(
763 !serde_json::to_string(&non_declaring)
764 .unwrap()
765 .contains("INBOUND-SECRET-BYTES")
766 );
767 }
768
769 #[test]
770 fn the_wrong_capability_does_not_unlock_a_hook() {
771 let extensions = extensions_with_credentials();
774
775 let mut identity = task();
776 attach_credential(
777 &mut identity,
778 "identity.resolve",
779 &extensions,
780 &caps(&[CAP_READ_DELEGATED]),
781 )
782 .unwrap();
783 assert!(identity.get(CREDENTIAL_FIELD).is_none());
784
785 let mut delegate = task();
786 attach_credential(
787 &mut delegate,
788 "token.delegate",
789 &extensions,
790 &caps(&[CAP_READ_INBOUND]),
791 )
792 .unwrap();
793 assert!(delegate.get(CREDENTIAL_FIELD).is_none());
794 }
795
796 #[test]
797 fn a_declaring_plugin_receives_the_delegated_token() {
798 let mut task = task();
799 attach_credential(
800 &mut task,
801 "token.delegate",
802 &extensions_with_credentials(),
803 &caps(&[CAP_READ_DELEGATED]),
804 )
805 .expect("a declared capability is honored");
806
807 let delegated = &task[CREDENTIAL_FIELD]["delegated"];
808 assert_eq!(delegated["token"], DELEGATED_SECRET);
809 assert_eq!(delegated["outbound_header"], "Authorization");
810 assert_eq!(delegated["audience"], "https://billing.example.com");
811 assert_eq!(delegated["scopes"][0], "read");
812 assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
813 }
814
815 #[test]
816 fn non_credential_hooks_never_receive_a_credential() {
817 for hook in [
820 "tool_pre_invoke",
821 "tool_post_invoke",
822 "prompt_pre_fetch",
823 "resource_post_fetch",
824 "cmf.tool_pre_invoke",
825 "cmf.prompt_pre_invoke",
826 "http.request",
827 "some_custom_hook",
828 "identity_resolve",
831 "token_delegate",
832 ] {
833 let mut task = task();
834 attach_credential(
835 &mut task,
836 hook,
837 &extensions_with_credentials(),
838 &caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
839 )
840 .unwrap();
841
842 assert!(
843 task.get(CREDENTIAL_FIELD).is_none(),
844 "hook '{hook}' must not receive credentials"
845 );
846 }
847 }
848
849 #[test]
852 fn a_declared_capability_with_no_extension_errors() {
853 let err = attach_credential(
857 &mut task(),
858 "identity.resolve",
859 &Extensions::default(),
860 &caps(&[CAP_READ_INBOUND]),
861 )
862 .expect_err("a declared capability that cannot be honored must error");
863
864 assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
865 }
866
867 #[test]
868 fn a_declared_capability_with_no_matching_token_errors() {
869 let mut raw = RawCredentialsExtension::default();
872 raw.delegated_tokens.insert(
873 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
874 .with_subject_id("alice"),
875 RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
876 );
877 let extensions = Extensions {
878 raw_credentials: Some(Arc::new(raw)),
879 ..Default::default()
880 };
881
882 let err = attach_credential(
883 &mut task(),
884 "identity.resolve",
885 &extensions,
886 &caps(&[CAP_READ_INBOUND]),
887 )
888 .expect_err("no inbound token means the capability cannot be honored");
889 assert!(matches!(err, HostError::Credential { .. }));
890 }
891
892 #[test]
893 fn an_empty_token_is_rejected_rather_than_sent() {
894 for token in ["", " ", "\t\n"] {
899 let mut raw = RawCredentialsExtension::default();
900 raw.inbound_tokens.insert(
901 TokenRole::User,
902 RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
903 );
904 let extensions = Extensions {
905 raw_credentials: Some(Arc::new(raw)),
906 ..Default::default()
907 };
908
909 let err = attach_credential(
910 &mut task(),
911 "identity.resolve",
912 &extensions,
913 &caps(&[CAP_READ_INBOUND]),
914 )
915 .expect_err("an empty or whitespace-only token must be refused");
916 assert!(matches!(err, HostError::Credential { .. }));
917 }
918 }
919
920 #[test]
921 fn an_empty_delegated_token_is_rejected() {
922 let mut raw = RawCredentialsExtension::default();
923 raw.delegated_tokens.insert(
924 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
925 .with_subject_id("alice"),
926 RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
927 );
928 let extensions = Extensions {
929 raw_credentials: Some(Arc::new(raw)),
930 ..Default::default()
931 };
932
933 let err = attach_credential(
934 &mut task(),
935 "token.delegate",
936 &extensions,
937 &caps(&[CAP_READ_DELEGATED]),
938 )
939 .expect_err("an empty delegated token must be refused");
940 assert!(matches!(err, HostError::Credential { .. }));
941 }
942
943 #[test]
944 fn a_fail_closed_error_never_names_the_token() {
945 let mut raw = RawCredentialsExtension::default();
946 raw.inbound_tokens.insert(
947 TokenRole::User,
948 RawInboundToken::new(" ", "Authorization", TokenKind::Jwt),
949 );
950 let extensions = Extensions {
951 raw_credentials: Some(Arc::new(raw)),
952 ..Default::default()
953 };
954
955 let err = attach_credential(
956 &mut task(),
957 "identity.resolve",
958 &extensions,
959 &caps(&[CAP_READ_INBOUND]),
960 )
961 .expect_err("errors");
962 let message = err.to_string();
963 assert!(
964 message.contains("identity.resolve"),
965 "the message should name the hook: {message}"
966 );
967 assert!(message.contains("empty"));
968 }
969
970 #[test]
973 fn production_credential_types_still_refuse_to_serialize_tokens() {
974 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
979 let json = serde_json::to_string(&inbound).unwrap();
980 assert!(
981 !json.contains("INBOUND-SECRET-BYTES"),
982 "the serde guard regressed: {json}"
983 );
984
985 let delegated = RawDelegatedToken::new(
986 DELEGATED_SECRET,
987 "Authorization",
988 "aud",
989 vec![],
990 chrono::Utc::now(),
991 );
992 let json = serde_json::to_string(&delegated).unwrap();
993 assert!(
994 !json.contains("MINTED-DELEGATED"),
995 "the serde guard regressed: {json}"
996 );
997
998 let extensions = extensions_with_credentials();
1003 let raw = extensions.raw_credentials.as_ref().unwrap();
1004 let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
1005 assert!(
1006 !json.contains("INBOUND-SECRET-BYTES"),
1007 "the serde guard regressed: {json}"
1008 );
1009
1010 for token in raw.delegated_tokens.values() {
1011 let json = serde_json::to_string(token).unwrap();
1012 assert!(
1013 !json.contains("MINTED-DELEGATED"),
1014 "the serde guard regressed: {json}"
1015 );
1016 }
1017 }
1018
1019 #[test]
1020 fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
1021 let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
1024 let round_tripped: RawInboundToken =
1025 serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
1026
1027 assert_eq!(
1028 &*round_tripped.token, "",
1029 "the guard strips the bytes on the way out"
1030 );
1031 assert_eq!(round_tripped.source_header, "Authorization");
1032 }
1033
1034 #[test]
1037 fn the_dto_debug_output_hides_the_token() {
1038 let dto = CredentialDto {
1039 inbound: Some(InboundCredential {
1040 token: INBOUND_SECRET.into(),
1041 source_header: "Authorization".into(),
1042 kind: "jwt".into(),
1043 headers: Some(HashMap::from([(
1044 "Authorization".into(),
1045 format!("Bearer {INBOUND_SECRET}"),
1046 )])),
1047 }),
1048 delegated: None,
1049 };
1050
1051 let outer = format!("{dto:?}");
1054 let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
1055
1056 for debug in [&outer, &inner] {
1057 assert!(
1058 !debug.contains("INBOUND-SECRET-BYTES"),
1059 "token leaked into Debug: {debug}"
1060 );
1061 }
1062 assert!(inner.contains("Authorization"));
1064 assert!(inner.contains("jwt"));
1065 }
1066
1067 #[test]
1068 fn the_delegated_dto_debug_output_hides_the_token() {
1069 let credential = DelegatedCredential {
1070 token: DELEGATED_SECRET.into(),
1071 outbound_header: "Authorization".into(),
1072 audience: "https://billing.example.com".into(),
1073 scopes: vec!["read".into()],
1074 };
1075
1076 let debug = format!("{credential:?}");
1077 assert!(
1078 !debug.contains("MINTED-DELEGATED"),
1079 "token leaked into Debug: {debug}"
1080 );
1081 assert!(debug.contains("billing.example.com"));
1082 assert!(debug.contains("read"));
1083 }
1084
1085 #[test]
1088 fn token_kinds_use_the_wire_names_the_worker_matches_on() {
1089 assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
1092 assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
1093 assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
1094 assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
1095 assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
1096 }
1097
1098 #[test]
1099 fn wire_names_match_the_serde_representation() {
1100 for kind in [
1103 TokenKind::Jwt,
1104 TokenKind::Opaque,
1105 TokenKind::SpiffeJwt,
1106 TokenKind::Ucan,
1107 TokenKind::TxnToken,
1108 ] {
1109 let via_serde = serde_json::to_value(&kind).unwrap();
1110 assert_eq!(
1111 via_serde.as_str().unwrap(),
1112 token_kind_wire_name(&kind),
1113 "hand-written wire name disagrees with serde for {kind:?}"
1114 );
1115 }
1116 }
1117
1118 #[test]
1119 fn the_user_token_is_preferred_over_the_client_token() {
1120 let mut raw = RawCredentialsExtension::default();
1123 raw.inbound_tokens.insert(
1124 TokenRole::Client,
1125 RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
1126 );
1127 raw.inbound_tokens.insert(
1128 TokenRole::User,
1129 RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
1130 );
1131 let extensions = Extensions {
1132 raw_credentials: Some(Arc::new(raw)),
1133 ..Default::default()
1134 };
1135
1136 let mut task = task();
1137 attach_credential(
1138 &mut task,
1139 "identity.resolve",
1140 &extensions,
1141 &caps(&[CAP_READ_INBOUND]),
1142 )
1143 .unwrap();
1144 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
1145 }
1146
1147 #[test]
1148 fn headers_are_synthesized_from_the_source_header() {
1149 let mut task = task();
1150 attach_credential(
1151 &mut task,
1152 "identity.resolve",
1153 &extensions_with_credentials(),
1154 &caps(&[CAP_READ_INBOUND]),
1155 )
1156 .unwrap();
1157
1158 assert_eq!(
1161 task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
1162 INBOUND_SECRET
1163 );
1164 }
1165
1166 #[test]
1167 fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
1168 let mut raw = RawCredentialsExtension::default();
1169 raw.delegated_tokens.insert(
1170 DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
1171 RawDelegatedToken::new(
1172 "GATEWAY-TOKEN",
1173 "Authorization",
1174 "aud",
1175 vec![],
1176 chrono::Utc::now(),
1177 ),
1178 );
1179 raw.delegated_tokens.insert(
1180 DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
1181 .with_subject_id("alice"),
1182 RawDelegatedToken::new(
1183 "USER-DELEGATED-TOKEN",
1184 "Authorization",
1185 "aud",
1186 vec![],
1187 chrono::Utc::now(),
1188 ),
1189 );
1190 let extensions = Extensions {
1191 raw_credentials: Some(Arc::new(raw)),
1192 ..Default::default()
1193 };
1194
1195 let mut task = task();
1196 attach_credential(
1197 &mut task,
1198 "token.delegate",
1199 &extensions,
1200 &caps(&[CAP_READ_DELEGATED]),
1201 )
1202 .unwrap();
1203 assert_eq!(
1204 task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
1205 "a delegator acting for the user must not fall back to the gateway's identity"
1206 );
1207 }
1208
1209 #[test]
1212 fn the_delegated_pick_matches_the_requested_audience() {
1213 let extensions = extensions_from(vec![
1219 delegated(
1220 "alice",
1221 "https://billing.example.com",
1222 DelegationMode::OnBehalfOfUser,
1223 "BILLING-TOKEN",
1224 ),
1225 delegated(
1226 "alice",
1227 "https://search.example.com",
1228 DelegationMode::OnBehalfOfUser,
1229 "SEARCH-TOKEN",
1230 ),
1231 delegated(
1232 "alice",
1233 "https://payroll.example.com",
1234 DelegationMode::OnBehalfOfUser,
1235 "PAYROLL-TOKEN",
1236 ),
1237 ]);
1238
1239 assert_eq!(
1240 pick_delegated(&extensions, "https://search.example.com").unwrap(),
1241 "SEARCH-TOKEN",
1242 "a token minted for another audience must never be substituted"
1243 );
1244 assert_eq!(
1245 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1246 "BILLING-TOKEN"
1247 );
1248 }
1249
1250 #[test]
1251 fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
1252 let forward = vec![
1257 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1258 delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
1259 delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
1260 delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
1261 ];
1262 let reversed: Vec<_> = forward.iter().rev().cloned().collect();
1263
1264 const EXPECTED: &str = "A";
1266
1267 for _ in 0..200 {
1268 let one = extensions_from(forward.clone());
1269 let other = extensions_from(reversed.clone());
1270 assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
1271 assert_eq!(
1272 pick_delegated(&other, "aud-a").unwrap(),
1273 EXPECTED,
1274 "insertion order must not change the pick"
1275 );
1276 }
1277 }
1278
1279 #[test]
1280 fn no_token_for_the_requested_audience_fails_closed() {
1281 let extensions = extensions_from(vec![delegated(
1284 "alice",
1285 "https://billing.example.com",
1286 DelegationMode::OnBehalfOfUser,
1287 "BILLING-TOKEN",
1288 )]);
1289
1290 let err = pick_delegated(&extensions, "https://attacker.example.com")
1291 .expect_err("no matching audience means no token");
1292
1293 let message = err.to_string();
1294 assert!(matches!(err, HostError::Credential { .. }), "{message}");
1295 assert!(
1296 message.contains("attacker.example.com"),
1297 "the error should name the audience that had no token: {message}"
1298 );
1299 assert!(
1300 !message.contains("BILLING-TOKEN"),
1301 "the error must not name the token it declined to substitute: {message}"
1302 );
1303 }
1304
1305 #[test]
1306 fn the_audience_filter_is_applied_before_the_mode_preference() {
1307 let extensions = extensions_from(vec![
1312 delegated(
1313 "alice",
1314 "https://other.example.com",
1315 DelegationMode::OnBehalfOfUser,
1316 "OTHER-AUDIENCE-USER-TOKEN",
1317 ),
1318 delegated(
1319 "gw",
1320 "https://billing.example.com",
1321 DelegationMode::AsThisWorkload,
1322 "BILLING-GATEWAY-TOKEN",
1323 ),
1324 ]);
1325
1326 assert_eq!(
1327 pick_delegated(&extensions, "https://billing.example.com").unwrap(),
1328 "BILLING-GATEWAY-TOKEN"
1329 );
1330 }
1331
1332 #[test]
1333 fn within_one_audience_the_user_token_still_wins() {
1334 let extensions = extensions_from(vec![
1339 delegated(
1340 "aaa-gw",
1341 "aud",
1342 DelegationMode::AsThisWorkload,
1343 "GATEWAY-TOKEN",
1344 ),
1345 delegated(
1346 "zzz-user",
1347 "aud",
1348 DelegationMode::OnBehalfOfUser,
1349 "USER-TOKEN",
1350 ),
1351 ]);
1352
1353 assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
1354 }
1355
1356 #[test]
1357 fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
1358 let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
1362 let token = RawDelegatedToken::new(
1363 "MISMATCHED-TOKEN",
1364 "Authorization",
1365 "aud-token",
1366 vec![],
1367 chrono::Utc::now(),
1368 );
1369 let extensions = extensions_from(vec![(key, token)]);
1370
1371 for audience in ["aud-key", "aud-token"] {
1372 assert!(
1373 pick_delegated(&extensions, audience).is_err(),
1374 "a self-inconsistent token must not satisfy '{audience}'"
1375 );
1376 }
1377 }
1378
1379 #[test]
1380 fn an_unscoped_delegation_accepts_any_audience() {
1381 let tokens = vec![
1386 delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
1387 delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
1388 ];
1389
1390 for _ in 0..50 {
1391 let mut task = serde_json::json!({
1392 "task_type": "load_and_run_hook",
1393 "payload": { "target_name": "billing", "target_type": "tool" },
1394 });
1395 attach_credential(
1396 &mut task,
1397 "token.delegate",
1398 &extensions_from(tokens.clone()),
1399 &caps(&[CAP_READ_DELEGATED]),
1400 )
1401 .expect("an unscoped delegation is still served");
1402 assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
1403 }
1404 }
1405
1406 #[test]
1407 fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
1408 assert!(
1410 mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
1411 );
1412 }
1413
1414 #[test]
1415 fn the_requested_audience_is_read_from_the_payload() {
1416 assert_eq!(
1417 requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
1418 Some("https://billing.example.com")
1419 );
1420 assert_eq!(requested_audience(&task()), None);
1422 assert_eq!(
1423 requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
1424 None
1425 );
1426 assert_eq!(
1427 requested_audience(&serde_json::json!({ "payload": {} })),
1428 None
1429 );
1430 }
1431
1432 #[test]
1433 fn the_credential_field_name_matches_the_worker_contract() {
1434 assert_eq!(
1435 CREDENTIAL_FIELD, "credential",
1436 "the worker reads task_data['credential']"
1437 );
1438 }
1439
1440 use ppe::praxis_policy_core::delegation::DelegationPayload;
1443 use ppe::praxis_policy_core::identity::{IdentityPayload, TokenSource};
1444
1445 fn identity_payload() -> IdentityPayload {
1446 IdentityPayload::new(INBOUND_SECRET, TokenSource::Bearer).with_headers(HashMap::from([
1447 ("authorization".into(), format!("Bearer {INBOUND_SECRET}")),
1448 ("cookie".into(), "session=abc".into()),
1449 ("x-tenant".into(), "acme".into()),
1450 ]))
1451 }
1452
1453 #[test]
1454 fn an_identity_payload_supplies_its_own_token_to_a_declaring_plugin() {
1455 let mut task = task();
1456 attach_credential_for_payload(
1459 &mut task,
1460 "identity.resolve",
1461 &identity_payload(),
1462 &Extensions::default(),
1463 &caps(&[CAP_READ_INBOUND]),
1464 )
1465 .expect("the payload's token honors the capability");
1466
1467 let inbound = &task[CREDENTIAL_FIELD]["inbound"];
1468 assert_eq!(inbound["token"], INBOUND_SECRET);
1469 assert_eq!(inbound["source_header"], "authorization");
1470 assert_eq!(inbound["kind"], "opaque");
1471 assert_eq!(inbound["headers"]["cookie"], "session=abc");
1473 assert_eq!(inbound["headers"]["x-tenant"], "acme");
1474 }
1475
1476 #[test]
1477 fn a_header_borne_identity_credential_is_found() {
1478 let payload: IdentityPayload = serde_json::from_value(serde_json::json!({
1479 "source": "bearer",
1480 "source_header": "X-User-Token",
1481 "headers": {"x-user-token": INBOUND_SECRET},
1482 }))
1483 .unwrap();
1484 let mut task = task();
1485 attach_credential_for_payload(
1486 &mut task,
1487 "identity.resolve",
1488 &payload,
1489 &Extensions::default(),
1490 &caps(&[CAP_READ_INBOUND]),
1491 )
1492 .unwrap();
1493 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1494 assert_eq!(
1495 task[CREDENTIAL_FIELD]["inbound"]["source_header"],
1496 "x-user-token"
1497 );
1498 }
1499
1500 #[test]
1501 fn an_identity_payload_gives_a_non_declaring_plugin_nothing() {
1502 let mut task = task();
1503 attach_credential_for_payload(
1504 &mut task,
1505 "identity.resolve",
1506 &identity_payload(),
1507 &extensions_with_credentials(),
1508 &caps(&[CAP_READ_DELEGATED]),
1509 )
1510 .unwrap();
1511 assert!(task.get(CREDENTIAL_FIELD).is_none());
1512 }
1513
1514 #[test]
1515 fn an_identity_payload_without_a_credential_fails_closed() {
1516 let payload = IdentityPayload::new("", TokenSource::Bearer);
1517 let mut task = task();
1518 let err = attach_credential_for_payload(
1521 &mut task,
1522 "identity.resolve",
1523 &payload,
1524 &extensions_with_credentials(),
1525 &caps(&[CAP_READ_INBOUND]),
1526 )
1527 .unwrap_err();
1528 assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1529 assert!(task.get(CREDENTIAL_FIELD).is_none());
1530 }
1531
1532 #[test]
1533 fn a_delegation_payload_supplies_its_bearer_token_to_an_inbound_declaring_plugin() {
1534 let payload = DelegationPayload::new(INBOUND_SECRET, "billing")
1535 .with_target_audience("https://billing.example.com")
1536 .with_required_permissions(vec!["read".into()]);
1537 let mut gated = task();
1538 attach_credential_for_payload(
1539 &mut gated,
1540 "token.delegate",
1541 &payload,
1542 &Extensions::default(),
1543 &caps(&[CAP_READ_INBOUND]),
1544 )
1545 .unwrap();
1546 let delegated = &gated[CREDENTIAL_FIELD]["delegated"];
1547 assert_eq!(delegated["token"], INBOUND_SECRET);
1548 assert_eq!(delegated["audience"], "https://billing.example.com");
1549 assert_eq!(delegated["scopes"], serde_json::json!(["read"]));
1550
1551 let mut task = task();
1554 attach_credential_for_payload(
1555 &mut task,
1556 "token.delegate",
1557 &payload,
1558 &Extensions::default(),
1559 &caps(&[CAP_READ_DELEGATED]),
1560 )
1561 .unwrap();
1562 assert!(task.get(CREDENTIAL_FIELD).is_none());
1563 }
1564
1565 #[test]
1566 fn a_delegation_payload_without_a_bearer_token_fails_closed() {
1567 let payload = DelegationPayload::new(" ", "billing");
1568 let mut task = task();
1569 let err = attach_credential_for_payload(
1570 &mut task,
1571 "token.delegate",
1572 &payload,
1573 &extensions_with_credentials(),
1574 &caps(&[CAP_READ_INBOUND]),
1575 )
1576 .unwrap_err();
1577 assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
1578 }
1579
1580 #[test]
1581 fn a_legacy_payload_still_reads_the_extensions() {
1582 let mut task = task();
1583 attach_credential_for_payload(
1584 &mut task,
1585 "identity.resolve",
1586 &crate::legacy::IdentityResolvePayload::default(),
1587 &extensions_with_credentials(),
1588 &caps(&[CAP_READ_INBOUND]),
1589 )
1590 .unwrap();
1591 assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
1592 }
1593}