cpex_core/identity/payload.rs
1// Location: ./crates/cpex-core/src/identity/payload.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `IdentityPayload` — the unified state struct threaded through the
7// IdentityResolve hook chain. Plays two roles in one type:
8//
9// * **Input** (private fields, read-only after construction) —
10// `raw_token`, `source`, `source_header`, `headers`, `client_host`,
11// `client_port`. Populated by the host once at request entry and
12// never mutated by handlers. Privacy is enforced at the module
13// boundary: external code reads through `pub fn raw_token() -> &str`
14// etc. and has no setters or mutable field access, so even a
15// `payload.clone()` followed by `clone.raw_token = ...` fails to
16// compile.
17//
18// * **Accumulating output** (`pub` fields) — `subject`, `client`,
19// `caller_workload`, `delegation`, `raw_credentials`, `rejected`,
20// `reject_status`, `reject_reason`, `resolved_at`, `raw_claims`.
21// Handlers clone the payload, populate the output fields they care
22// about, and return the updated payload via
23// `PluginResult::modify_payload`. Sequential-phase executor
24// semantics thread plugin N's output into plugin N+1's input,
25// producing a natural accumulator chain.
26//
27// # Why one struct instead of separate Payload + Result
28//
29// An earlier draft had `IdentityPayload` (input) and `IdentityResult`
30// (output) as distinct types — the Python framework's split
31// (`cpex/framework/hooks/identity.py`). That made the first handler
32// awkward: it received an "empty IdentityResult" with no way to read
33// the raw token without dropping back to `Extensions`. Folding the
34// two types into one means handler N always has the inputs it needs
35// (private getters) plus whatever previous handlers have already
36// accumulated (read direct pub fields), and the hook signature stays
37// uniform with everything else in the framework — `invoke_named::<H>`
38// with `PluginResult<H::Payload>` on the way out.
39//
40// # Rejection model
41//
42// Handlers reject via `PluginResult::deny(PluginViolation::new(code,
43// reason))` — the same path every other hook uses. The executor's
44// `continue_processing = false` check halts the chain at the
45// framework level, so no later handler can run and accidentally
46// overwrite the decision. There is intentionally no `rejected` /
47// `reject_status` / `reject_reason` flag on the payload itself —
48// duplicating the rejection state in a `pub` field would let a
49// later handler clone the payload, clear the flag, and quietly
50// turn a 401 into a 200. The framework's existing halt machinery
51// already does the right thing.
52//
53// Host-side HTTP mapping is conventional: `PluginViolation.code`
54// is the resolution-specific identifier (`auth.expired`,
55// `auth.audience_mismatch`, `auth.missing_scope`), and the host
56// maps it to a status code (401 / 403 / etc.). Same pattern as
57// CMF tool-pre-invoke denials.
58
59use std::collections::HashMap;
60use std::sync::Arc;
61
62use chrono::{DateTime, Utc};
63use serde::{Deserialize, Serialize};
64use zeroize::Zeroizing;
65
66use crate::executor::PipelineResult;
67use crate::extensions::{
68 ClientExtension, DelegationExtension, Extensions, RawCredentialsExtension, SecurityExtension,
69 SubjectExtension, WorkloadIdentity,
70};
71use crate::impl_plugin_payload;
72
73/// Where the raw credential was extracted from. Lets handlers
74/// short-circuit on payloads they don't service (an mTLS-only
75/// resolver ignores `Bearer` payloads). `Custom(String)` is the
76/// escape hatch for bespoke wire formats.
77#[non_exhaustive]
78#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum TokenSource {
81 /// `Authorization: Bearer <jwt>` style.
82 Bearer,
83 /// `X-User-Token` style — explicit user-identity header alongside
84 /// a separate gateway-access token in `Authorization`.
85 UserToken,
86 /// mTLS — credential is the peer X.509 chain (surfaced via
87 /// `X-Forwarded-Client-Cert`). `raw_token` may be empty in this
88 /// case; the chain itself flows through `headers`.
89 Mtls,
90 /// SPIFFE JWT-SVID — JWT-shaped but with SPIFFE-specific claims.
91 SpiffeJwtSvid,
92 /// API key in a header or query param.
93 ApiKey,
94 /// Operator-defined extraction path.
95 #[serde(untagged)]
96 Custom(String),
97}
98
99impl Default for TokenSource {
100 fn default() -> Self {
101 TokenSource::Bearer
102 }
103}
104
105/// State threaded through the IdentityResolve hook chain.
106///
107/// See the module-level docs for the input/output split. In short:
108/// **input fields are private** (set once via the constructor +
109/// builders, never mutated), **output fields are `pub`** (handlers
110/// populate them on clones and return the updated payload).
111///
112/// Implements `PluginPayload` so it can flow through the executor's
113/// existing Sequential-phase machinery — no bespoke plumbing.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct IdentityPayload {
116 // ----- Input (private — host-supplied, never mutated by handlers) -----
117 /// Raw credential bytes. Cleared on drop via `Zeroizing`.
118 /// `#[serde(skip)]` — never appears in serialized output.
119 #[serde(skip)]
120 raw_token: Zeroizing<String>,
121
122 /// Where the credential was extracted from.
123 source: TokenSource,
124
125 /// HTTP header (or other wire-level slot) the token arrived in.
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 source_header: Option<String>,
128
129 /// Full request headers — escape hatch for custom auth flows.
130 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
131 headers: HashMap<String, String>,
132
133 /// Client IP, when known.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 client_host: Option<String>,
136
137 /// Client TCP port, when known.
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 client_port: Option<u16>,
140
141 // ----- Output (pub — handlers populate via direct assignment on clones) -----
142 /// Resolved user identity. `None` until a handler populates it.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub subject: Option<SubjectExtension>,
145
146 /// Resolved OAuth client / gateway-access identity.
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub client: Option<ClientExtension>,
149
150 /// Resolved attested workload identity for the inbound peer.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub caller_workload: Option<WorkloadIdentity>,
153
154 /// Initial delegation chain parsed from `act` / equivalent claims.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub delegation: Option<DelegationExtension>,
157
158 /// Raw inbound tokens to stash in
159 /// `Extensions.raw_credentials.inbound_tokens` after the chain
160 /// completes (gated by `read_inbound_credentials` for consumers).
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub raw_credentials: Option<RawCredentialsExtension>,
163
164 /// Optional resolution timestamp. Audit-useful.
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub resolved_at: Option<DateTime<Utc>>,
167
168 /// Raw decoded token claims, when a handler wants to expose them
169 /// for audit/policy without elevating each claim to a typed
170 /// field. Mirrors the Python `raw_claims: dict[str, Any]`.
171 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
172 pub raw_claims: HashMap<String, serde_json::Value>,
173}
174
175impl IdentityPayload {
176 /// Construct a payload with the required input fields populated.
177 /// The most common entry point — hosts call this once per request
178 /// before invoking the hook. Optional input slots
179 /// (`source_header`, `headers`, `client_host`, `client_port`) are
180 /// set via the `.with_*` builders below; output fields start as
181 /// `None` / `false` / empty and accumulate as handlers run.
182 pub fn new(raw_token: impl Into<String>, source: TokenSource) -> Self {
183 Self {
184 raw_token: Zeroizing::new(raw_token.into()),
185 source,
186 source_header: None,
187 headers: HashMap::new(),
188 client_host: None,
189 client_port: None,
190 subject: None,
191 client: None,
192 caller_workload: None,
193 delegation: None,
194 raw_credentials: None,
195 resolved_at: None,
196 raw_claims: HashMap::new(),
197 }
198 }
199
200 // -------- Input builders --------
201
202 pub fn with_source_header(mut self, h: impl Into<String>) -> Self {
203 self.source_header = Some(h.into());
204 self
205 }
206
207 pub fn with_headers(mut self, h: HashMap<String, String>) -> Self {
208 self.headers = h;
209 self
210 }
211
212 pub fn with_client_host(mut self, h: impl Into<String>) -> Self {
213 self.client_host = Some(h.into());
214 self
215 }
216
217 pub fn with_client_port(mut self, port: u16) -> Self {
218 self.client_port = Some(port);
219 self
220 }
221
222 // -------- Input read accessors (no mutable variants) --------
223
224 /// The raw credential bytes. Borrowed — handlers cannot move
225 /// or replace the underlying `Zeroizing<String>` through this
226 /// accessor.
227 pub fn raw_token(&self) -> &str {
228 &self.raw_token
229 }
230
231 pub fn source(&self) -> &TokenSource {
232 &self.source
233 }
234
235 pub fn source_header(&self) -> Option<&str> {
236 self.source_header.as_deref()
237 }
238
239 pub fn headers(&self) -> &HashMap<String, String> {
240 &self.headers
241 }
242
243 pub fn client_host(&self) -> Option<&str> {
244 self.client_host.as_deref()
245 }
246
247 pub fn client_port(&self) -> Option<u16> {
248 self.client_port
249 }
250
251 // -------- Output helpers --------
252
253 /// Layer another payload's *output* fields onto this one's,
254 /// following "Some replaces None, last write wins per slot."
255 /// Input fields are not touched — the running payload's input
256 /// is canonical for the whole chain.
257 ///
258 /// Rejection is *not* a merged field — handlers reject via
259 /// `PluginResult::deny`, which halts the chain at the framework
260 /// level rather than being expressed as payload state. See the
261 /// module docs for the rationale.
262 pub fn merge(&mut self, other: IdentityPayload) {
263 if other.subject.is_some() {
264 self.subject = other.subject;
265 }
266 if other.client.is_some() {
267 self.client = other.client;
268 }
269 if other.caller_workload.is_some() {
270 self.caller_workload = other.caller_workload;
271 }
272 if other.delegation.is_some() {
273 self.delegation = other.delegation;
274 }
275 if other.raw_credentials.is_some() {
276 self.raw_credentials = other.raw_credentials;
277 }
278 if other.resolved_at.is_some() {
279 self.resolved_at = other.resolved_at;
280 }
281 for (k, v) in other.raw_claims {
282 self.raw_claims.insert(k, v);
283 }
284 }
285
286 // -------- Host-side application helpers --------
287
288 /// Pull the resolved `IdentityPayload` out of a `PipelineResult`
289 /// returned by `mgr.invoke_named::<IdentityHook>(...)`. Returns
290 /// `None` when the pipeline was denied (no `modified_payload`)
291 /// or when the result's payload wasn't an `IdentityPayload` — a
292 /// programmer error if the latter, since the executor produces
293 /// `modified_payload` typed per the hook's `HookTypeDef::Payload`.
294 ///
295 /// Clones the inner payload — the original `Box<dyn PluginPayload>`
296 /// stays in the `PipelineResult` so callers can also inspect
297 /// `continue_processing`, `violation`, etc.
298 pub fn from_pipeline_result(result: &PipelineResult) -> Option<Self> {
299 result
300 .modified_payload
301 .as_ref()
302 .and_then(|p| p.as_any().downcast_ref::<IdentityPayload>())
303 .cloned()
304 }
305
306 /// Apply this payload's resolved identity slots back into an
307 /// `Extensions` container. Returns a new `Extensions` ready to
308 /// hand to the next hook in the request lifecycle (`cmf.tool_pre_invoke`,
309 /// etc.) — downstream plugins read `security.subject` /
310 /// `security.client` / `security.caller_workload` /
311 /// `raw_credentials` etc. through the standard capability-gated
312 /// filter.
313 ///
314 /// Merging rules:
315 ///
316 /// - **`security.subject` / `.client` / `.caller_workload`** —
317 /// `Some` values on the payload overwrite the existing slot;
318 /// other security fields (labels, classification, this_workload,
319 /// auth_method, objects, data) are preserved from the input
320 /// Extensions.
321 /// - **`raw_credentials`** — replaced wholesale when populated on
322 /// the payload. Wholesale rather than merged because handlers
323 /// produce the complete set of inbound tokens for this request;
324 /// the host's pre-invoke Extensions wouldn't normally carry one.
325 /// - **`delegation`** — replaced wholesale when populated.
326 /// Initial chain from `act` claims in the inbound credential.
327 ///
328 /// Input fields on the payload (`raw_token`, `headers`, …) are
329 /// **not** copied into Extensions — they're the resolver's
330 /// internal workspace, not request-wide state.
331 pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions {
332 let needs_security_update =
333 self.subject.is_some() || self.client.is_some() || self.caller_workload.is_some();
334
335 if needs_security_update {
336 // Clone-out the existing security extension (or default a
337 // fresh one) so we can write our identity slots while
338 // preserving labels / classification / etc.
339 let mut sec: SecurityExtension = ext
340 .security
341 .as_ref()
342 .map(|arc| (**arc).clone())
343 .unwrap_or_default();
344 if let Some(s) = &self.subject {
345 sec.subject = Some(s.clone());
346 }
347 if let Some(c) = &self.client {
348 sec.client = Some(c.clone());
349 }
350 if let Some(w) = &self.caller_workload {
351 sec.caller_workload = Some(w.clone());
352 }
353 ext.security = Some(Arc::new(sec));
354 }
355
356 if let Some(rc) = &self.raw_credentials {
357 ext.raw_credentials = Some(Arc::new(rc.clone()));
358 }
359
360 if let Some(d) = &self.delegation {
361 ext.delegation = Some(Arc::new(d.clone()));
362 }
363
364 ext
365 }
366}
367
368impl_plugin_payload!(IdentityPayload);
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn raw_token_serializes_without_secret() {
376 let p = IdentityPayload::new("eyJhbGciOiJSUzI1NiJ9.payload.sig", TokenSource::Bearer);
377 let json = serde_json::to_string(&p).unwrap();
378 assert!(
379 !json.contains("eyJhbGciOiJSUzI1NiJ9"),
380 "raw_token leaked into serialized form: {}",
381 json,
382 );
383 assert!(json.contains("bearer"));
384 }
385
386 #[test]
387 fn deserialize_yields_empty_raw_token() {
388 let json = r#"{"source":"bearer"}"#;
389 let p: IdentityPayload = serde_json::from_str(json).unwrap();
390 assert_eq!(p.raw_token(), "");
391 assert_eq!(p.source(), &TokenSource::Bearer);
392 }
393
394 #[test]
395 fn token_source_custom_round_trips() {
396 let s = TokenSource::Custom("magic-link".into());
397 let json = serde_json::to_string(&s).unwrap();
398 let back: TokenSource = serde_json::from_str(&json).unwrap();
399 assert_eq!(s, back);
400 }
401
402 #[test]
403 fn input_builders_chain() {
404 let mut h = HashMap::new();
405 h.insert("user-agent".to_string(), "curl/8.0".to_string());
406 let p = IdentityPayload::new("tok", TokenSource::Bearer)
407 .with_source_header("Authorization")
408 .with_headers(h)
409 .with_client_host("10.0.0.1")
410 .with_client_port(443);
411 assert_eq!(p.raw_token(), "tok");
412 assert_eq!(p.source_header(), Some("Authorization"));
413 assert_eq!(p.client_host(), Some("10.0.0.1"));
414 assert_eq!(p.client_port(), Some(443));
415 assert_eq!(
416 p.headers().get("user-agent").map(String::as_str),
417 Some("curl/8.0")
418 );
419 }
420
421 #[test]
422 fn handler_can_populate_output_on_clone() {
423 // Exercises the typical handler pattern: clone the running
424 // payload, set the output fields the handler is responsible
425 // for, return the updated payload. Input fields survive
426 // the clone unchanged.
427 let original = IdentityPayload::new("eyJ.tok", TokenSource::Bearer);
428 let mut updated = original.clone();
429 updated.subject = Some(SubjectExtension {
430 id: Some("alice".into()),
431 ..Default::default()
432 });
433 assert_eq!(updated.raw_token(), "eyJ.tok"); // input preserved
434 assert_eq!(
435 updated.subject.as_ref().unwrap().id.as_deref(),
436 Some("alice")
437 );
438 // Original unchanged — the clone is a separate value.
439 assert!(original.subject.is_none());
440 }
441
442 #[test]
443 fn merge_overlays_some_onto_none() {
444 // Cross-handler chaining: handler 1 resolves the subject,
445 // handler 2 contributes the workload. Merged result carries
446 // both.
447 let mut base = IdentityPayload::new("tok", TokenSource::Bearer);
448 base.subject = Some(SubjectExtension {
449 id: Some("alice".into()),
450 ..Default::default()
451 });
452 let mut overlay = IdentityPayload::new("tok", TokenSource::Bearer);
453 overlay.caller_workload = Some(WorkloadIdentity {
454 spiffe_id: Some("spiffe://corp.com/inbound".into()),
455 ..Default::default()
456 });
457 base.merge(overlay);
458 assert_eq!(base.subject.as_ref().unwrap().id.as_deref(), Some("alice"));
459 assert!(base.caller_workload.is_some());
460 }
461}