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