cpex_core/delegation/payload.rs
1// Location: ./crates/cpex-core/src/delegation/payload.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `DelegationPayload` — the unified state struct threaded through the
7// TokenDelegate hook chain. Same input/output split pattern as
8// `IdentityPayload` (slice 2):
9//
10// * **Input** (private — host-supplied, never mutated by handlers) —
11// `bearer_token`, `target_name`, `target_type`, `target_audience`,
12// `required_permissions`, `trust_domain`, `auth_enforced_by`,
13// `route_attenuation`. Set once at the call site that needs to mint
14// a downstream credential. Privacy is enforced at the module
15// boundary: external code reads through accessors and has no
16// setters or mutable field access.
17//
18// * **Accumulating output** (`pub` fields) — `delegated_token` and
19// `delegation_update`. Handlers clone the payload, populate these,
20// return the updated payload via `PluginResult::modify_payload`.
21//
22// # Where this hook fits
23//
24// IdentityResolve (slice 2) is *inbound* — validates the caller's
25// credentials at request entry, populates `security.subject` /
26// `security.client` / `security.caller_workload`. TokenDelegate is
27// *outbound* — when a plugin (typically a forwarding proxy) needs to
28// make a downstream call to a tool or agent, it asks for an
29// appropriately-scoped credential for that target. A handler (RFC
30// 8693 token exchanger, UCAN minter, passthrough) produces the
31// minted token; the framework stashes it in
32// `Extensions.raw_credentials.delegated_tokens` for the proxy plugin
33// to attach on the upstream request.
34//
35// # Caching
36//
37// Not in this slice. The spec describes a `TokenCacheControl` trait
38// at §9.8 that wraps this hook with `get_or_mint(audience, scopes)`
39// semantics — outbound callers ask the trait for a token; the trait
40// hits the cache first and only dispatches through the hook on cache
41// miss. That layer lives one slice later. For now, every
42// `mgr.invoke_named::<TokenDelegateHook>(...)` re-runs the chain.
43//
44// # Rejection
45//
46// Same as IdentityResolve: handlers reject via
47// `PluginResult::deny(PluginViolation::new(code, reason))`. The
48// executor halts the chain; no later handler runs and the request
49// fails with the violation surfaced to the host. No `rejected` flag
50// on the payload.
51
52use std::collections::HashMap;
53use std::sync::Arc;
54
55use chrono::{DateTime, Utc};
56use serde::{Deserialize, Serialize};
57use zeroize::Zeroizing;
58
59use crate::executor::PipelineResult;
60use crate::extensions::raw_credentials::DelegationMode;
61use crate::extensions::{
62 DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken,
63};
64use crate::impl_plugin_payload;
65
66/// Kind of downstream entity the credential is being minted for.
67/// `Custom(String)` is the escape hatch for host-defined entity
68/// types beyond the well-known shapes.
69#[non_exhaustive]
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum TargetType {
73 /// A tool invocation (MCP tool, function call).
74 Tool,
75 /// An agent — another LLM-driven actor.
76 Agent,
77 /// A static resource (file, URL, document store entry).
78 Resource,
79 /// A service (microservice, internal API).
80 Service,
81 /// Operator-defined target kind.
82 #[serde(untagged)]
83 Custom(String),
84}
85
86impl Default for TargetType {
87 fn default() -> Self {
88 TargetType::Tool
89 }
90}
91
92/// Who's responsible for enforcing authorization on the downstream
93/// call. From the `ObjectSecurityProfile` of the target. Determines
94/// whether the gateway brokers credentials (`Caller`), trusts the
95/// target to handle auth itself (`Target`), or both layers enforce
96/// (`Both`).
97#[non_exhaustive]
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum AuthEnforcedBy {
101 /// Caller (the gateway / our process) enforces — typical for
102 /// internal services that trust the gateway's authorization
103 /// decision.
104 Caller,
105 /// Target enforces — typical for external services with their
106 /// own access control. We may still attach credentials but the
107 /// downstream makes the final allow/deny decision.
108 Target,
109 /// Both layers enforce — defense in depth.
110 Both,
111}
112
113impl Default for AuthEnforcedBy {
114 fn default() -> Self {
115 AuthEnforcedBy::Caller
116 }
117}
118
119/// Scope-attenuation config carried from the route DSL. Lets the
120/// route author narrow what the minted credential is allowed to do
121/// beyond the broad authorization the inbound credential carried.
122///
123/// `resource_template` is a templated URI (e.g.
124/// `"hr://employees/{{ args.employee_id }}"`) that the framework
125/// renders against request-time arguments before passing into the
126/// minted token's scope claim. v0 doesn't include a template
127/// renderer — handlers receive the raw template string and render
128/// themselves; a framework-side renderer can come later.
129#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct AttenuationConfig {
131 /// Specific capabilities the route author wants granted.
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub capabilities: Vec<String>,
134
135 /// URI template for the resource being accessed. Unrendered —
136 /// handlers substitute `{{ args.* }}` placeholders themselves
137 /// using request context.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub resource_template: Option<String>,
140
141 /// Actions allowed on the resource (read / write / delete /
142 /// custom verbs).
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub actions: Vec<String>,
145
146 /// Token lifetime override in seconds. `None` lets the handler
147 /// pick its default.
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub ttl_seconds: Option<u64>,
150}
151
152/// State threaded through the TokenDelegate hook chain.
153///
154/// See the module-level docs for the input/output split. Input
155/// fields are private (set once via the constructor + builders,
156/// never mutated). Output fields are `pub` (handlers populate on
157/// clones and return the updated payload).
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct DelegationPayload {
160 // ----- Input (private — caller-supplied, never mutated by handlers) -----
161 /// The caller's current credential — the one a token-exchange
162 /// handler will swap for a downstream-scoped credential. Cleared
163 /// on drop via `Zeroizing`. `#[serde(skip)]` — never appears in
164 /// serialized output.
165 #[serde(skip)]
166 bearer_token: Zeroizing<String>,
167
168 /// Name of the tool / agent / resource being called.
169 target_name: String,
170
171 /// Kind of downstream entity.
172 #[serde(default)]
173 target_type: TargetType,
174
175 /// Audience URI for the target, from route config.
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 target_audience: Option<String>,
178
179 /// Required permissions from the target's `ObjectSecurityProfile`.
180 /// Handlers must produce a credential that grants these (or fail).
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 required_permissions: Vec<String>,
183
184 /// Target's trust domain (SPIFFE-style) — useful for handlers
185 /// that mint workload-identity tokens.
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 trust_domain: Option<String>,
188
189 /// Who's responsible for enforcing authorization.
190 #[serde(default)]
191 auth_enforced_by: AuthEnforcedBy,
192
193 /// Scope-attenuation config from the route DSL.
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 route_attenuation: Option<AttenuationConfig>,
196
197 // ----- Output (pub — handlers populate via direct assignment on clones) -----
198 /// The minted outbound credential. `None` until a handler
199 /// produces one. Carries the raw bytes (cleared on drop), the
200 /// header the proxy plugin should attach it under, the
201 /// audience it was minted for, the effective scopes, and the
202 /// expiry timestamp.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub delegated_token: Option<RawDelegatedToken>,
205
206 /// Chain update — the new hop to append to the running
207 /// `DelegationExtension`. Handlers append themselves to the
208 /// chain so audit / policy can trace who delegated to whom.
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub delegation_update: Option<DelegationExtension>,
211
212 /// What kind of principal the minted token represents.
213 /// Handlers populating `delegated_token` should also set this
214 /// so `apply_to_extensions` keys the cache correctly:
215 ///
216 /// * `OnBehalfOfUser` — token speaks for the original user
217 /// (RFC 8693 on-behalf-of / actor-token, UCAN delegation).
218 /// Standard flow; cache key includes the user's subject id.
219 /// * `AsGateway` — token speaks for the gateway itself.
220 /// User identity is conveyed through separate context.
221 /// Cache key falls back to the gateway's identity.
222 ///
223 /// `None` defaults to `OnBehalfOfUser` for backward compatibility
224 /// with handlers that don't yet populate the field. Long-term,
225 /// handlers should always set this explicitly.
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub delegation_mode: Option<DelegationMode>,
228
229 /// Resolution timestamp. Audit-useful.
230 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub minted_at: Option<DateTime<Utc>>,
232
233 /// Optional metadata produced by the handler (telemetry,
234 /// diagnostics). Not load-bearing for policy.
235 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
236 pub metadata: HashMap<String, serde_json::Value>,
237}
238
239impl DelegationPayload {
240 /// Construct a payload with the required input fields populated.
241 /// The most common entry point — outbound callers (forwarding
242 /// proxies, etc.) build this once per delegation point. Optional
243 /// input slots are set via the `.with_*` builders below; output
244 /// fields start as `None` / empty and accumulate as handlers run.
245 pub fn new(bearer_token: impl Into<String>, target_name: impl Into<String>) -> Self {
246 Self {
247 bearer_token: Zeroizing::new(bearer_token.into()),
248 target_name: target_name.into(),
249 target_type: TargetType::Tool,
250 target_audience: None,
251 required_permissions: Vec::new(),
252 trust_domain: None,
253 auth_enforced_by: AuthEnforcedBy::Caller,
254 route_attenuation: None,
255 delegated_token: None,
256 delegation_update: None,
257 delegation_mode: None,
258 minted_at: None,
259 metadata: HashMap::new(),
260 }
261 }
262
263 // -------- Input builders --------
264
265 pub fn with_target_type(mut self, t: TargetType) -> Self {
266 self.target_type = t;
267 self
268 }
269
270 pub fn with_target_audience(mut self, aud: impl Into<String>) -> Self {
271 self.target_audience = Some(aud.into());
272 self
273 }
274
275 pub fn with_required_permissions(mut self, perms: Vec<String>) -> Self {
276 self.required_permissions = perms;
277 self
278 }
279
280 pub fn with_trust_domain(mut self, td: impl Into<String>) -> Self {
281 self.trust_domain = Some(td.into());
282 self
283 }
284
285 pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self {
286 self.auth_enforced_by = who;
287 self
288 }
289
290 pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self {
291 self.route_attenuation = Some(cfg);
292 self
293 }
294
295 // -------- Input read accessors --------
296
297 /// The caller's bearer token — borrowed, no way to move or
298 /// replace the underlying `Zeroizing<String>` through this.
299 pub fn bearer_token(&self) -> &str {
300 &self.bearer_token
301 }
302
303 pub fn target_name(&self) -> &str {
304 &self.target_name
305 }
306
307 pub fn target_type(&self) -> &TargetType {
308 &self.target_type
309 }
310
311 pub fn target_audience(&self) -> Option<&str> {
312 self.target_audience.as_deref()
313 }
314
315 pub fn required_permissions(&self) -> &[String] {
316 &self.required_permissions
317 }
318
319 pub fn trust_domain(&self) -> Option<&str> {
320 self.trust_domain.as_deref()
321 }
322
323 pub fn auth_enforced_by(&self) -> AuthEnforcedBy {
324 self.auth_enforced_by
325 }
326
327 pub fn route_attenuation(&self) -> Option<&AttenuationConfig> {
328 self.route_attenuation.as_ref()
329 }
330
331 // -------- Output helpers --------
332
333 /// Layer another payload's *output* fields onto this one's,
334 /// following "Some replaces None, last write wins per slot."
335 /// Input fields are not touched — the running payload's input
336 /// is canonical for the whole chain.
337 ///
338 /// Metadata is merged (not replaced) — `other`'s keys overlay
339 /// `self`'s, matching the "later handler additively contributes
340 /// telemetry" expectation.
341 pub fn merge(&mut self, other: DelegationPayload) {
342 if other.delegated_token.is_some() {
343 self.delegated_token = other.delegated_token;
344 }
345 if other.delegation_update.is_some() {
346 self.delegation_update = other.delegation_update;
347 }
348 if other.delegation_mode.is_some() {
349 self.delegation_mode = other.delegation_mode;
350 }
351 if other.minted_at.is_some() {
352 self.minted_at = other.minted_at;
353 }
354 for (k, v) in other.metadata {
355 self.metadata.insert(k, v);
356 }
357 }
358
359 // -------- Host-side application helpers --------
360
361 /// Pull the resolved `DelegationPayload` out of a `PipelineResult`
362 /// returned by `mgr.invoke_named::<TokenDelegateHook>(...)`.
363 /// Returns `None` when the pipeline was denied or when the result's
364 /// payload wasn't a `DelegationPayload`. Same contract as
365 /// `IdentityPayload::from_pipeline_result`.
366 pub fn from_pipeline_result(result: &PipelineResult) -> Option<Self> {
367 result
368 .modified_payload
369 .as_ref()
370 .and_then(|p| p.as_any().downcast_ref::<DelegationPayload>())
371 .cloned()
372 }
373
374 /// Apply this payload's resolved output slots back into an
375 /// `Extensions` container. Returns a new `Extensions` ready to
376 /// hand to the outbound proxy plugin that will attach the minted
377 /// credential and forward.
378 ///
379 /// Application rules:
380 ///
381 /// - **`raw_credentials.delegated_tokens`** — if the payload
382 /// carries a `delegated_token`, it's inserted into the map under
383 /// a `DelegationKey` derived from the input fields (audience,
384 /// subject not yet plumbed — see "Open work" below). Pre-existing
385 /// delegated tokens are preserved.
386 /// - **`delegation`** — `delegation_update` overlays on top of
387 /// the existing chain (Some replaces None / appends).
388 ///
389 /// # Open work
390 ///
391 /// The `DelegationKey` we synthesize here uses only fields the
392 /// payload knows about — `audience`, `scopes` (derived from the
393 /// effective scopes on the minted token), `mode`. The `subject_id`
394 /// field of `DelegationKey` requires reading the request's
395 /// `Extensions.security.subject.id`; we plumb that lookup here
396 /// rather than asking outbound callers to thread the subject
397 /// through. If `security.subject.id` is absent the key falls back
398 /// to the empty string — flagged via tracing but not fatal,
399 /// because some delegation flows are gateway-as-principal
400 /// (AsGateway mode) and don't need a subject.
401 pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions {
402 if let Some(ref token) = self.delegated_token {
403 use crate::extensions::raw_credentials::DelegationKey;
404
405 let subject_id = ext
406 .security
407 .as_ref()
408 .and_then(|s| s.subject.as_ref())
409 .and_then(|s| s.id.clone())
410 .unwrap_or_default();
411
412 // Default to OnBehalfOfUser when the handler didn't
413 // populate `delegation_mode`. Backward-compatible with
414 // handlers from sub-step B; future handlers should
415 // populate the field explicitly.
416 let mode = self
417 .delegation_mode
418 .clone()
419 .unwrap_or(DelegationMode::OnBehalfOfUser);
420 let key = DelegationKey {
421 subject_id,
422 audience: token.audience.clone(),
423 scopes: token.scopes.clone(),
424 mode,
425 };
426
427 let mut raw = ext
428 .raw_credentials
429 .as_ref()
430 .map(|arc| (**arc).clone())
431 .unwrap_or_else(RawCredentialsExtension::default);
432 raw.delegated_tokens.insert(key, token.clone());
433 ext.raw_credentials = Some(Arc::new(raw));
434 }
435
436 if let Some(ref update) = self.delegation_update {
437 // Replace wholesale for v0. A per-hop append semantics
438 // would deep-merge the chain, but `DelegationExtension`'s
439 // append rules live with the type — handlers that want
440 // to add a hop produce a `DelegationExtension` containing
441 // the new hop in its chain.
442 ext.delegation = Some(Arc::new(update.clone()));
443 }
444
445 ext
446 }
447}
448
449impl_plugin_payload!(DelegationPayload);
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::extensions::raw_credentials::RawDelegatedToken;
455
456 #[test]
457 fn bearer_token_does_not_serialize() {
458 let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation");
459 let json = serde_json::to_string(&p).unwrap();
460 assert!(
461 !json.contains("eyJ.caller.tok"),
462 "bearer_token leaked into serialized form: {}",
463 json,
464 );
465 assert!(json.contains("get_compensation"));
466 }
467
468 #[test]
469 fn deserialize_yields_empty_bearer_token() {
470 let json = r#"{"target_name":"get_compensation"}"#;
471 let p: DelegationPayload = serde_json::from_str(json).unwrap();
472 assert_eq!(p.bearer_token(), "");
473 assert_eq!(p.target_name(), "get_compensation");
474 }
475
476 #[test]
477 fn input_builders_chain() {
478 let p = DelegationPayload::new("tok", "get_compensation")
479 .with_target_type(TargetType::Tool)
480 .with_target_audience("https://hr.example.com")
481 .with_required_permissions(vec!["read:compensation".into()])
482 .with_trust_domain("hr.example.com")
483 .with_auth_enforced_by(AuthEnforcedBy::Target)
484 .with_route_attenuation(AttenuationConfig {
485 capabilities: vec!["read:compensation".into()],
486 resource_template: Some("hr://employees/{{ args.employee_id }}".into()),
487 actions: vec!["read".into()],
488 ttl_seconds: Some(60),
489 });
490 assert_eq!(p.bearer_token(), "tok");
491 assert_eq!(p.target_name(), "get_compensation");
492 assert_eq!(p.target_audience(), Some("https://hr.example.com"));
493 assert_eq!(p.required_permissions(), &["read:compensation".to_string()]);
494 assert_eq!(p.trust_domain(), Some("hr.example.com"));
495 assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target);
496 let att = p.route_attenuation().unwrap();
497 assert_eq!(att.ttl_seconds, Some(60));
498 assert_eq!(att.actions, vec!["read"]);
499 }
500
501 #[test]
502 fn target_type_custom_round_trips() {
503 let t = TargetType::Custom("workflow".into());
504 let json = serde_json::to_string(&t).unwrap();
505 let back: TargetType = serde_json::from_str(&json).unwrap();
506 assert_eq!(t, back);
507 }
508
509 #[test]
510 fn handler_can_populate_output_on_clone() {
511 // Typical handler pattern: clone running payload, set
512 // delegated_token + delegation_update, return.
513 let original = DelegationPayload::new("caller-tok", "downstream-tool");
514 let mut updated = original.clone();
515 updated.delegated_token = Some(RawDelegatedToken::new(
516 "minted-bytes",
517 "Authorization",
518 "https://api.example.com",
519 vec!["read".into()],
520 Utc::now(),
521 ));
522 // Input survives the clone.
523 assert_eq!(updated.bearer_token(), "caller-tok");
524 assert_eq!(updated.target_name(), "downstream-tool");
525 // Output populated.
526 assert!(updated.delegated_token.is_some());
527 // Original untouched.
528 assert!(original.delegated_token.is_none());
529 }
530
531 #[test]
532 fn merge_overlays_outputs() {
533 let mut base = DelegationPayload::new("tok", "tool");
534 base.metadata.insert("attempt".into(), serde_json::json!(1));
535 let mut overlay = DelegationPayload::new("", "");
536 overlay.delegated_token = Some(RawDelegatedToken::new(
537 "x",
538 "Authorization",
539 "aud",
540 vec![],
541 Utc::now(),
542 ));
543 overlay
544 .metadata
545 .insert("latency_ms".into(), serde_json::json!(42));
546 base.merge(overlay);
547 assert!(base.delegated_token.is_some());
548 // Metadata merged additively — both keys present.
549 assert!(base.metadata.contains_key("attempt"));
550 assert!(base.metadata.contains_key("latency_ms"));
551 }
552
553 #[test]
554 fn apply_to_extensions_writes_delegated_token_keyed_by_audience() {
555 use crate::extensions::raw_credentials::DelegationMode;
556 use crate::extensions::SubjectExtension;
557
558 let mut p = DelegationPayload::new("tok", "get_compensation");
559 p.delegated_token = Some(RawDelegatedToken::new(
560 "minted-jwt",
561 "Authorization",
562 "https://hr.example.com",
563 vec!["read:compensation".into()],
564 Utc::now() + chrono::Duration::seconds(300),
565 ));
566
567 // Pre-existing subject in extensions — DelegationKey.subject_id
568 // should pull from there.
569 let initial_ext = Extensions {
570 security: Some(Arc::new(crate::extensions::SecurityExtension {
571 subject: Some(SubjectExtension {
572 id: Some("alice".into()),
573 ..Default::default()
574 }),
575 ..Default::default()
576 })),
577 ..Default::default()
578 };
579
580 let updated = p.apply_to_extensions(initial_ext);
581 let raw = updated.raw_credentials.as_ref().unwrap();
582 assert_eq!(raw.delegated_tokens.len(), 1);
583
584 // Look up by the synthesized key.
585 let expected_key = crate::extensions::raw_credentials::DelegationKey {
586 subject_id: "alice".into(),
587 audience: "https://hr.example.com".into(),
588 scopes: vec!["read:compensation".into()],
589 mode: DelegationMode::OnBehalfOfUser,
590 };
591 assert!(raw.delegated_tokens.contains_key(&expected_key));
592 }
593
594 #[test]
595 fn apply_to_extensions_respects_explicit_delegation_mode() {
596 // Handler that mints an AsGateway-mode token (gateway-as-principal
597 // flow). The key in `delegated_tokens` should carry AsGateway,
598 // not the default OnBehalfOfUser.
599 let mut p = DelegationPayload::new("tok", "tool");
600 p.delegated_token = Some(RawDelegatedToken::new(
601 "gateway-token",
602 "Authorization",
603 "https://downstream.example.com",
604 vec!["service:call".into()],
605 Utc::now(),
606 ));
607 p.delegation_mode = Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
608
609 let updated = p.apply_to_extensions(Extensions::default());
610 let raw = updated.raw_credentials.as_ref().unwrap();
611 let key = raw.delegated_tokens.keys().next().unwrap();
612 assert!(matches!(
613 key.mode,
614 crate::extensions::raw_credentials::DelegationMode::AsGateway
615 ));
616 }
617
618 #[test]
619 fn apply_to_extensions_defaults_delegation_mode_when_unset() {
620 // Handler that didn't populate delegation_mode — apply should
621 // use OnBehalfOfUser as the safe default.
622 let mut p = DelegationPayload::new("tok", "tool");
623 p.delegated_token = Some(RawDelegatedToken::new(
624 "user-token",
625 "Authorization",
626 "https://aud.example.com",
627 vec!["read".into()],
628 Utc::now(),
629 ));
630 // delegation_mode left None.
631 let updated = p.apply_to_extensions(Extensions::default());
632 let raw = updated.raw_credentials.as_ref().unwrap();
633 let key = raw.delegated_tokens.keys().next().unwrap();
634 assert!(matches!(
635 key.mode,
636 crate::extensions::raw_credentials::DelegationMode::OnBehalfOfUser
637 ));
638 }
639
640 #[test]
641 fn merge_threads_delegation_mode_through_chain() {
642 // Handler A leaves delegation_mode unset; handler B sets it.
643 // After merge, the accumulator should carry handler B's mode.
644 let mut base = DelegationPayload::new("tok", "tool");
645 // base.delegation_mode = None
646 let mut overlay = DelegationPayload::new("", "");
647 overlay.delegation_mode =
648 Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
649 base.merge(overlay);
650 assert!(matches!(
651 base.delegation_mode,
652 Some(crate::extensions::raw_credentials::DelegationMode::AsGateway)
653 ));
654 }
655
656 #[test]
657 fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() {
658 // Gateway-as-principal flow — no Subject extension present.
659 // The DelegationKey falls back to empty subject_id rather
660 // than panicking; flagged via tracing in production but
661 // not fatal here.
662 let mut p = DelegationPayload::new("tok", "tool");
663 p.delegated_token = Some(RawDelegatedToken::new(
664 "minted",
665 "Authorization",
666 "aud",
667 vec![],
668 Utc::now(),
669 ));
670 let updated = p.apply_to_extensions(Extensions::default());
671 let raw = updated.raw_credentials.as_ref().unwrap();
672 let key = raw.delegated_tokens.keys().next().unwrap();
673 assert_eq!(key.subject_id, "");
674 }
675
676 #[test]
677 fn auth_enforced_by_defaults_to_caller() {
678 let p = DelegationPayload::new("tok", "tool");
679 assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller);
680 }
681
682 #[test]
683 fn target_type_defaults_to_tool() {
684 let p = DelegationPayload::new("tok", "tool");
685 assert_eq!(p.target_type(), &TargetType::Tool);
686 }
687}