Skip to main content

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}