Skip to main content

cpex_hosts_python/
credential_payloads.rs

1// Location: ./crates/cpex-hosts-python/src/credential_payloads.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Ted Habeck
5//
6// PPE's identity and delegation payloads, on the worker's wire.
7//
8// # Why this exists
9//
10// PPE dispatches `identity.resolve` with `praxis_policy_core::identity::
11// IdentityPayload` and `token.delegate` with `delegation::DelegationPayload`.
12// The FFI, the Python bindings and the APL delegate step all build those
13// types. The Python worker knows neither: it rebuilds its own
14// `IdentityPayload` / `DelegationPayload` Pydantic models (the shapes the
15// `legacy::payloads` structs mirror), and its plugins answer with an
16// `IdentityResult` / `DelegationResult`. This module translates both ways:
17//
18//   outbound: the PPE payload ──> the worker model's JSON, minus every secret
19//   inbound:  the plugin's result ──> a clone of the dispatched PPE payload
20//             with only the output fields the result carries written
21//
22// Mapping back onto a clone (the `cmf_shim` pattern) is what lets PPE's own
23// downcasts succeed: `IdentityPayload::from_pipeline_result` and
24// `DelegationPayload::from_pipeline_result` look for exactly these types, and
25// the input fields PPE keeps private stay what the host set.
26//
27// # Credentials
28//
29// No raw token travels in the JSON built here. `raw_token` is sent empty and
30// `bearer_token` is not sent at all; a plugin that declared the matching
31// capability gets the plaintext from the separate `credential` object
32// (`crate::credentials`), and only that plugin does. The identity headers are
33// the other channel a token can ride, so the public copy drops the
34// credential-bearing ones: the configured source header, `authorization`,
35// `proxy-authorization`, cookies, `x-api-key`, and any header whose name or
36// value contains the token. The capability-gated `credential` object carries
37// the full header map instead.
38//
39// # Field decisions
40//
41//   identity.resolve, outbound
42//     raw_token             sent empty; plaintext only on the gated channel
43//     source                sent (PPE's wire spelling)
44//     source_header         not sent; the gated channel names it
45//     headers               sent minus credential-bearing headers
46//     client_host/port      sent
47//     resolver outputs      not sent; the Python model has no slot for them,
48//                           and they survive on the clone
49//
50//   identity.resolve, inbound (`IdentityResult`, or an echoed payload)
51//     subject, delegation   written when set; a field PPE cannot hold fails
52//                           closed
53//     raw_claims            merged
54//     rejected              true becomes a deny carrying reject_reason and
55//                           reject_status
56//     input fields          ignored: PPE treats them as canonical for the
57//                           chain, as `IdentityPayload::merge` does
58//     anything else         fails closed when set
59//
60//   token.delegate, outbound
61//     bearer_token          not sent; plaintext only on the gated channel
62//     actor token / role    refused when set: the Python model cannot carry
63//                           an actor, so a minted token would lose its `act`
64//     subject               refused unless `user`: the Python model assumes
65//                           the bearer is the user's token
66//     everything else       sent
67//
68//   token.delegate, inbound (`DelegationResult`, or an echoed payload)
69//     delegated_token       becomes `delegated_token`, for the target
70//                           audience (or target name) and the required
71//                           permissions
72//     forwarded_headers     accepted only as the one header that carries the
73//                           minted token, which names its outbound header;
74//                           anything else fails closed
75//     cache_ttl             bounds the minted token's expiry
76//     cache_key             ignored: PPE leaves caching to the handler
77//     delegation_update     written when it is not the empty default
78//     input fields          ignored, as above
79//     anything else         fails closed when set
80
81use std::collections::HashMap;
82
83use praxis_policy_core::delegation::{DelegationPayload, DelegationSubject, HOOK_TOKEN_DELEGATE};
84use praxis_policy_core::error::PluginViolation;
85use praxis_policy_core::extensions::{DelegationExtension, RawDelegatedToken, SubjectExtension};
86use praxis_policy_core::hooks::payload::PluginPayload;
87use praxis_policy_core::identity::{HOOK_IDENTITY_RESOLVE, IdentityPayload};
88use serde::Serialize;
89use serde::de::DeserializeOwned;
90use serde_json::{Map, Value};
91
92use crate::error::HostError;
93
94/// Header an identity payload's credential arrives in when it names none.
95const DEFAULT_CREDENTIAL_HEADER: &str = "authorization";
96
97/// Header names that carry credentials by convention, compared lowercase.
98/// Never sent in the public header map, whatever the source header is.
99pub(crate) const CREDENTIAL_HEADERS: &[&str] = &[
100    "authorization",
101    "proxy-authorization",
102    "cookie",
103    "set-cookie",
104    "x-api-key",
105    "x-auth-token",
106    "x-access-token",
107    "x-amzn-oidc-data",
108    "x-goog-iap-jwt-assertion",
109    "x-amz-security-token",
110];
111
112/// A public header needs both an approved name and a validator for its value.
113/// Empty by default: unvalidated header values can contain credentials under
114/// any name, including names that normally carry tracing metadata.
115const PUBLIC_HEADER_VALIDATORS: &[(&str, fn(&str) -> bool)] = &[];
116
117/// Header the minted token is attached under when the plugin names none.
118const DEFAULT_OUTBOUND_HEADER: &str = "Authorization";
119
120/// Lifetime given to a minted token whose plugin reported no `cache_ttl`.
121///
122/// The Python `DelegationResult` has no expiry field, so the host has to pick
123/// one. Short on purpose: an expiry the issuer did not state should make a
124/// consumer re-delegate soon rather than reuse the token for long.
125const DEFAULT_DELEGATED_TOKEN_TTL_SECS: i64 = 60;
126
127/// Fields of the worker's `IdentityPayload` model. A plugin that echoes its
128/// payload back returns these; PPE keeps the matching fields private.
129const IDENTITY_INPUT_FIELDS: &[&str] = &[
130    "raw_token",
131    "source",
132    "headers",
133    "client_host",
134    "client_port",
135];
136
137/// Fields of the worker's `DelegationPayload` model, ignored on the way back
138/// for the same reason.
139const DELEGATION_INPUT_FIELDS: &[&str] = &[
140    "target_name",
141    "target_type",
142    "target_audience",
143    "required_permissions",
144    "trust_domain",
145    "auth_enforced_by",
146    "route_attenuation",
147    "bearer_token",
148];
149
150/// Whether `payload` is one of the two PPE payloads this module translates.
151pub fn is_ppe_credential_payload(payload: &dyn PluginPayload) -> bool {
152    let any = payload.as_any();
153    any.is::<IdentityPayload>() || any.is::<DelegationPayload>()
154}
155
156// --- identity.resolve -------------------------------------------------------
157
158/// The inbound credential an identity payload carries, if any.
159///
160/// `raw_token` first. A host that deserialized the payload (the FFI and the
161/// Python bindings do) has an empty `raw_token` and the credential in the
162/// header map, so the source header is read next, with a `Bearer ` prefix
163/// stripped the way PPE's own JWT resolver strips it.
164pub(crate) fn inbound_token(payload: &IdentityPayload) -> Option<String> {
165    let raw = payload.raw_token();
166    if !raw.trim().is_empty() {
167        return Some(raw.to_string());
168    }
169    let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
170    let (_, value) = header_entry(payload.headers(), header)?;
171    let token = strip_bearer(value).trim();
172    (!token.is_empty()).then(|| token.to_string())
173}
174
175/// The header the credential arrived in, spelled as the header map spells it
176/// when the map holds it.
177pub(crate) fn credential_header_name(payload: &IdentityPayload) -> String {
178    let header = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
179    header_entry(payload.headers(), header)
180        .map_or_else(|| header.to_string(), |(name, _)| name.to_string())
181}
182
183/// Case-insensitive header lookup, returning the key as stored.
184fn header_entry<'a>(
185    headers: &'a HashMap<String, String>,
186    name: &str,
187) -> Option<(&'a str, &'a str)> {
188    headers
189        .iter()
190        .find(|(k, _)| k.eq_ignore_ascii_case(name))
191        .map(|(k, v)| (k.as_str(), v.as_str()))
192}
193
194fn strip_bearer(value: &str) -> &str {
195    let trimmed = value.trim_start();
196    match trimmed.get(..7) {
197        Some(prefix) if prefix.eq_ignore_ascii_case("bearer ") => &trimmed[7..],
198        _ => trimmed,
199    }
200}
201
202/// Whether a raw header may cross to a worker outside the gated credential
203/// channel. No name is approved yet: a familiar header name alone cannot
204/// prove its value contains no credential. Add an entry to
205/// `PUBLIC_HEADER_VALIDATORS` only with a strict validator and a documented
206/// need for that field.
207pub(crate) fn is_public_header(
208    name: &str,
209    value: &str,
210    source_header: Option<&str>,
211    token: Option<&str>,
212) -> bool {
213    let lower = name.to_ascii_lowercase();
214    if CREDENTIAL_HEADERS.contains(&lower.as_str())
215        || source_header.is_some_and(|source| lower.eq_ignore_ascii_case(source))
216        || token.is_some_and(|t| name.contains(t) || value.contains(t))
217    {
218        return false;
219    }
220    PUBLIC_HEADER_VALIDATORS
221        .iter()
222        .any(|(approved, validate)| lower == *approved && validate(value))
223}
224
225/// Public request headers for an identity plugin. The full map is available
226/// only through the `read_inbound_credentials`-gated credential DTO.
227pub(crate) fn public_headers(payload: &IdentityPayload) -> HashMap<String, String> {
228    let token = inbound_token(payload);
229    let source = payload.source_header().unwrap_or(DEFAULT_CREDENTIAL_HEADER);
230    payload
231        .headers()
232        .iter()
233        .filter(|(name, value)| is_public_header(name, value, Some(source), token.as_deref()))
234        .map(|(name, value)| (name.clone(), value.clone()))
235        .collect()
236}
237
238/// The worker `IdentityPayload` JSON for a PPE identity payload.
239pub fn identity_to_worker(payload: &IdentityPayload) -> Result<Value, HostError> {
240    let source = serde_json::to_value(payload.source()).map_err(|e| HostError::Protocol {
241        message: format!("could not serialize the identity payload's source: {e}"),
242    })?;
243    let mut json = serde_json::json!({
244        // Required by the Python model. Empty here; the plaintext reaches a
245        // plugin that declared `read_inbound_credentials` through the
246        // `credential` object, which the worker folds onto this field.
247        "raw_token": "",
248        "source": source,
249        "headers": public_headers(payload),
250    });
251    if let Some(host) = payload.client_host() {
252        json["client_host"] = Value::String(host.to_string());
253    }
254    if let Some(port) = payload.client_port() {
255        json["client_port"] = Value::from(port);
256    }
257    Ok(json)
258}
259
260/// What an identity plugin's result means for the PPE pipeline.
261#[derive(Debug)]
262pub enum IdentityOutcome {
263    /// The dispatched payload with the plugin's output fields written.
264    Resolved(Box<IdentityPayload>),
265    /// The plugin set `rejected`. Carried as a deny, which PPE's identity
266    /// model uses in place of a flag on the payload.
267    Rejected(PluginViolation),
268}
269
270/// Map an identity plugin's modified payload back onto a clone of the one
271/// PPE dispatched.
272pub fn identity_from_worker(
273    original: &IdentityPayload,
274    modified: Value,
275) -> Result<IdentityOutcome, HostError> {
276    let fields = object(HOOK_IDENTITY_RESOLVE, modified)?;
277    for (key, value) in &fields {
278        let known = matches!(
279            key.as_str(),
280            "subject"
281                | "delegation"
282                | "raw_claims"
283                | "rejected"
284                | "reject_status"
285                | "reject_reason"
286        ) || IDENTITY_INPUT_FIELDS.contains(&key.as_str());
287        if !known && !is_empty(value) {
288            return Err(unrepresentable(HOOK_IDENTITY_RESOLVE, key));
289        }
290    }
291
292    match fields.get("rejected") {
293        None | Some(Value::Null) | Some(Value::Bool(false)) => {},
294        Some(Value::Bool(true)) => return Ok(IdentityOutcome::Rejected(rejection(&fields)?)),
295        Some(_) => {
296            return Err(protocol(
297                HOOK_IDENTITY_RESOLVE,
298                "`rejected` must be a boolean",
299            ));
300        },
301    }
302
303    let mut resolved = original.clone();
304    if let Some(subject) = fields.get("subject").filter(|v| !v.is_null()) {
305        resolved.subject = Some(parse_lossless::<SubjectExtension>(
306            HOOK_IDENTITY_RESOLVE,
307            "subject",
308            &python_subject(subject),
309        )?);
310    }
311    if let Some(delegation) = fields.get("delegation").filter(|v| !v.is_null()) {
312        resolved.delegation = Some(parse_lossless::<DelegationExtension>(
313            HOOK_IDENTITY_RESOLVE,
314            "delegation",
315            delegation,
316        )?);
317    }
318    match fields.get("raw_claims") {
319        None | Some(Value::Null) => {},
320        Some(Value::Object(claims)) => {
321            for (name, value) in claims {
322                resolved.raw_claims.insert(name.clone(), value.clone());
323            }
324        },
325        Some(_) => {
326            return Err(protocol(
327                HOOK_IDENTITY_RESOLVE,
328                "`raw_claims` must be an object",
329            ));
330        },
331    }
332    Ok(IdentityOutcome::Resolved(Box::new(resolved)))
333}
334
335/// The deny a `rejected` identity result stands for.
336fn rejection(fields: &Map<String, Value>) -> Result<PluginViolation, HostError> {
337    let reason = match fields.get("reject_reason") {
338        None | Some(Value::Null) => "",
339        Some(Value::String(reason)) => reason.as_str(),
340        Some(_) => {
341            return Err(protocol(
342                HOOK_IDENTITY_RESOLVE,
343                "`reject_reason` must be a string",
344            ));
345        },
346    };
347    let reason = if reason.is_empty() {
348        "the identity plugin rejected the credential"
349    } else {
350        reason
351    };
352    let mut violation = PluginViolation::new("auth.rejected", reason);
353    match fields.get("reject_status") {
354        None | Some(Value::Null) => {},
355        Some(status) => {
356            let status = status.as_i64().ok_or_else(|| {
357                protocol(HOOK_IDENTITY_RESOLVE, "`reject_status` must be an integer")
358            })?;
359            violation = violation.with_proto_error_code(status);
360            violation
361                .details
362                .insert("reject_status".into(), Value::from(status));
363        },
364    }
365    Ok(violation)
366}
367
368/// The Python `SubjectExtension` names its kind `type`; PPE's names it
369/// `subject_type`. Renamed so the lossless check does not read it as dropped.
370fn python_subject(subject: &Value) -> Value {
371    let mut subject = subject.clone();
372    if let Some(fields) = subject.as_object_mut()
373        && !fields.contains_key("subject_type")
374        && let Some(kind) = fields.remove("type")
375    {
376        fields.insert("subject_type".into(), kind);
377    }
378    subject
379}
380
381// --- token.delegate ---------------------------------------------------------
382
383/// The worker `DelegationPayload` JSON for a PPE delegation payload.
384///
385/// Refuses an exchange the Python model cannot describe, before anything
386/// reaches the worker.
387pub fn delegation_to_worker(payload: &DelegationPayload) -> Result<Value, HostError> {
388    if payload.actor_role().is_some() || !payload.actor_token().is_empty() {
389        return Err(protocol(
390            HOOK_TOKEN_DELEGATE,
391            "the delegation carries an RFC 8693 actor, which the Python DelegationPayload cannot \
392             carry; a token minted without it would lose its `act` attribution",
393        ));
394    }
395    if payload.subject() != &DelegationSubject::User {
396        return Err(protocol(
397            HOOK_TOKEN_DELEGATE,
398            "the delegation is for a subject other than `user`, and the Python DelegationPayload \
399             reads its bearer token as the user's; a plugin would attribute the minted token to \
400             the wrong principal",
401        ));
402    }
403
404    let mut json = serde_json::json!({
405        "target_name": payload.target_name(),
406        "target_type": to_json(payload.target_type())?,
407        "required_permissions": payload.required_permissions(),
408        "auth_enforced_by": to_json(&payload.auth_enforced_by())?,
409    });
410    if let Some(audience) = payload.target_audience() {
411        json["target_audience"] = Value::String(audience.to_string());
412    }
413    if let Some(domain) = payload.trust_domain() {
414        json["trust_domain"] = Value::String(domain.to_string());
415    }
416    if let Some(attenuation) = payload.route_attenuation() {
417        json["route_attenuation"] = to_json(attenuation)?;
418    }
419    Ok(json)
420}
421
422/// Map a delegation plugin's modified payload back onto a clone of the one
423/// PPE dispatched.
424pub fn delegation_from_worker(
425    original: &DelegationPayload,
426    modified: Value,
427) -> Result<DelegationPayload, HostError> {
428    let fields = object(HOOK_TOKEN_DELEGATE, modified)?;
429    for (key, value) in &fields {
430        let known = matches!(
431            key.as_str(),
432            "delegated_token"
433                | "delegation_update"
434                | "forwarded_headers"
435                | "cache_key"
436                | "cache_ttl"
437        ) || DELEGATION_INPUT_FIELDS.contains(&key.as_str());
438        if !known && !is_empty(value) {
439            return Err(unrepresentable(HOOK_TOKEN_DELEGATE, key));
440        }
441    }
442
443    let token = match fields.get("delegated_token") {
444        None | Some(Value::Null) => None,
445        Some(Value::String(token)) if !token.trim().is_empty() => Some(token.as_str()),
446        Some(Value::String(_)) => {
447            return Err(protocol(
448                HOOK_TOKEN_DELEGATE,
449                "the plugin returned an empty `delegated_token`",
450            ));
451        },
452        Some(_) => {
453            return Err(protocol(
454                HOOK_TOKEN_DELEGATE,
455                "`delegated_token` must be a string",
456            ));
457        },
458    };
459    let forwarded = match fields.get("forwarded_headers") {
460        None | Some(Value::Null) => Map::new(),
461        Some(Value::Object(headers)) => headers.clone(),
462        Some(_) => {
463            return Err(protocol(
464                HOOK_TOKEN_DELEGATE,
465                "`forwarded_headers` must be an object",
466            ));
467        },
468    };
469
470    let mut resolved = original.clone();
471    match token {
472        Some(token) => {
473            let outbound_header = outbound_header(token, &forwarded)?;
474            let now = chrono::Utc::now();
475            let ttl = match fields.get("cache_ttl") {
476                None | Some(Value::Null) => DEFAULT_DELEGATED_TOKEN_TTL_SECS,
477                Some(ttl) => ttl.as_i64().filter(|secs| *secs > 0).ok_or_else(|| {
478                    protocol(
479                        HOOK_TOKEN_DELEGATE,
480                        "`cache_ttl` must be a positive integer",
481                    )
482                })?,
483            };
484            let audience = original
485                .target_audience()
486                .unwrap_or_else(|| original.target_name())
487                .to_string();
488            let expires_at = chrono::Duration::try_seconds(ttl)
489                .and_then(|duration| now.checked_add_signed(duration))
490                .ok_or_else(|| protocol(HOOK_TOKEN_DELEGATE, "`cache_ttl` is out of range"))?;
491            resolved.delegated_token = Some(RawDelegatedToken::new(
492                token,
493                outbound_header,
494                audience,
495                original.required_permissions().to_vec(),
496                expires_at,
497            ));
498            resolved.delegation_mode = Some(original.subject().default_mode());
499            resolved.minted_at = Some(now);
500        },
501        None if !forwarded.is_empty() => {
502            return Err(protocol(
503                HOOK_TOKEN_DELEGATE,
504                "the plugin returned `forwarded_headers` with no `delegated_token`, and a PPE \
505                 delegation payload carries headers only as the minted token's outbound header",
506            ));
507        },
508        None => {},
509    }
510
511    if let Some(update) = fields.get("delegation_update").filter(|v| !v.is_null()) {
512        let update: DelegationExtension =
513            parse_lossless(HOOK_TOKEN_DELEGATE, "delegation_update", update)?;
514        if !is_default_delegation(&update) {
515            resolved.delegation_update = Some(update);
516        }
517    }
518    Ok(resolved)
519}
520
521/// The header the minted token goes upstream in.
522///
523/// The Python result can name it through `forwarded_headers`. The PPE payload
524/// holds one outbound header for one token, so the only shape that maps is a
525/// single header whose value is the token, bare or as a bearer.
526fn outbound_header(token: &str, forwarded: &Map<String, Value>) -> Result<String, HostError> {
527    let mut entries = forwarded.iter();
528    let Some((name, value)) = entries.next() else {
529        return Ok(DEFAULT_OUTBOUND_HEADER.to_string());
530    };
531    let carries_token = value
532        .as_str()
533        .is_some_and(|v| v == token || strip_bearer(v) == token);
534    if entries.next().is_some() || !carries_token {
535        return Err(protocol(
536            HOOK_TOKEN_DELEGATE,
537            "the plugin returned `forwarded_headers` other than the single header carrying the \
538             minted token, and a PPE delegation payload cannot carry extra headers",
539        ));
540    }
541    Ok(name.clone())
542}
543
544fn is_default_delegation(update: &DelegationExtension) -> bool {
545    update.chain.is_empty()
546        && update.depth == 0
547        && update.origin_subject_id.is_none()
548        && update.actor_subject_id.is_none()
549        && !update.delegated
550}
551
552// --- shared -----------------------------------------------------------------
553
554fn object(hook: &str, value: Value) -> Result<Map<String, Value>, HostError> {
555    match value {
556        Value::Object(fields) => Ok(fields),
557        _ => Err(protocol(hook, "the modified payload is not a JSON object")),
558    }
559}
560
561/// Deserialize into a PPE type, failing closed when that drops a set field.
562///
563/// PPE's extension types ignore fields they do not know, so a plain
564/// `from_value` would silently lose whatever the Python model has and PPE's
565/// does not. Serializing back and comparing which set fields survived catches
566/// that. The error names the field path, never a value.
567fn parse_lossless<T: DeserializeOwned + Serialize>(
568    hook: &str,
569    field: &str,
570    value: &Value,
571) -> Result<T, HostError> {
572    let parsed: T = serde_json::from_value(value.clone()).map_err(|_| {
573        protocol(
574            hook,
575            &format!("`{field}` does not match the PPE type it maps to"),
576        )
577    })?;
578    let back = serde_json::to_value(&parsed).map_err(|e| HostError::Protocol {
579        message: format!("could not re-serialize `{field}`: {e}"),
580    })?;
581    if let Some(path) = first_dropped(value, &back, field) {
582        return Err(unrepresentable(hook, &path));
583    }
584    Ok(parsed)
585}
586
587/// The path of the first set field in `original` that `round_tripped` lost.
588fn first_dropped(original: &Value, round_tripped: &Value, path: &str) -> Option<String> {
589    match (original, round_tripped) {
590        (Value::Object(fields), Value::Object(kept)) => fields.iter().find_map(|(key, value)| {
591            if is_empty(value) {
592                return None;
593            }
594            let path = format!("{path}.{key}");
595            match kept.get(key) {
596                None => Some(path),
597                Some(kept) => first_dropped(value, kept, &path),
598            }
599        }),
600        (Value::Array(items), Value::Array(kept)) => {
601            if items.len() != kept.len() {
602                return Some(path.to_string());
603            }
604            items
605                .iter()
606                .zip(kept)
607                .enumerate()
608                .find_map(|(i, (item, kept))| first_dropped(item, kept, &format!("{path}[{i}]")))
609        },
610        _ => None,
611    }
612}
613
614/// A value that carries nothing: what Pydantic dumps for an unset field.
615fn is_empty(value: &Value) -> bool {
616    match value {
617        Value::Null | Value::Bool(false) => true,
618        Value::String(s) => s.is_empty(),
619        Value::Array(items) => items.is_empty(),
620        Value::Object(fields) => fields.is_empty(),
621        Value::Bool(true) | Value::Number(_) => false,
622    }
623}
624
625fn to_json<T: Serialize>(value: &T) -> Result<Value, HostError> {
626    serde_json::to_value(value).map_err(|e| HostError::Protocol {
627        message: format!("could not serialize a delegation payload field: {e}"),
628    })
629}
630
631fn protocol(hook: &str, message: &str) -> HostError {
632    HostError::Protocol {
633        message: format!("hook '{hook}': {message}"),
634    }
635}
636
637fn unrepresentable(hook: &str, field: &str) -> HostError {
638    protocol(
639        hook,
640        &format!(
641            "the plugin returned `{field}`, which the PPE payload has nowhere to hold; refusing \
642             to drop it silently"
643        ),
644    )
645}
646
647#[cfg(test)]
648mod tests {
649    use praxis_policy_core::delegation::AttenuationConfig;
650    use praxis_policy_core::extensions::raw_credentials::TokenRole;
651    use praxis_policy_core::extensions::{DelegationHop, SubjectType};
652    use praxis_policy_core::identity::TokenSource;
653    use serde_json::json;
654
655    use super::*;
656
657    const TOKEN: &str = "eyJhbGciOiJSUzI1NiJ9.IDENTITY-PLAINTEXT.sig";
658    const MINTED: &str = "MINTED-DELEGATED-PLAINTEXT";
659
660    fn identity() -> IdentityPayload {
661        IdentityPayload::new(TOKEN, TokenSource::Bearer)
662            .with_source_header("x-user-token")
663            .with_headers(HashMap::from([
664                ("authorization".into(), format!("Bearer {TOKEN}")),
665                ("x-user-token".into(), "opaque-user-credential".into()),
666                ("cookie".into(), "session=COOKIE-SECRET".into()),
667                ("x-echo".into(), format!("copy:{TOKEN}")),
668                ("x-tenant".into(), "acme".into()),
669            ]))
670            .with_client_host("10.0.0.1")
671            .with_client_port(8443)
672    }
673
674    fn delegation() -> DelegationPayload {
675        DelegationPayload::new(TOKEN, "billing-api")
676            .with_target_audience("https://billing.example.com")
677            .with_required_permissions(vec!["invoices:read".into()])
678            .with_trust_domain("example.org")
679            .with_route_attenuation(AttenuationConfig {
680                capabilities: vec!["read".into()],
681                ttl_seconds: Some(120),
682                ..Default::default()
683            })
684    }
685
686    fn resolved(outcome: IdentityOutcome) -> IdentityPayload {
687        match outcome {
688            IdentityOutcome::Resolved(payload) => *payload,
689            IdentityOutcome::Rejected(v) => panic!("expected a resolved payload, got {v:?}"),
690        }
691    }
692
693    // --- identity outbound ----------------------------------------------------
694
695    #[test]
696    fn identity_json_carries_no_credential_material() {
697        let json = identity_to_worker(&identity()).unwrap();
698        let wire = json.to_string();
699        assert!(!wire.contains("IDENTITY-PLAINTEXT"), "token leaked: {wire}");
700        assert!(
701            !wire.contains("opaque-user-credential"),
702            "source header leaked: {wire}"
703        );
704        assert!(!wire.contains("COOKIE-SECRET"), "cookie leaked: {wire}");
705
706        assert_eq!(json["raw_token"], "");
707        assert_eq!(json["source"], "bearer");
708        assert_eq!(json["headers"], json!({}));
709        assert_eq!(json["client_host"], "10.0.0.1");
710        assert_eq!(json["client_port"], 8443);
711    }
712
713    #[test]
714    fn identity_json_matches_the_worker_model() {
715        // The legacy struct mirrors the worker's Pydantic model field for field.
716        let json = identity_to_worker(&identity()).unwrap();
717        let mirrored: crate::legacy::IdentityResolvePayload =
718            serde_json::from_value(json).expect("the worker model accepts the shape");
719        assert_eq!(mirrored.source, "bearer");
720    }
721
722    #[test]
723    fn a_header_borne_credential_is_found_and_no_raw_headers_are_public() {
724        // The FFI and the Python bindings deserialize the payload, so the token
725        // arrives in the header map and `raw_token` is empty.
726        let payload: IdentityPayload = serde_json::from_value(json!({
727            "source": "bearer",
728            "headers": {
729                "Authorization": format!("Bearer {TOKEN}"),
730                "x-tenant": "acme",
731                "X-Amzn-Oidc-Data": "gateway-jwt",
732                "X-Request-Id": "looks-benign-but-unvalidated"
733            },
734        }))
735        .unwrap();
736        assert_eq!(inbound_token(&payload).as_deref(), Some(TOKEN));
737        assert_eq!(credential_header_name(&payload), "Authorization");
738        assert!(public_headers(&payload).is_empty());
739    }
740
741    #[test]
742    fn a_custom_source_keeps_its_wire_spelling() {
743        let payload = IdentityPayload::new(TOKEN, TokenSource::Custom("magic-link".into()));
744        assert_eq!(
745            identity_to_worker(&payload).unwrap()["source"],
746            "magic-link"
747        );
748    }
749
750    // --- identity inbound -----------------------------------------------------
751
752    #[test]
753    fn an_identity_result_maps_onto_a_clone_of_the_dispatched_payload() {
754        let original = identity();
755        let back = resolved(
756            identity_from_worker(
757                &original,
758                json!({
759                    "subject": {
760                        "id": "alice", "type": "user", "roles": ["hr"],
761                        "permissions": [], "teams": [], "claims": {"dept": "people"},
762                    },
763                    "delegation": null,
764                    "rejected": false,
765                    "reject_status": 401,
766                    "reject_reason": "",
767                    "raw_claims": {"sub": "alice"},
768                }),
769            )
770            .unwrap(),
771        );
772
773        let subject = back.subject.as_ref().expect("the subject is written");
774        assert_eq!(subject.id.as_deref(), Some("alice"));
775        assert_eq!(subject.subject_type, Some(SubjectType::User));
776        assert!(subject.roles.contains("hr"));
777        assert_eq!(subject.claims["dept"], "people");
778        assert_eq!(back.raw_claims["sub"], "alice");
779
780        // Input fields are the dispatched ones, so PPE's later steps still
781        // read the host's credential.
782        assert_eq!(back.raw_token(), TOKEN);
783        assert_eq!(back.source_header(), Some("x-user-token"));
784        assert_eq!(back.headers().len(), original.headers().len());
785    }
786
787    #[test]
788    fn a_resolved_identity_survives_ppes_pipeline_downcast() {
789        let back = resolved(
790            identity_from_worker(
791                &identity(),
792                json!({"subject": {"id": "alice", "type": "user"}}),
793            )
794            .unwrap(),
795        );
796        let boxed: Box<dyn PluginPayload> = Box::new(back);
797        let result = praxis_policy_core::executor::PipelineResult::allowed_with(
798            boxed,
799            praxis_policy_core::extensions::Extensions::default(),
800            Default::default(),
801        );
802        let extracted = IdentityPayload::from_pipeline_result(&result)
803            .expect("PPE downcasts the modified payload to IdentityPayload");
804        assert_eq!(
805            extracted.subject.and_then(|s| s.id).as_deref(),
806            Some("alice")
807        );
808    }
809
810    #[test]
811    fn an_echoed_identity_payload_changes_nothing() {
812        let original = identity();
813        let echo = json!({
814            "raw_token": "**********", "source": "custom",
815            "headers": {"x-injected": "1"}, "client_host": "6.6.6.6", "client_port": 1,
816        });
817        let back = resolved(identity_from_worker(&original, echo).unwrap());
818        assert!(back.subject.is_none());
819        assert_eq!(
820            back.client_host(),
821            Some("10.0.0.1"),
822            "inputs stay canonical"
823        );
824        assert_eq!(back.raw_token(), TOKEN);
825    }
826
827    #[test]
828    fn a_rejected_identity_becomes_a_deny() {
829        let outcome = identity_from_worker(
830            &identity(),
831            json!({
832                "subject": {"id": "alice", "type": "user"},
833                "rejected": true, "reject_status": 403, "reject_reason": "token expired",
834            }),
835        )
836        .unwrap();
837        let IdentityOutcome::Rejected(violation) = outcome else {
838            panic!("rejected must deny even when a subject is set");
839        };
840        assert_eq!(violation.code, "auth.rejected");
841        assert_eq!(violation.reason, "token expired");
842        assert_eq!(violation.proto_error_code, Some(403));
843        assert_eq!(violation.details["reject_status"], 403);
844    }
845
846    #[test]
847    fn an_unknown_identity_result_field_fails_closed() {
848        let err = identity_from_worker(&identity(), json!({"client": {"id": "gw"}})).unwrap_err();
849        assert!(matches!(err, HostError::Protocol { .. }), "{err:?}");
850        assert!(err.to_string().contains("`client`"), "{err}");
851    }
852
853    #[test]
854    fn a_subject_field_ppe_cannot_hold_fails_closed() {
855        let err = identity_from_worker(
856            &identity(),
857            json!({"subject": {"id": "alice", "type": "user", "tenant": "acme"}}),
858        )
859        .unwrap_err();
860        assert!(err.to_string().contains("subject.tenant"), "{err}");
861    }
862
863    #[test]
864    fn a_malformed_subject_fails_closed_without_echoing_values() {
865        let err = identity_from_worker(
866            &identity(),
867            json!({"subject": {"id": "SUBJECT-VALUE", "type": "SUBJECT-VALUE"}}),
868        )
869        .unwrap_err();
870        assert!(!err.to_string().contains("SUBJECT-VALUE"), "{err}");
871    }
872
873    // --- delegation outbound --------------------------------------------------
874
875    #[test]
876    fn delegation_json_matches_the_worker_model_and_carries_no_token() {
877        let json = delegation_to_worker(&delegation()).unwrap();
878        assert!(!json.to_string().contains("IDENTITY-PLAINTEXT"), "{json}");
879        assert!(json.get("bearer_token").is_none());
880
881        let mirrored: crate::legacy::TokenDelegatePayload =
882            serde_json::from_value(json.clone()).expect("the worker model accepts the shape");
883        assert_eq!(mirrored.target_name, "billing-api");
884        assert_eq!(mirrored.target_type, "tool");
885        assert_eq!(
886            mirrored.target_audience.as_deref(),
887            Some("https://billing.example.com")
888        );
889        assert_eq!(mirrored.required_permissions, vec!["invoices:read"]);
890        assert_eq!(mirrored.trust_domain.as_deref(), Some("example.org"));
891        assert_eq!(mirrored.auth_enforced_by, "caller");
892        assert_eq!(json["route_attenuation"]["ttl_seconds"], 120);
893    }
894
895    #[test]
896    fn a_delegation_with_an_actor_fails_closed() {
897        let payload = delegation().with_actor(TokenRole::CallerWorkload, "svid");
898        let err = delegation_to_worker(&payload).unwrap_err();
899        assert!(err.to_string().contains("actor"), "{err}");
900    }
901
902    #[test]
903    fn a_delegation_for_another_subject_fails_closed() {
904        let payload = delegation().with_subject(DelegationSubject::ThisWorkload);
905        let err = delegation_to_worker(&payload).unwrap_err();
906        assert!(err.to_string().contains("subject"), "{err}");
907    }
908
909    // --- delegation inbound ---------------------------------------------------
910
911    #[test]
912    fn a_delegation_result_maps_onto_a_clone_of_the_dispatched_payload() {
913        let original = delegation();
914        let back = delegation_from_worker(
915            &original,
916            json!({
917                "delegated_token": MINTED,
918                "delegation_update": {
919                    "chain": [{"subject_id": "alice", "subject_type": "user",
920                               "audience": "https://billing.example.com",
921                               "scopes_granted": ["invoices:read"],
922                               "timestamp": "2026-01-01T00:00:00Z",
923                               "ttl_seconds": null, "strategy": "token_exchange",
924                               "from_cache": false}],
925                    "depth": 1, "origin_subject_id": "alice", "actor_subject_id": "alice",
926                    "delegated": true, "age_seconds": 0.0,
927                },
928                "forwarded_headers": {"X-Upstream-Auth": format!("Bearer {MINTED}")},
929                "cache_key": "k",
930                "cache_ttl": 300,
931            }),
932        )
933        .unwrap();
934
935        let token = back.delegated_token.as_ref().expect("the token is written");
936        assert_eq!(token.token.as_str(), MINTED);
937        assert_eq!(token.outbound_header, "X-Upstream-Auth");
938        assert_eq!(token.audience, "https://billing.example.com");
939        assert_eq!(token.scopes, vec!["invoices:read"]);
940        let ttl = token.expires_at - chrono::Utc::now();
941        assert!(ttl.num_seconds() > 250 && ttl.num_seconds() <= 300, "{ttl}");
942        assert_eq!(
943            back.delegation_mode,
944            Some(praxis_policy_core::extensions::raw_credentials::DelegationMode::OnBehalfOfUser)
945        );
946        let update = back
947            .delegation_update
948            .as_ref()
949            .expect("the chain update is written");
950        assert_eq!(update.depth, 1);
951        assert_eq!(update.chain.len(), 1);
952
953        // The caller's credential stays on the clone for PPE to apply.
954        assert_eq!(back.bearer_token(), TOKEN);
955
956        let boxed: Box<dyn PluginPayload> = Box::new(back);
957        let result = praxis_policy_core::executor::PipelineResult::allowed_with(
958            boxed,
959            praxis_policy_core::extensions::Extensions::default(),
960            Default::default(),
961        );
962        assert!(
963            DelegationPayload::from_pipeline_result(&result).is_some(),
964            "PPE downcasts the modified payload to DelegationPayload"
965        );
966    }
967
968    #[test]
969    fn a_minimal_delegation_result_uses_the_defaults() {
970        let back = delegation_from_worker(
971            &delegation(),
972            json!({"delegated_token": MINTED, "delegation_update": {
973                "chain": [], "depth": 0, "origin_subject_id": null,
974                "actor_subject_id": null, "delegated": false, "age_seconds": 0.0,
975            }, "forwarded_headers": {}, "cache_key": null, "cache_ttl": null}),
976        )
977        .unwrap();
978        let token = back.delegated_token.unwrap();
979        assert_eq!(token.outbound_header, DEFAULT_OUTBOUND_HEADER);
980        assert!(
981            back.delegation_update.is_none(),
982            "an empty default update is no update"
983        );
984    }
985
986    #[test]
987    fn out_of_range_delegation_ttl_is_a_protocol_error() {
988        let err = delegation_from_worker(
989            &delegation(),
990            json!({"delegated_token": MINTED, "cache_ttl": i64::MAX}),
991        )
992        .unwrap_err();
993        assert!(matches!(err, HostError::Protocol { .. }), "{err:?}");
994        assert!(err.to_string().contains("`cache_ttl` is out of range"));
995    }
996
997    #[test]
998    fn extra_forwarded_headers_fail_closed() {
999        for forwarded in [
1000            json!({"Authorization": format!("Bearer {MINTED}"), "X-Extra": "1"}),
1001            json!({"X-Other": "not-the-token"}),
1002        ] {
1003            let err = delegation_from_worker(
1004                &delegation(),
1005                json!({"delegated_token": MINTED, "forwarded_headers": forwarded}),
1006            )
1007            .unwrap_err();
1008            assert!(err.to_string().contains("forwarded_headers"), "{err}");
1009            assert!(!err.to_string().contains(MINTED), "{err}");
1010        }
1011    }
1012
1013    #[test]
1014    fn forwarded_headers_without_a_token_fail_closed() {
1015        let err = delegation_from_worker(
1016            &delegation(),
1017            json!({"forwarded_headers": {"Authorization": "Bearer x"}}),
1018        )
1019        .unwrap_err();
1020        assert!(err.to_string().contains("forwarded_headers"), "{err}");
1021    }
1022
1023    #[test]
1024    fn an_unknown_delegation_result_field_fails_closed() {
1025        let err = delegation_from_worker(
1026            &delegation(),
1027            json!({"delegated_token": MINTED, "refresh_token": "r"}),
1028        )
1029        .unwrap_err();
1030        assert!(err.to_string().contains("refresh_token"), "{err}");
1031        assert!(!err.to_string().contains(MINTED), "{err}");
1032    }
1033
1034    #[test]
1035    fn a_delegation_hop_field_ppe_cannot_hold_fails_closed() {
1036        let hop = serde_json::to_value(DelegationHop {
1037            subject_id: "alice".into(),
1038            ..Default::default()
1039        })
1040        .unwrap();
1041        let mut hop = hop.as_object().unwrap().clone();
1042        hop.insert("issuer".into(), json!("idp"));
1043        let err = delegation_from_worker(
1044            &delegation(),
1045            json!({"delegation_update": {"chain": [hop], "depth": 1}}),
1046        )
1047        .unwrap_err();
1048        assert!(
1049            err.to_string()
1050                .contains("delegation_update.chain[0].issuer"),
1051            "{err}"
1052        );
1053    }
1054
1055    #[test]
1056    fn no_token_and_no_update_returns_the_payload_unchanged() {
1057        let back =
1058            delegation_from_worker(&delegation(), json!({"target_name": "billing-api"})).unwrap();
1059        assert!(back.delegated_token.is_none());
1060        assert!(back.delegation_update.is_none());
1061    }
1062}