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