1use std::collections::HashMap;
82
83use praxis_policy_core::delegation::{DelegationPayload, DelegationSubject, HOOK_TOKEN_DELEGATE};
84use praxis_policy_core::error::PluginViolation;
85use praxis_policy_core::extensions::{DelegationExtension, RawDelegatedToken, SubjectExtension};
86use praxis_policy_core::hooks::payload::PluginPayload;
87use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload};
88use serde::Serialize;
89use serde::de::DeserializeOwned;
90use serde_json::{Map, Value};
91
92use crate::error::HostError;
93
94const DEFAULT_CREDENTIAL_HEADER: &str = "authorization";
96
97pub(crate) const CREDENTIAL_HEADERS: &[&str] = &[
100 "authorization",
101 "proxy-authorization",
102 "cookie",
103 "set-cookie",
104 "x-api-key",
105 "x-auth-token",
106 "x-access-token",
107 "x-amzn-oidc-data",
108 "x-goog-iap-jwt-assertion",
109 "x-amz-security-token",
110];
111
112const PUBLIC_HEADER_VALIDATORS: &[(&str, fn(&str) -> bool)] = &[];
116
117const DEFAULT_OUTBOUND_HEADER: &str = "Authorization";
119
120const DEFAULT_DELEGATED_TOKEN_TTL_SECS: i64 = 60;
126
127const IDENTITY_INPUT_FIELDS: &[&str] = &[
130 "raw_token",
131 "source",
132 "headers",
133 "client_host",
134 "client_port",
135];
136
137const DELEGATION_INPUT_FIELDS: &[&str] = &[
140 "target_name",
141 "target_type",
142 "target_audience",
143 "required_permissions",
144 "trust_domain",
145 "auth_enforced_by",
146 "route_attenuation",
147 "bearer_token",
148];
149
150pub fn is_ppe_credential_payload(payload: &dyn PluginPayload) -> bool {
152 let any = payload.as_any();
153 any.is::<IdentityPayload>() || any.is::<DelegationPayload>()
154}
155
156pub(crate) fn inbound_token(payload: &IdentityPayload) -> Option<String> {
165 let raw = payload.raw_token();
166 if !raw.trim().is_empty() {
167 return Some(raw.to_string());
168 }
169 let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
170 let (_, value) = header_entry(payload.headers(), header)?;
171 let token = strip_bearer(value).trim();
172 (!token.is_empty()).then(|| token.to_string())
173}
174
175pub(crate) fn credential_header_name(payload: &IdentityPayload) -> String {
178 let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
179 header_entry(payload.headers(), header)
180 .map_or_else(|| header.to_string(), |(name, _)| name.to_string())
181}
182
183fn header_entry<'a>(
185 headers: &'a HashMap<String, String>,
186 name: &str,
187) -> Option<(&'a str, &'a str)> {
188 headers
189 .iter()
190 .find(|(k, _)| k.eq_ignore_ascii_case(name))
191 .map(|(k, v)| (k.as_str(), v.as_str()))
192}
193
194fn strip_bearer(value: &str) -> &str {
195 let trimmed = value.trim_start();
196 match trimmed.get(..7) {
197 Some(prefix) if prefix.eq_ignore_ascii_case("bearer ") => &trimmed[7..],
198 _ => trimmed,
199 }
200}
201
202pub(crate) fn is_public_header(
208 name: &str,
209 value: &str,
210 source_header: Option<&str>,
211 token: Option<&str>,
212) -> bool {
213 let lower = name.to_ascii_lowercase();
214 if CREDENTIAL_HEADERS.contains(&lower.as_str())
215 || source_header.is_some_and(|source| lower.eq_ignore_ascii_case(source))
216 || token.is_some_and(|t| name.contains(t) || value.contains(t))
217 {
218 return false;
219 }
220 PUBLIC_HEADER_VALIDATORS
221 .iter()
222 .any(|(approved, validate)| lower == *approved && validate(value))
223}
224
225pub(crate) fn public_headers(payload: &IdentityPayload) -> HashMap<String, String> {
228 let token = inbound_token(payload);
229 let source = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
230 payload
231 .headers()
232 .iter()
233 .filter(|(name, value)| is_public_header(name, value, Some(source), token.as_deref()))
234 .map(|(name, value)| (name.clone(), value.clone()))
235 .collect()
236}
237
238pub fn identity_to_worker(payload: &IdentityPayload) -> Result<Value, HostError> {
240 let source = serde_json::to_value(payload.source()).map_err(|e| HostError::Protocol {
241 message: format!("could not serialize the identity payload's source: {e}"),
242 })?;
243 let mut json = serde_json::json!({
244 "raw_token": "",
248 "source": source,
249 "headers": public_headers(payload),
250 });
251 if let Some(host) = payload.client_host() {
252 json["client_host"] = Value::String(host.to_string());
253 }
254 if let Some(port) = payload.client_port() {
255 json["client_port"] = Value::from(port);
256 }
257 Ok(json)
258}
259
260#[derive(Debug)]
262pub enum IdentityOutcome {
263 Resolved(Box<IdentityPayload>),
265 Rejected(PluginViolation),
268}
269
270pub fn identity_from_worker(
273 original: &IdentityPayload,
274 modified: Value,
275) -> Result<IdentityOutcome, HostError> {
276 let fields = object(HOOK_IDENTITY_RESOLVE, modified)?;
277 for (key, value) in &fields {
278 let known = matches!(
279 key.as_str(),
280 "subject"
281 | "delegation"
282 | "raw_claims"
283 | "rejected"
284 | "reject_status"
285 | "reject_reason"
286 ) || IDENTITY_INPUT_FIELDS.contains(&key.as_str());
287 if !known && !is_empty(value) {
288 return Err(unrepresentable(HOOK_IDENTITY_RESOLVE, key));
289 }
290 }
291
292 match fields.get("rejected") {
293 None | Some(Value::Null) | Some(Value::Bool(false)) => {},
294 Some(Value::Bool(true)) => return Ok(IdentityOutcome::Rejected(rejection(&fields)?)),
295 Some(_) => {
296 return Err(protocol(
297 HOOK_IDENTITY_RESOLVE,
298 "`rejected` must be a boolean",
299 ));
300 },
301 }
302
303 let mut resolved = original.clone();
304 if let Some(subject) = fields.get("subject").filter(|v| !v.is_null()) {
305 resolved.subject = Some(parse_lossless::<SubjectExtension>(
306 HOOK_IDENTITY_RESOLVE,
307 "subject",
308 &python_subject(subject),
309 )?);
310 }
311 if let Some(delegation) = fields.get("delegation").filter(|v| !v.is_null()) {
312 resolved.delegation = Some(parse_lossless::<DelegationExtension>(
313 HOOK_IDENTITY_RESOLVE,
314 "delegation",
315 delegation,
316 )?);
317 }
318 match fields.get("raw_claims") {
319 None | Some(Value::Null) => {},
320 Some(Value::Object(claims)) => {
321 for (name, value) in claims {
322 resolved.raw_claims.insert(name.clone(), value.clone());
323 }
324 },
325 Some(_) => {
326 return Err(protocol(
327 HOOK_IDENTITY_RESOLVE,
328 "`raw_claims` must be an object",
329 ));
330 },
331 }
332 Ok(IdentityOutcome::Resolved(Box::new(resolved)))
333}
334
335fn rejection(fields: &Map<String, Value>) -> Result<PluginViolation, HostError> {
337 let reason = match fields.get("reject_reason") {
338 None | Some(Value::Null) => "",
339 Some(Value::String(reason)) => reason.as_str(),
340 Some(_) => {
341 return Err(protocol(
342 HOOK_IDENTITY_RESOLVE,
343 "`reject_reason` must be a string",
344 ));
345 },
346 };
347 let reason = if reason.is_empty() {
348 "the identity plugin rejected the credential"
349 } else {
350 reason
351 };
352 let mut violation = PluginViolation::new("auth.rejected", reason);
353 match fields.get("reject_status") {
354 None | Some(Value::Null) => {},
355 Some(status) => {
356 let status = status.as_i64().ok_or_else(|| {
357 protocol(HOOK_IDENTITY_RESOLVE, "`reject_status` must be an integer")
358 })?;
359 violation = violation.with_proto_error_code(status);
360 violation
361 .details
362 .insert("reject_status".into(), Value::from(status));
363 },
364 }
365 Ok(violation)
366}
367
368fn python_subject(subject: &Value) -> Value {
371 let mut subject = subject.clone();
372 if let Some(fields) = subject.as_object_mut()
373 && !fields.contains_key("subject_type")
374 && let Some(kind) = fields.remove("type")
375 {
376 fields.insert("subject_type".into(), kind);
377 }
378 subject
379}
380
381pub fn delegation_to_worker(payload: &DelegationPayload) -> Result<Value, HostError> {
388 if payload.actor_role().is_some() || !payload.actor_token().is_empty() {
389 return Err(protocol(
390 HOOK_TOKEN_DELEGATE,
391 "the delegation carries an RFC 8693 actor, which the Python DelegationPayload cannot \
392 carry; a token minted without it would lose its `act` attribution",
393 ));
394 }
395 if payload.subject() != &DelegationSubject::User {
396 return Err(protocol(
397 HOOK_TOKEN_DELEGATE,
398 "the delegation is for a subject other than `user`, and the Python DelegationPayload \
399 reads its bearer token as the user's; a plugin would attribute the minted token to \
400 the wrong principal",
401 ));
402 }
403
404 let mut json = serde_json::json!({
405 "target_name": payload.target_name(),
406 "target_type": to_json(payload.target_type())?,
407 "required_permissions": payload.required_permissions(),
408 "auth_enforced_by": to_json(&payload.auth_enforced_by())?,
409 });
410 if let Some(audience) = payload.target_audience() {
411 json["target_audience"] = Value::String(audience.to_string());
412 }
413 if let Some(domain) = payload.trust_domain() {
414 json["trust_domain"] = Value::String(domain.to_string());
415 }
416 if let Some(attenuation) = payload.route_attenuation() {
417 json["route_attenuation"] = to_json(attenuation)?;
418 }
419 Ok(json)
420}
421
422pub fn delegation_from_worker(
425 original: &DelegationPayload,
426 modified: Value,
427) -> Result<DelegationPayload, HostError> {
428 let fields = object(HOOK_TOKEN_DELEGATE, modified)?;
429 for (key, value) in &fields {
430 let known = matches!(
431 key.as_str(),
432 "delegated_token"
433 | "delegation_update"
434 | "forwarded_headers"
435 | "cache_key"
436 | "cache_ttl"
437 ) || DELEGATION_INPUT_FIELDS.contains(&key.as_str());
438 if !known && !is_empty(value) {
439 return Err(unrepresentable(HOOK_TOKEN_DELEGATE, key));
440 }
441 }
442
443 let token = match fields.get("delegated_token") {
444 None | Some(Value::Null) => None,
445 Some(Value::String(token)) if !token.trim().is_empty() => Some(token.as_str()),
446 Some(Value::String(_)) => {
447 return Err(protocol(
448 HOOK_TOKEN_DELEGATE,
449 "the plugin returned an empty `delegated_token`",
450 ));
451 },
452 Some(_) => {
453 return Err(protocol(
454 HOOK_TOKEN_DELEGATE,
455 "`delegated_token` must be a string",
456 ));
457 },
458 };
459 let forwarded = match fields.get("forwarded_headers") {
460 None | Some(Value::Null) => Map::new(),
461 Some(Value::Object(headers)) => headers.clone(),
462 Some(_) => {
463 return Err(protocol(
464 HOOK_TOKEN_DELEGATE,
465 "`forwarded_headers` must be an object",
466 ));
467 },
468 };
469
470 let mut resolved = original.clone();
471 match token {
472 Some(token) => {
473 let outbound_header = outbound_header(token, &forwarded)?;
474 let now = chrono::Utc::now();
475 let ttl = match fields.get("cache_ttl") {
476 None | Some(Value::Null) => DEFAULT_DELEGATED_TOKEN_TTL_SECS,
477 Some(ttl) => ttl.as_i64().filter(|secs| *secs > 0).ok_or_else(|| {
478 protocol(
479 HOOK_TOKEN_DELEGATE,
480 "`cache_ttl` must be a positive integer",
481 )
482 })?,
483 };
484 let audience = original
485 .target_audience()
486 .unwrap_or_else(|| original.target_name())
487 .to_string();
488 let expires_at = chrono::Duration::try_seconds(ttl)
489 .and_then(|duration| now.checked_add_signed(duration))
490 .ok_or_else(|| protocol(HOOK_TOKEN_DELEGATE, "`cache_ttl` is out of range"))?;
491 resolved.delegated_token = Some(RawDelegatedToken::new(
492 token,
493 outbound_header,
494 audience,
495 original.required_permissions().to_vec(),
496 expires_at,
497 ));
498 resolved.delegation_mode = Some(original.subject().default_mode());
499 resolved.minted_at = Some(now);
500 },
501 None if !forwarded.is_empty() => {
502 return Err(protocol(
503 HOOK_TOKEN_DELEGATE,
504 "the plugin returned `forwarded_headers` with no `delegated_token`, and a PPE \
505 delegation payload carries headers only as the minted token's outbound header",
506 ));
507 },
508 None => {},
509 }
510
511 if let Some(update) = fields.get("delegation_update").filter(|v| !v.is_null()) {
512 let update: DelegationExtension =
513 parse_lossless(HOOK_TOKEN_DELEGATE, "delegation_update", update)?;
514 if !is_default_delegation(&update) {
515 resolved.delegation_update = Some(update);
516 }
517 }
518 Ok(resolved)
519}
520
521fn outbound_header(token: &str, forwarded: &Map<String, Value>) -> Result<String, HostError> {
527 let mut entries = forwarded.iter();
528 let Some((name, value)) = entries.next() else {
529 return Ok(DEFAULT_OUTBOUND_HEADER.to_string());
530 };
531 let carries_token = value
532 .as_str()
533 .is_some_and(|v| v == token || strip_bearer(v) == token);
534 if entries.next().is_some() || !carries_token {
535 return Err(protocol(
536 HOOK_TOKEN_DELEGATE,
537 "the plugin returned `forwarded_headers` other than the single header carrying the \
538 minted token, and a PPE delegation payload cannot carry extra headers",
539 ));
540 }
541 Ok(name.clone())
542}
543
544fn is_default_delegation(update: &DelegationExtension) -> bool {
545 update.chain.is_empty()
546 && update.depth == 0
547 && update.origin_subject_id.is_none()
548 && update.actor_subject_id.is_none()
549 && !update.delegated
550}
551
552fn object(hook: &str, value: Value) -> Result<Map<String, Value>, HostError> {
555 match value {
556 Value::Object(fields) => Ok(fields),
557 _ => Err(protocol(hook, "the modified payload is not a JSON object")),
558 }
559}
560
561fn parse_lossless<T: DeserializeOwned + Serialize>(
568 hook: &str,
569 field: &str,
570 value: &Value,
571) -> Result<T, HostError> {
572 let parsed: T = serde_json::from_value(value.clone()).map_err(|_| {
573 protocol(
574 hook,
575 &format!("`{field}` does not match the PPE type it maps to"),
576 )
577 })?;
578 let back = serde_json::to_value(&parsed).map_err(|e| HostError::Protocol {
579 message: format!("could not re-serialize `{field}`: {e}"),
580 })?;
581 if let Some(path) = first_dropped(value, &back, field) {
582 return Err(unrepresentable(hook, &path));
583 }
584 Ok(parsed)
585}
586
587fn first_dropped(original: &Value, round_tripped: &Value, path: &str) -> Option<String> {
589 match (original, round_tripped) {
590 (Value::Object(fields), Value::Object(kept)) => fields.iter().find_map(|(key, value)| {
591 if is_empty(value) {
592 return None;
593 }
594 let path = format!("{path}.{key}");
595 match kept.get(key) {
596 None => Some(path),
597 Some(kept) => first_dropped(value, kept, &path),
598 }
599 }),
600 (Value::Array(items), Value::Array(kept)) => {
601 if items.len() != kept.len() {
602 return Some(path.to_string());
603 }
604 items
605 .iter()
606 .zip(kept)
607 .enumerate()
608 .find_map(|(i, (item, kept))| first_dropped(item, kept, &format!("{path}[{i}]")))
609 },
610 _ => None,
611 }
612}
613
614fn is_empty(value: &Value) -> bool {
616 match value {
617 Value::Null | Value::Bool(false) => true,
618 Value::String(s) => s.is_empty(),
619 Value::Array(items) => items.is_empty(),
620 Value::Object(fields) => fields.is_empty(),
621 Value::Bool(true) | Value::Number(_) => false,
622 }
623}
624
625fn to_json<T: Serialize>(value: &T) -> Result<Value, HostError> {
626 serde_json::to_value(value).map_err(|e| HostError::Protocol {
627 message: format!("could not serialize a delegation payload field: {e}"),
628 })
629}
630
631fn protocol(hook: &str, message: &str) -> HostError {
632 HostError::Protocol {
633 message: format!("hook '{hook}': {message}"),
634 }
635}
636
637fn unrepresentable(hook: &str, field: &str) -> HostError {
638 protocol(
639 hook,
640 &format!(
641 "the plugin returned `{field}`, which the PPE payload has nowhere to hold; refusing \
642 to drop it silently"
643 ),
644 )
645}
646
647#[cfg(test)]
648mod tests {
649 use praxis_policy_core::delegation::AttenuationConfig;
650 use praxis_policy_core::extensions::raw_credentials::TokenRole;
651 use praxis_policy_core::extensions::{DelegationHop, SubjectType};
652 use praxis_policy_core::identity::TokenSource;
653 use serde_json::json;
654
655 use super::*;
656
657 const TOKEN: &str = "eyJhbGciOiJSUzI1NiJ9.IDENTITY-PLAINTEXT.sig";
658 const MINTED: &str = "MINTED-DELEGATED-PLAINTEXT";
659
660 fn identity() -> IdentityPayload {
661 IdentityPayload::new(TOKEN, TokenSource::Bearer)
662 .with_source_header("x-user-token")
663 .with_headers(HashMap::from([
664 ("authorization".into(), format!("Bearer {TOKEN}")),
665 ("x-user-token".into(), "opaque-user-credential".into()),
666 ("cookie".into(), "session=COOKIE-SECRET".into()),
667 ("x-echo".into(), format!("copy:{TOKEN}")),
668 ("x-tenant".into(), "acme".into()),
669 ]))
670 .with_client_host("10.0.0.1")
671 .with_client_port(8443)
672 }
673
674 fn delegation() -> DelegationPayload {
675 DelegationPayload::new(TOKEN, "billing-api")
676 .with_target_audience("https://billing.example.com")
677 .with_required_permissions(vec!["invoices:read".into()])
678 .with_trust_domain("example.org")
679 .with_route_attenuation(AttenuationConfig {
680 capabilities: vec!["read".into()],
681 ttl_seconds: Some(120),
682 ..Default::default()
683 })
684 }
685
686 fn resolved(outcome: IdentityOutcome) -> IdentityPayload {
687 match outcome {
688 IdentityOutcome::Resolved(payload) => *payload,
689 IdentityOutcome::Rejected(v) => panic!("expected a resolved payload, got {v:?}"),
690 }
691 }
692
693 #[test]
696 fn identity_json_carries_no_credential_material() {
697 let json = identity_to_worker(&identity()).unwrap();
698 let wire = json.to_string();
699 assert!(!wire.contains("IDENTITY-PLAINTEXT"), "token leaked: {wire}");
700 assert!(
701 !wire.contains("opaque-user-credential"),
702 "source header leaked: {wire}"
703 );
704 assert!(!wire.contains("COOKIE-SECRET"), "cookie leaked: {wire}");
705
706 assert_eq!(json["raw_token"], "");
707 assert_eq!(json["source"], "bearer");
708 assert_eq!(json["headers"], json!({}));
709 assert_eq!(json["client_host"], "10.0.0.1");
710 assert_eq!(json["client_port"], 8443);
711 }
712
713 #[test]
714 fn identity_json_matches_the_worker_model() {
715 let json = identity_to_worker(&identity()).unwrap();
717 let mirrored: crate::legacy::IdentityResolvePayload =
718 serde_json::from_value(json).expect("the worker model accepts the shape");
719 assert_eq!(mirrored.source, "bearer");
720 }
721
722 #[test]
723 fn a_header_borne_credential_is_found_and_no_raw_headers_are_public() {
724 let payload: IdentityPayload = serde_json::from_value(json!({
727 "source": "bearer",
728 "headers": {
729 "Authorization": format!("Bearer {TOKEN}"),
730 "x-tenant": "acme",
731 "X-Amzn-Oidc-Data": "gateway-jwt",
732 "X-Request-Id": "looks-benign-but-unvalidated"
733 },
734 }))
735 .unwrap();
736 assert_eq!(inbound_token(&payload).as_deref(), Some(TOKEN));
737 assert_eq!(credential_header_name(&payload), "Authorization");
738 assert!(public_headers(&payload).is_empty());
739 }
740
741 #[test]
742 fn a_custom_source_keeps_its_wire_spelling() {
743 let payload = IdentityPayload::new(TOKEN, TokenSource::Custom("magic-link".into()));
744 assert_eq!(
745 identity_to_worker(&payload).unwrap()["source"],
746 "magic-link"
747 );
748 }
749
750 #[test]
753 fn an_identity_result_maps_onto_a_clone_of_the_dispatched_payload() {
754 let original = identity();
755 let back = resolved(
756 identity_from_worker(
757 &original,
758 json!({
759 "subject": {
760 "id": "alice", "type": "user", "roles": ["hr"],
761 "permissions": [], "teams": [], "claims": {"dept": "people"},
762 },
763 "delegation": null,
764 "rejected": false,
765 "reject_status": 401,
766 "reject_reason": "",
767 "raw_claims": {"sub": "alice"},
768 }),
769 )
770 .unwrap(),
771 );
772
773 let subject = back.subject.as_ref().expect("the subject is written");
774 assert_eq!(subject.id.as_deref(), Some("alice"));
775 assert_eq!(subject.subject_type, Some(SubjectType::User));
776 assert!(subject.roles.contains("hr"));
777 assert_eq!(subject.claims["dept"], "people");
778 assert_eq!(back.raw_claims["sub"], "alice");
779
780 assert_eq!(back.raw_token(), TOKEN);
783 assert_eq!(back.source_header(), Some("x-user-token"));
784 assert_eq!(back.headers().len(), original.headers().len());
785 }
786
787 #[test]
788 fn a_resolved_identity_survives_ppes_pipeline_downcast() {
789 let back = resolved(
790 identity_from_worker(
791 &identity(),
792 json!({"subject": {"id": "alice", "type": "user"}}),
793 )
794 .unwrap(),
795 );
796 let boxed: Box<dyn PluginPayload> = Box::new(back);
797 let result = praxis_policy_core::executor::PipelineResult::allowed_with(
798 boxed,
799 praxis_policy_core::extensions::Extensions::default(),
800 Default::default(),
801 );
802 let extracted = IdentityPayload::from_pipeline_result(&result)
803 .expect("PPE downcasts the modified payload to IdentityPayload");
804 assert_eq!(
805 extracted.subject.and_then(|s| s.id).as_deref(),
806 Some("alice")
807 );
808 }
809
810 #[test]
811 fn an_echoed_identity_payload_changes_nothing() {
812 let original = identity();
813 let echo = json!({
814 "raw_token": "**********", "source": "custom",
815 "headers": {"x-injected": "1"}, "client_host": "6.6.6.6", "client_port": 1,
816 });
817 let back = resolved(identity_from_worker(&original, echo).unwrap());
818 assert!(back.subject.is_none());
819 assert_eq!(
820 back.client_host(),
821 Some("10.0.0.1"),
822 "inputs stay canonical"
823 );
824 assert_eq!(back.raw_token(), TOKEN);
825 }
826
827 #[test]
828 fn a_rejected_identity_becomes_a_deny() {
829 let outcome = identity_from_worker(
830 &identity(),
831 json!({
832 "subject": {"id": "alice", "type": "user"},
833 "rejected": true, "reject_status": 403, "reject_reason": "token expired",
834 }),
835 )
836 .unwrap();
837 let IdentityOutcome::Rejected(violation) = outcome else {
838 panic!("rejected must deny even when a subject is set");
839 };
840 assert_eq!(violation.code, "auth.rejected");
841 assert_eq!(violation.reason, "token expired");
842 assert_eq!(violation.proto_error_code, Some(403));
843 assert_eq!(violation.details["reject_status"], 403);
844 }
845
846 #[test]
847 fn an_unknown_identity_result_field_fails_closed() {
848 let err = identity_from_worker(&identity(), json!({"client": {"id": "gw"}})).unwrap_err();
849 assert!(matches!(err, HostError::Protocol { .. }), "{err:?}");
850 assert!(err.to_string().contains("`client`"), "{err}");
851 }
852
853 #[test]
854 fn a_subject_field_ppe_cannot_hold_fails_closed() {
855 let err = identity_from_worker(
856 &identity(),
857 json!({"subject": {"id": "alice", "type": "user", "tenant": "acme"}}),
858 )
859 .unwrap_err();
860 assert!(err.to_string().contains("subject.tenant"), "{err}");
861 }
862
863 #[test]
864 fn a_malformed_subject_fails_closed_without_echoing_values() {
865 let err = identity_from_worker(
866 &identity(),
867 json!({"subject": {"id": "SUBJECT-VALUE", "type": "SUBJECT-VALUE"}}),
868 )
869 .unwrap_err();
870 assert!(!err.to_string().contains("SUBJECT-VALUE"), "{err}");
871 }
872
873 #[test]
876 fn delegation_json_matches_the_worker_model_and_carries_no_token() {
877 let json = delegation_to_worker(&delegation()).unwrap();
878 assert!(!json.to_string().contains("IDENTITY-PLAINTEXT"), "{json}");
879 assert!(json.get("bearer_token").is_none());
880
881 let mirrored: crate::legacy::TokenDelegatePayload =
882 serde_json::from_value(json.clone()).expect("the worker model accepts the shape");
883 assert_eq!(mirrored.target_name, "billing-api");
884 assert_eq!(mirrored.target_type, "tool");
885 assert_eq!(
886 mirrored.target_audience.as_deref(),
887 Some("https://billing.example.com")
888 );
889 assert_eq!(mirrored.required_permissions, vec!["invoices:read"]);
890 assert_eq!(mirrored.trust_domain.as_deref(), Some("example.org"));
891 assert_eq!(mirrored.auth_enforced_by, "caller");
892 assert_eq!(json["route_attenuation"]["ttl_seconds"], 120);
893 }
894
895 #[test]
896 fn a_delegation_with_an_actor_fails_closed() {
897 let payload = delegation().with_actor(TokenRole::CallerWorkload, "svid");
898 let err = delegation_to_worker(&payload).unwrap_err();
899 assert!(err.to_string().contains("actor"), "{err}");
900 }
901
902 #[test]
903 fn a_delegation_for_another_subject_fails_closed() {
904 let payload = delegation().with_subject(DelegationSubject::ThisWorkload);
905 let err = delegation_to_worker(&payload).unwrap_err();
906 assert!(err.to_string().contains("subject"), "{err}");
907 }
908
909 #[test]
912 fn a_delegation_result_maps_onto_a_clone_of_the_dispatched_payload() {
913 let original = delegation();
914 let back = delegation_from_worker(
915 &original,
916 json!({
917 "delegated_token": MINTED,
918 "delegation_update": {
919 "chain": [{"subject_id": "alice", "subject_type": "user",
920 "audience": "https://billing.example.com",
921 "scopes_granted": ["invoices:read"],
922 "timestamp": "2026-01-01T00:00:00Z",
923 "ttl_seconds": null, "strategy": "token_exchange",
924 "from_cache": false}],
925 "depth": 1, "origin_subject_id": "alice", "actor_subject_id": "alice",
926 "delegated": true, "age_seconds": 0.0,
927 },
928 "forwarded_headers": {"X-Upstream-Auth": format!("Bearer {MINTED}")},
929 "cache_key": "k",
930 "cache_ttl": 300,
931 }),
932 )
933 .unwrap();
934
935 let token = back.delegated_token.as_ref().expect("the token is written");
936 assert_eq!(token.token.as_str(), MINTED);
937 assert_eq!(token.outbound_header, "X-Upstream-Auth");
938 assert_eq!(token.audience, "https://billing.example.com");
939 assert_eq!(token.scopes, vec!["invoices:read"]);
940 let ttl = token.expires_at - chrono::Utc::now();
941 assert!(ttl.num_seconds() > 250 && ttl.num_seconds() <= 300, "{ttl}");
942 assert_eq!(
943 back.delegation_mode,
944 Some(praxis_policy_core::extensions::raw_credentials::DelegationMode::OnBehalfOfUser)
945 );
946 let update = back
947 .delegation_update
948 .as_ref()
949 .expect("the chain update is written");
950 assert_eq!(update.depth, 1);
951 assert_eq!(update.chain.len(), 1);
952
953 assert_eq!(back.bearer_token(), TOKEN);
955
956 let boxed: Box<dyn PluginPayload> = Box::new(back);
957 let result = praxis_policy_core::executor::PipelineResult::allowed_with(
958 boxed,
959 praxis_policy_core::extensions::Extensions::default(),
960 Default::default(),
961 );
962 assert!(
963 DelegationPayload::from_pipeline_result(&result).is_some(),
964 "PPE downcasts the modified payload to DelegationPayload"
965 );
966 }
967
968 #[test]
969 fn a_minimal_delegation_result_uses_the_defaults() {
970 let back = delegation_from_worker(
971 &delegation(),
972 json!({"delegated_token": MINTED, "delegation_update": {
973 "chain": [], "depth": 0, "origin_subject_id": null,
974 "actor_subject_id": null, "delegated": false, "age_seconds": 0.0,
975 }, "forwarded_headers": {}, "cache_key": null, "cache_ttl": null}),
976 )
977 .unwrap();
978 let token = back.delegated_token.unwrap();
979 assert_eq!(token.outbound_header, DEFAULT_OUTBOUND_HEADER);
980 assert!(
981 back.delegation_update.is_none(),
982 "an empty default update is no update"
983 );
984 }
985
986 #[test]
987 fn out_of_range_delegation_ttl_is_a_protocol_error() {
988 let err = delegation_from_worker(
989 &delegation(),
990 json!({"delegated_token": MINTED, "cache_ttl": i64::MAX}),
991 )
992 .unwrap_err();
993 assert!(matches!(err, HostError::Protocol { .. }), "{err:?}");
994 assert!(err.to_string().contains("`cache_ttl` is out of range"));
995 }
996
997 #[test]
998 fn extra_forwarded_headers_fail_closed() {
999 for forwarded in [
1000 json!({"Authorization": format!("Bearer {MINTED}"), "X-Extra": "1"}),
1001 json!({"X-Other": "not-the-token"}),
1002 ] {
1003 let err = delegation_from_worker(
1004 &delegation(),
1005 json!({"delegated_token": MINTED, "forwarded_headers": forwarded}),
1006 )
1007 .unwrap_err();
1008 assert!(err.to_string().contains("forwarded_headers"), "{err}");
1009 assert!(!err.to_string().contains(MINTED), "{err}");
1010 }
1011 }
1012
1013 #[test]
1014 fn forwarded_headers_without_a_token_fail_closed() {
1015 let err = delegation_from_worker(
1016 &delegation(),
1017 json!({"forwarded_headers": {"Authorization": "Bearer x"}}),
1018 )
1019 .unwrap_err();
1020 assert!(err.to_string().contains("forwarded_headers"), "{err}");
1021 }
1022
1023 #[test]
1024 fn an_unknown_delegation_result_field_fails_closed() {
1025 let err = delegation_from_worker(
1026 &delegation(),
1027 json!({"delegated_token": MINTED, "refresh_token": "r"}),
1028 )
1029 .unwrap_err();
1030 assert!(err.to_string().contains("refresh_token"), "{err}");
1031 assert!(!err.to_string().contains(MINTED), "{err}");
1032 }
1033
1034 #[test]
1035 fn a_delegation_hop_field_ppe_cannot_hold_fails_closed() {
1036 let hop = serde_json::to_value(DelegationHop {
1037 subject_id: "alice".into(),
1038 ..Default::default()
1039 })
1040 .unwrap();
1041 let mut hop = hop.as_object().unwrap().clone();
1042 hop.insert("issuer".into(), json!("idp"));
1043 let err = delegation_from_worker(
1044 &delegation(),
1045 json!({"delegation_update": {"chain": [hop], "depth": 1}}),
1046 )
1047 .unwrap_err();
1048 assert!(
1049 err.to_string()
1050 .contains("delegation_update.chain[0].issuer"),
1051 "{err}"
1052 );
1053 }
1054
1055 #[test]
1056 fn no_token_and_no_update_returns_the_payload_unchanged() {
1057 let back =
1058 delegation_from_worker(&delegation(), json!({"target_name": "billing-api"})).unwrap();
1059 assert!(back.delegated_token.is_none());
1060 assert!(back.delegation_update.is_none());
1061 }
1062}