apl_cpex/session_resolver.rs
1// Location: ./crates/apl-cpex/src/session_resolver.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// 3-tier session-id resolver. The Python apl-plugins `SessionResolver`
7// (cpex/framework/session.py) shipped a 4-tier version including a
8// client-supplied `X-CPEX-Session-Id` header tier. **That tier is
9// excluded by design here**: an authenticated client can set the
10// header to another subject's known session id and inherit their
11// accumulated taint labels, or to a new value and escape their own
12// tainted session — defeating `session.labels`-based deny policies
13// entirely. The Python comment framed the header as a feature ("lets
14// a smart client maintain its own session boundary"); under threat
15// modeling it is a privilege-escalation channel with no surviving
16// use case the other tiers don't cover. If a future deployment needs
17// client-supplied session grouping, the right shape is a subject-
18// bound hash (`sha256(subject_id : client_value)`), not the raw
19// header value.
20//
21// The resolver walks these tiers in order, returning the first hit:
22//
23// 0. `agent` — `AgentExtension.session_id`. A *pre-resolved*
24// value: an upstream plugin or middleware decided what the
25// session is and wrote it here (for the FFI/AuthBridge path this
26// is the client `X-Session-Id` header / A2A contextId). Highest
27// priority among sources, but **subject-bound** before use
28// (`sha256(subject_id : value)`): the raw value is attacker-chosen,
29// so it must only scope state WITHIN the authenticated subject,
30// never across principals. Falls through when no subject is present.
31//
32// 1. `token_claim` — explicit `session_id` claim in the inbound JWT.
33// Strongest binding among the *derived* tiers: the auth issuer
34// chose this session and signed it into the token. Read from
35// `SecurityExtension.subject.claims["session_id"]` and **subject-
36// bound** the same way (a signed claim is per-issuer and may repeat
37// across principals, so the key must still include the subject).
38//
39// 2. `identity` — derived: sha256(sub : caller_workload : this_workload)[:16].
40// No special infrastructure needed; the triple is already populated
41// by `cpex-plugin-identity-jwt`'s claim mapping. Same user + same agent +
42// same gateway = same session, stable across token refresh (the
43// claims are stable even when the token string isn't).
44//
45// 3. `none` — no usable identifier; caller (CmfPluginInvoker)
46// skips hydration / persistence. Returns `Ok(None)` so the caller
47// can distinguish "no session" from "resolver error" if we ever
48// add an error variant.
49//
50// Each tier reads from a typed `Extensions` field, not raw JWT/HTTP
51// payloads — those have already been mapped by upstream identity
52// plugins (cpex-plugin-identity-jwt). The resolver stays free of crypto /
53// parsing logic.
54
55use cpex_core::extensions::Extensions;
56use sha2::{Digest, Sha256};
57
58/// Which tier produced the session id. Useful for diagnostics / audit
59/// and to let downstream code branch on binding strength (e.g., only
60/// trust `token_claim`-derived sessions for the highest-stakes
61/// operations).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum SessionSource {
64 /// Pre-resolved by an upstream plugin via `AgentExtension.session_id`.
65 /// Highest priority — represents an authoritative decision.
66 Agent,
67 /// JWT `session_id` claim — strongest binding among derived tiers.
68 TokenClaim,
69 /// Derived from the identity triple. Stable across token refresh.
70 Identity,
71}
72
73impl SessionSource {
74 pub fn as_str(self) -> &'static str {
75 match self {
76 SessionSource::Agent => "agent",
77 SessionSource::TokenClaim => "token_claim",
78 SessionSource::Identity => "identity",
79 }
80 }
81}
82
83/// 16 hex chars (64 bits) of `sha256(raw)`. Shared by the identity tier
84/// and the subject-binding of the Agent/TokenClaim tiers so all derived
85/// session ids have one keying scheme. Matches the Python implementation's
86/// `hexdigest()[:16]`.
87fn short_hash(raw: &str) -> String {
88 let mut hasher = Sha256::new();
89 hasher.update(raw.as_bytes());
90 let digest = hasher.finalize();
91 digest
92 .iter()
93 .take(8)
94 .map(|b| format!("{:02x}", b))
95 .collect()
96}
97
98/// Bind a client/upstream-supplied raw session value to the authenticated
99/// subject: `sha256(subject_id : raw)`. This is the subject-bound shape the
100/// module doc prescribes for the (previously raw) Agent and TokenClaim tiers,
101/// so a session id chosen by one principal cannot address another principal's
102/// session bucket. Returns `None` when there is no authenticated subject — a
103/// bare client value has no safe scope, consistent with Tiers 2/3, which also
104/// require a subject.
105fn subject_scoped(subject_id: Option<&str>, raw: &str) -> Option<String> {
106 let sub = subject_id?;
107 Some(short_hash(&format!("{}:{}", sub, raw)))
108}
109
110/// Resolve a session id from the request's `Extensions`. Returns
111/// `Some((id, source))` on the first tier that hits, or `None` when
112/// every tier comes up empty (anonymous request, no claims, no
113/// header, no identity).
114///
115/// Identity-tier (2) requires at minimum `security.subject.id` to be
116/// populated — without an end-user identifier there's no meaningful
117/// session boundary to hash against. The other two identity-triple
118/// components (caller_workload, this_workload) fall back to the
119/// `"-"` sentinel when absent, which keeps the hash defined but
120/// degrades to a (sub, *, *) session — usually fine for demos with
121/// a single gateway and single agent.
122pub fn resolve_session(ext: &Extensions) -> Option<(String, SessionSource)> {
123 // The authenticated subject, populated by the identity resolvers
124 // (cpex-plugin-identity-jwt) before this runs. Every client/upstream-supplied
125 // session value below is bound to it so one principal can't address
126 // another's session bucket.
127 let subject_id = ext
128 .security
129 .as_deref()
130 .and_then(|s| s.subject.as_ref())
131 .and_then(|s| s.id.as_deref());
132
133 // Tier 0: pre-resolved by an upstream plugin (for the FFI/AuthBridge
134 // path this is `X-Session-Id` / the A2A contextId). Authoritative among
135 // sources, but subject-bound here rather than trusted raw: the raw value
136 // is attacker-chosen, so it only ever scopes state WITHIN the
137 // authenticated subject. Falls through when no subject is present.
138 if let Some(agent) = ext.agent.as_deref() {
139 if let Some(sid) = agent.session_id.as_deref() {
140 if !sid.is_empty() {
141 if let Some(bound) = subject_scoped(subject_id, sid) {
142 return Some((bound, SessionSource::Agent));
143 }
144 }
145 }
146 }
147
148 // Tier 1: explicit JWT `session_id` claim — also subject-bound. Even a
149 // signed claim is per-issuer and could repeat across principals, so the
150 // store key must still incorporate the subject.
151 if let Some(sec) = ext.security.as_deref() {
152 if let Some(subj) = sec.subject.as_ref() {
153 if let Some(sid) = subj.claims.get("session_id") {
154 if !sid.is_empty() {
155 if let Some(bound) = subject_scoped(subject_id, sid) {
156 return Some((bound, SessionSource::TokenClaim));
157 }
158 }
159 }
160 }
161 }
162
163 // Tier 2: identity-derived. Hash the triple
164 // (end-user : calling agent : our gateway) — stable across token
165 // refresh because all three components survive token rotation.
166 if let Some(sec) = ext.security.as_deref() {
167 let sub = sec.subject.as_ref().and_then(|s| s.id.as_deref());
168 if let Some(sub) = sub {
169 // Fall back to `-` so a missing component degrades the
170 // session to (sub, *, *) rather than the resolver silently
171 // returning None. Important for demos where the gateway
172 // hasn't yet attested its own `this_workload` identity.
173 let actor = sec
174 .caller_workload
175 .as_ref()
176 .and_then(|w| w.client_id.as_deref())
177 .unwrap_or("-");
178 let aud = sec
179 .this_workload
180 .as_ref()
181 .and_then(|w| w.client_id.as_deref())
182 .unwrap_or("-");
183 let raw = format!("{}:{}:{}", sub, actor, aud);
184 return Some((short_hash(&raw), SessionSource::Identity));
185 }
186 }
187
188 // Tier 3: no session.
189 None
190}
191
192// =====================================================================
193// Tests — one scenario per tier, plus tier-priority assertions.
194// =====================================================================
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use cpex_core::extensions::{
200 AgentExtension, Extensions, HttpExtension, SecurityExtension, SubjectExtension,
201 WorkloadIdentity,
202 };
203 use std::sync::Arc;
204
205 fn extensions_with_security(sec: SecurityExtension) -> Extensions {
206 Extensions {
207 security: Some(Arc::new(sec)),
208 ..Default::default()
209 }
210 }
211
212 fn subject_with_claims(id: Option<&str>, claims: &[(&str, &str)]) -> SubjectExtension {
213 SubjectExtension {
214 id: id.map(String::from),
215 claims: claims
216 .iter()
217 .map(|(k, v)| (k.to_string(), v.to_string()))
218 .collect(),
219 ..Default::default()
220 }
221 }
222
223 // Build Extensions carrying both an agent.session_id and a subject id.
224 fn extensions_with_agent_and_subject(session_id: &str, subject_id: &str) -> Extensions {
225 let mut agent = AgentExtension::default();
226 agent.session_id = Some(session_id.into());
227 Extensions {
228 agent: Some(Arc::new(agent)),
229 security: Some(Arc::new(SecurityExtension {
230 subject: Some(subject_with_claims(Some(subject_id), &[])),
231 ..Default::default()
232 })),
233 ..Default::default()
234 }
235 }
236
237 // --- Tier 0: agent (pre-resolved) ---
238
239 #[test]
240 fn tier0_agent_session_id_is_subject_bound() {
241 // A pre-resolved (client-supplied) session id is hashed together
242 // with the authenticated subject, never returned raw.
243 let ext = extensions_with_agent_and_subject("sess-upstream", "alice");
244 let (sid, src) = resolve_session(&ext).expect("should resolve");
245 assert_eq!(src, SessionSource::Agent);
246 assert_eq!(sid, subject_scoped(Some("alice"), "sess-upstream").unwrap());
247 assert_ne!(sid, "sess-upstream", "raw client value must not be the key");
248 }
249
250 #[test]
251 fn tier0_same_session_id_different_subjects_are_distinct() {
252 // Guarantee: principal A reusing principal B's
253 // session id must NOT land in B's session bucket.
254 let alice = extensions_with_agent_and_subject("shared-sid", "alice");
255 let bob = extensions_with_agent_and_subject("shared-sid", "bob");
256 let (sid_a, _) = resolve_session(&alice).unwrap();
257 let (sid_b, _) = resolve_session(&bob).unwrap();
258 assert_ne!(
259 sid_a, sid_b,
260 "same client session id under different subjects must not collide",
261 );
262 }
263
264 #[test]
265 fn tier0_stable_for_same_subject_and_session_id() {
266 // Same subject + same client session id → same key, so a legit
267 // user's taint persists across their own request/response cycles.
268 let (sid1, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
269 let (sid2, _) = resolve_session(&extensions_with_agent_and_subject("s1", "bob")).unwrap();
270 assert_eq!(sid1, sid2);
271 }
272
273 #[test]
274 fn tier0_no_subject_falls_through() {
275 // A client session id with no authenticated subject has no safe
276 // scope: do not honor it (no anonymous cross-readable bucket).
277 let mut agent = AgentExtension::default();
278 agent.session_id = Some("sess-upstream".into());
279 let ext = Extensions {
280 agent: Some(Arc::new(agent)),
281 ..Default::default()
282 };
283 assert!(resolve_session(&ext).is_none());
284 }
285
286 #[test]
287 fn tier0_skips_empty_agent_session_id() {
288 // Empty agent.session_id should fall through, otherwise an
289 // upstream that accidentally cleared the slot aliases every
290 // such request to "".
291 let mut agent = AgentExtension::default();
292 agent.session_id = Some("".into());
293 let ext = Extensions {
294 agent: Some(Arc::new(agent)),
295 security: Some(Arc::new(SecurityExtension {
296 subject: Some(subject_with_claims(Some("alice"), &[])),
297 ..Default::default()
298 })),
299 ..Default::default()
300 };
301 // Empty Tier 0 falls through; identity tier (subject present) hits.
302 let (_, src) = resolve_session(&ext).expect("should fall through to identity");
303 assert_eq!(src, SessionSource::Identity);
304 }
305
306 #[test]
307 fn tier0_wins_over_token_claim() {
308 // Pre-resolved value beats a JWT claim — upstream authority — and is
309 // subject-bound rather than returned raw.
310 let mut agent = AgentExtension::default();
311 agent.session_id = Some("from-agent".into());
312 let sec = SecurityExtension {
313 subject: Some(subject_with_claims(
314 Some("alice"),
315 &[("session_id", "from-token")],
316 )),
317 ..Default::default()
318 };
319 let ext = Extensions {
320 agent: Some(Arc::new(agent)),
321 security: Some(Arc::new(sec)),
322 ..Default::default()
323 };
324
325 let (sid, src) = resolve_session(&ext).unwrap();
326 assert_eq!(src, SessionSource::Agent);
327 assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
328 }
329
330 #[test]
331 fn tier0_wins_over_identity() {
332 // T0 (agent.session_id) must win over T2 (identity triple) when
333 // both are available. Pins the tier priority explicitly so a
334 // future refactor of the resolver's walk order regresses loudly.
335 let mut agent = AgentExtension::default();
336 agent.session_id = Some("from-agent".into());
337 let sec = SecurityExtension {
338 subject: Some(subject_with_claims(Some("alice"), &[])),
339 caller_workload: Some(WorkloadIdentity {
340 client_id: Some("agent-007".into()),
341 ..Default::default()
342 }),
343 this_workload: Some(WorkloadIdentity {
344 client_id: Some("praxis-gateway".into()),
345 ..Default::default()
346 }),
347 ..Default::default()
348 };
349 let ext = Extensions {
350 agent: Some(Arc::new(agent)),
351 security: Some(Arc::new(sec)),
352 ..Default::default()
353 };
354
355 let (sid, src) = resolve_session(&ext).unwrap();
356 assert_eq!(
357 src,
358 SessionSource::Agent,
359 "T0 must win over T2 when both are available",
360 );
361 assert_eq!(sid, subject_scoped(Some("alice"), "from-agent").unwrap());
362 }
363
364 // --- Tier 1: token_claim ---
365
366 #[test]
367 fn tier1_token_claim_hits_when_session_id_claim_present() {
368 let sec = SecurityExtension {
369 subject: Some(subject_with_claims(
370 Some("alice@corp.com"),
371 &[("session_id", "sess-from-token-789")],
372 )),
373 ..Default::default()
374 };
375 let ext = extensions_with_security(sec);
376
377 let (sid, src) = resolve_session(&ext).expect("should resolve");
378 assert_eq!(src, SessionSource::TokenClaim);
379 // Subject-bound, not the raw claim value.
380 assert_eq!(
381 sid,
382 subject_scoped(Some("alice@corp.com"), "sess-from-token-789").unwrap()
383 );
384 assert_ne!(sid, "sess-from-token-789");
385 }
386
387 #[test]
388 fn tier1_skips_empty_session_id_claim() {
389 // Empty claim values should NOT win tier 1 — they degrade to
390 // identity-derived. Otherwise an issuer accidentally putting
391 // an empty string in the claim would yield "" as the session
392 // key, which would alias every such request.
393 let sec = SecurityExtension {
394 subject: Some(subject_with_claims(Some("alice"), &[("session_id", "")])),
395 ..Default::default()
396 };
397 let ext = extensions_with_security(sec);
398
399 let (_, src) = resolve_session(&ext).expect("should fall through to identity");
400 assert_eq!(src, SessionSource::Identity);
401 }
402
403 #[test]
404 fn tier1_same_session_id_claim_different_subjects_are_distinct() {
405 // The Finding 2 guarantee for T1. An issuer that reuses a
406 // session_id value across multiple principals (multi-tenant
407 // naming conventions, counters that don't carry the subject,
408 // etc.) must NOT let one principal land in another's session
409 // bucket. Direct mirror of the T0 cross-principal test.
410 let mk = |sub: &str| -> SecurityExtension {
411 SecurityExtension {
412 subject: Some(subject_with_claims(
413 Some(sub),
414 &[("session_id", "issuer-shared-sid")],
415 )),
416 ..Default::default()
417 }
418 };
419 let (sid_a, _) = resolve_session(&extensions_with_security(mk("alice"))).unwrap();
420 let (sid_b, _) = resolve_session(&extensions_with_security(mk("bob"))).unwrap();
421 assert_ne!(
422 sid_a, sid_b,
423 "same JWT session_id claim under different subjects must not collide",
424 );
425 }
426
427 #[test]
428 fn tier1_stable_for_same_subject_and_session_id_claim() {
429 // Same subject + same claim value → same key. A legit user's
430 // session stays consistent across requests carrying the same
431 // claim, so accumulated taint persists where it should.
432 let mk = || -> Extensions {
433 extensions_with_security(SecurityExtension {
434 subject: Some(subject_with_claims(
435 Some("alice"),
436 &[("session_id", "claim-value-42")],
437 )),
438 ..Default::default()
439 })
440 };
441 let (sid1, _) = resolve_session(&mk()).unwrap();
442 let (sid2, _) = resolve_session(&mk()).unwrap();
443 assert_eq!(sid1, sid2);
444 }
445
446 #[test]
447 fn tier1_no_subject_id_falls_through() {
448 // A JWT carries a `session_id` claim but has no `sub` (subject
449 // present but `id == None`). T1 has no safe scope without a
450 // subject — must fall through. T2 also requires a subject and
451 // therefore returns None overall.
452 let sec = SecurityExtension {
453 subject: Some(SubjectExtension {
454 id: None,
455 claims: [("session_id".to_string(), "claim-value".to_string())]
456 .into_iter()
457 .collect(),
458 ..Default::default()
459 }),
460 ..Default::default()
461 };
462 let ext = extensions_with_security(sec);
463 assert!(
464 resolve_session(&ext).is_none(),
465 "claim with no subject id has no safe scope; must not honor",
466 );
467 }
468
469 #[test]
470 fn tier1_wins_over_identity() {
471 // Both a JWT session_id claim AND a full identity triple are
472 // present. T1 must win over T2. Pins the tier priority
473 // explicitly — the existing happy-path test happens to omit
474 // T2 inputs, so without this T1>T2 priority is only implicit.
475 let sec = SecurityExtension {
476 subject: Some(subject_with_claims(
477 Some("alice"),
478 &[("session_id", "from-claim")],
479 )),
480 caller_workload: Some(WorkloadIdentity {
481 client_id: Some("agent-007".into()),
482 ..Default::default()
483 }),
484 this_workload: Some(WorkloadIdentity {
485 client_id: Some("praxis-gateway".into()),
486 ..Default::default()
487 }),
488 ..Default::default()
489 };
490 let ext = extensions_with_security(sec);
491
492 let (sid, src) = resolve_session(&ext).unwrap();
493 assert_eq!(
494 src,
495 SessionSource::TokenClaim,
496 "T1 must win over T2 when both are available",
497 );
498 assert_eq!(sid, subject_scoped(Some("alice"), "from-claim").unwrap());
499 }
500
501 // --- Tier 2 (`X-CPEX-Session-Id` header) is intentionally absent ---
502 //
503 // The Python `SessionResolver` included a header tier; cpex Rust
504 // does not. See the module-level doc comment for the threat model.
505 // A spoofing-regression guard lives below in
506 // `header_x_cpex_session_id_is_ignored`.
507
508 // --- Tier 2: identity ---
509
510 #[test]
511 fn tier2_identity_derived_when_no_claim() {
512 let sec = SecurityExtension {
513 subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
514 caller_workload: Some(WorkloadIdentity {
515 client_id: Some("agent-007".into()),
516 ..Default::default()
517 }),
518 this_workload: Some(WorkloadIdentity {
519 client_id: Some("praxis-gateway".into()),
520 ..Default::default()
521 }),
522 ..Default::default()
523 };
524 let ext = extensions_with_security(sec);
525
526 let (sid, src) = resolve_session(&ext).expect("should resolve");
527 assert_eq!(src, SessionSource::Identity);
528 // 16 hex chars (matches Python `sha256(...)[:16]`).
529 assert_eq!(sid.len(), 16);
530 assert!(sid.chars().all(|c| c.is_ascii_hexdigit()));
531 }
532
533 #[test]
534 fn tier2_identity_is_stable_across_calls() {
535 // Same triple → same session id. Property guarantees that
536 // a token refresh (which doesn't change sub/caller/this) keeps
537 // the session intact.
538 let mk = || -> SecurityExtension {
539 SecurityExtension {
540 subject: Some(subject_with_claims(Some("alice@corp.com"), &[])),
541 caller_workload: Some(WorkloadIdentity {
542 client_id: Some("agent-007".into()),
543 ..Default::default()
544 }),
545 this_workload: Some(WorkloadIdentity {
546 client_id: Some("praxis-gateway".into()),
547 ..Default::default()
548 }),
549 ..Default::default()
550 }
551 };
552 let ext1 = extensions_with_security(mk());
553 let ext2 = extensions_with_security(mk());
554 let (sid1, _) = resolve_session(&ext1).unwrap();
555 let (sid2, _) = resolve_session(&ext2).unwrap();
556 assert_eq!(sid1, sid2);
557 }
558
559 #[test]
560 fn tier2_distinguishes_different_users() {
561 let alice = SecurityExtension {
562 subject: Some(subject_with_claims(Some("alice"), &[])),
563 ..Default::default()
564 };
565 let bob = SecurityExtension {
566 subject: Some(subject_with_claims(Some("bob"), &[])),
567 ..Default::default()
568 };
569 let (sid_a, _) = resolve_session(&extensions_with_security(alice)).unwrap();
570 let (sid_b, _) = resolve_session(&extensions_with_security(bob)).unwrap();
571 assert_ne!(sid_a, sid_b);
572 }
573
574 #[test]
575 fn tier2_distinguishes_different_agents() {
576 // Same user, two different agents → different sessions.
577 // Important so a malicious agent's accumulated taints don't
578 // affect a different agent that user runs.
579 let mk = |agent: &str| -> SecurityExtension {
580 SecurityExtension {
581 subject: Some(subject_with_claims(Some("alice"), &[])),
582 caller_workload: Some(WorkloadIdentity {
583 client_id: Some(agent.into()),
584 ..Default::default()
585 }),
586 ..Default::default()
587 }
588 };
589 let (sid1, _) = resolve_session(&extensions_with_security(mk("agent-a"))).unwrap();
590 let (sid2, _) = resolve_session(&extensions_with_security(mk("agent-b"))).unwrap();
591 assert_ne!(sid1, sid2);
592 }
593
594 // --- Tier 3: none ---
595
596 #[test]
597 fn tier3_no_session_when_no_data() {
598 let ext = Extensions::default();
599 assert!(resolve_session(&ext).is_none());
600 }
601
602 #[test]
603 fn tier3_no_session_when_no_subject_id() {
604 // Security exists but no subject id → identity can't hash.
605 // Claim is absent too. Should be None.
606 let sec = SecurityExtension {
607 subject: Some(SubjectExtension::default()), // id = None
608 ..Default::default()
609 };
610 let ext = extensions_with_security(sec);
611 assert!(resolve_session(&ext).is_none());
612 }
613
614 // --- Wire-format documentation ---
615
616 #[test]
617 fn separator_format_collides_when_subject_contains_colon() {
618 // Document — but do not silently ignore — the colon-separator
619 // format's known ambiguity. A colon inside subject_id collides
620 // with one inside the raw value: both
621 // subject="alice:foo", raw="bar"
622 // and
623 // subject="alice", raw="foo:bar"
624 // hash the same string "alice:foo:bar" and thus produce the
625 // same session key.
626 //
627 // JWT `sub` claims are conventionally opaque URNs or emails,
628 // which in practice don't carry colons. This test asserts the
629 // collision exists so a future migration that introduces
630 // colon-bearing subject IDs (or changes the separator format
631 // unilaterally) breaks the build and forces a deliberate
632 // re-design — most likely a length-prefixed format like
633 // `{sub_len}:{sub}:{raw}`.
634 let a = subject_scoped(Some("alice:foo"), "bar").unwrap();
635 let b = subject_scoped(Some("alice"), "foo:bar").unwrap();
636 assert_eq!(
637 a, b,
638 "current format collides; if subject IDs can contain colons, switch to length-prefix",
639 );
640 }
641
642 // --- Spoofing guard (regression test for P0-2) ---
643
644 #[test]
645 fn header_x_cpex_session_id_is_ignored() {
646 // The Python apl-plugins resolver honored an `X-CPEX-Session-Id`
647 // header tier between token_claim and identity. We deliberately
648 // dropped it: an authenticated client could set the header to
649 // another subject's session id and inherit their accumulated
650 // taints, or to a random unused value and escape their own
651 // tainted session. This test pins that behaviour: the header is
652 // present, no token claim exists, and the resolver still falls
653 // through to identity-derived (or none) rather than honoring
654 // the header. If a future PR adds a header tier without
655 // subject binding, this test fails.
656 let sec = SecurityExtension {
657 subject: Some(subject_with_claims(Some("alice"), &[])),
658 caller_workload: Some(WorkloadIdentity {
659 client_id: Some("agent-007".into()),
660 ..Default::default()
661 }),
662 ..Default::default()
663 };
664 let mut http = HttpExtension::default();
665 http.request_headers
666 .insert("X-CPEX-Session-Id".into(), "sess-bob-stolen".into());
667 let ext = Extensions {
668 security: Some(Arc::new(sec)),
669 http: Some(Arc::new(http)),
670 ..Default::default()
671 };
672
673 let (sid, src) = resolve_session(&ext).expect("identity should still hit");
674 assert_eq!(
675 src,
676 SessionSource::Identity,
677 "header tier was removed; resolver must NOT honor X-CPEX-Session-Id",
678 );
679 assert_ne!(
680 sid, "sess-bob-stolen",
681 "header value must never become the session id",
682 );
683 }
684}