Skip to main content

ppoppo_token/access_token/
act.rs

1//! `act` (RFC 8693 §4.1) — **who is acting** for the subject.
2//!
3//! The second of the two axes this vocabulary admits. [`EntityType`]
4//! answers *what the principal is*; this answers *who is currently driving
5//! it*. Keeping them apart is the whole point of RFC_202607252223 — a human
6//! identity operated by an agent is `entity_type = "human"` **plus** an
7//! `act`, never a third value in the identity vocabulary.
8//!
9//! ## Why the registered claim name
10//!
11//! RFC 8693 §4.1 defines `act` as *"a means within a JWT to express that
12//! delegation has occurred and identify the acting party to whom authority
13//! has been delegated"* — a JSON **object**, with chains expressed by
14//! nesting (outermost = most recent actor). PAS mints these tokens from an
15//! RPC literally named `ExchangeToken` and, for an agent's dependent, from the
16//! RFC 8693 grant itself on `/oauth/token`; RFC 8693 *is* OAuth 2.0 Token
17//! Exchange, so the semantics apply exactly rather than "don't apply here"
18//! as the retired `delegator` claim's rationale asserted.
19//!
20//! ## Depth is the nesting, not a second claim
21//!
22//! The retired `dlg_depth` claim reified a fact the structure already
23//! carries. Counting [`Act::depth`] is strictly stronger: a token cannot
24//! *misreport* its own depth when the depth is the shape.
25//!
26//! ## Deliberately stricter than the RFC
27//!
28//! RFC 8693 §4.1 permits arbitrary actor-identifying claims inside `act`.
29//! This type admits `sub` and a nested `act` and nothing else. That is not
30//! an oversight to be "fixed" toward RFC permissiveness: M45's PII
31//! allowlist scans **top-level keys only**, so the interior of the first
32//! object-valued claim would otherwise be a region the allowlist
33//! structurally cannot see (`act: {"sub": …, "email": …}` would sail
34//! through). PAS is the only issuer and emits only `sub`; M45's premise is
35//! that anything PAS would not emit is forgery.
36//!
37//! Two strictnesses are load-bearing, and both recurse because the nested
38//! field is this same type:
39//!
40//! 1. **`deny_unknown_fields`** — no extra interior keys.
41//! 2. **Map form only.** serde's derived `Deserialize` also accepts a
42//!    *sequence* whose elements are the fields in declaration order, and
43//!    it does not reject trailing elements — so `["actor", null, "…"]`
44//!    would parse *and* carry an unnamed payload past both the allowlist
45//!    and `deny_unknown_fields`. Rejecting anything but a JSON object is
46//!    what makes point 1 airtight rather than decorative.
47//!
48//! [`EntityType`]: super::EntityType
49
50use std::fmt;
51
52use serde::de::{MapAccess, Visitor, value::MapAccessDeserializer};
53use serde::{Deserialize, Deserializer, Serialize};
54
55/// The acting party (RFC 8693 §4.1), and — through [`Self::act`] — the
56/// delegation chain behind it.
57///
58/// Wire shape is the RFC's: `{"sub": "…", "act": {"sub": "…"}}`. The
59/// outermost value is the *current* actor; each nested `act` is the party
60/// that authorized the one enclosing it.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62pub struct Act {
63    /// Identifier of the acting party. PAS stamps the actor's `ppnum_id`
64    /// (ULID); the engine does not validate the format, because a future
65    /// Token Exchange phase may carry non-ppoppo principals here.
66    pub sub: String,
67
68    /// The prior link in the delegation chain, if any. `Box` because the
69    /// type is self-referential; `None` for the common single-hop case.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub act: Option<Box<Act>>,
72}
73
74/// The map-form fields, derived so `deny_unknown_fields` does the interior
75/// allowlisting. Kept private: [`Act`]'s own `Deserialize` is the only way
76/// in, and it refuses every wire form but a JSON object.
77#[derive(Deserialize)]
78#[serde(deny_unknown_fields)]
79struct ActFields {
80    sub: String,
81    #[serde(default)]
82    act: Option<Box<Act>>,
83}
84
85impl<'de> Deserialize<'de> for Act {
86    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
87        struct MapOnly;
88
89        impl<'de> Visitor<'de> for MapOnly {
90            type Value = Act;
91
92            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93                f.write_str("an RFC 8693 `act` object")
94            }
95
96            fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Act, A::Error> {
97                let fields = ActFields::deserialize(MapAccessDeserializer::new(map))?;
98                Ok(Act {
99                    sub: fields.sub,
100                    act: fields.act,
101                })
102            }
103        }
104
105        // `deserialize_map`, not `deserialize_struct`: the latter also
106        // admits the sequence form (see the module docs).
107        deserializer.deserialize_map(MapOnly)
108    }
109}
110
111impl Act {
112    /// A single-hop actor — the shape both PAS agent-flow mint sites emit.
113    ///
114    /// There is deliberately no chain builder: no mint site nests today
115    /// (the retired flat `delegator` claim could not express a chain
116    /// either, so nothing regresses). The engine still enforces
117    /// [`Self::depth`] on *inbound* tokens regardless, because a nested
118    /// `act` arriving at verify is either another issuer's or a forgery.
119    #[must_use]
120    pub fn new(sub: impl Into<String>) -> Self {
121        Self {
122            sub: sub.into(),
123            act: None,
124        }
125    }
126
127    /// Delegation depth — `1` for a single actor, `+1` per nested link.
128    ///
129    /// Iterative rather than recursive: the payload is attacker-supplied,
130    /// and a bound that could blow the stack while measuring it would be
131    /// no bound at all.
132    #[must_use]
133    pub fn depth(&self) -> usize {
134        let mut depth = 1;
135        let mut link = self.act.as_deref();
136        while let Some(next) = link {
137            depth += 1;
138            link = next.act.as_deref();
139        }
140        depth
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
147    use super::*;
148
149    #[test]
150    fn single_actor_is_depth_one() {
151        assert_eq!(Act::new("01HSAB00000000000000000000").depth(), 1);
152    }
153
154    #[test]
155    fn depth_counts_every_nested_link() {
156        let chain = Act {
157            sub: "a".into(),
158            act: Some(Box::new(Act {
159                sub: "b".into(),
160                act: Some(Box::new(Act::new("c"))),
161            })),
162        };
163        assert_eq!(chain.depth(), 3);
164    }
165
166    /// The wire shape is the RFC's, and a single-hop actor must not emit a
167    /// `"act": null` key — absent means absent.
168    #[test]
169    fn single_hop_serializes_to_the_rfc_shape() {
170        let json = serde_json::to_value(Act::new("actor")).expect("serialize");
171        assert_eq!(json, serde_json::json!({"sub": "actor"}));
172    }
173
174    #[test]
175    fn nested_shape_round_trips() {
176        let chain = Act {
177            sub: "outer".into(),
178            act: Some(Box::new(Act::new("inner"))),
179        };
180        let json = serde_json::to_value(&chain).expect("serialize");
181        assert_eq!(
182            json,
183            serde_json::json!({"sub":"outer","act":{"sub":"inner"}})
184        );
185        assert_eq!(serde_json::from_value::<Act>(json).expect("parse"), chain);
186    }
187
188    /// **The M45 blind spot this type closes.** The PII allowlist scans
189    /// top-level keys; without `deny_unknown_fields` an interior `email`
190    /// would never be looked at by anything.
191    #[test]
192    fn interior_pii_is_rejected_at_every_level() {
193        for smuggled in [
194            serde_json::json!({"sub": "actor", "email": "a@b.c"}),
195            serde_json::json!({"sub": "outer", "act": {"sub": "inner", "email": "a@b.c"}}),
196        ] {
197            assert!(
198                serde_json::from_value::<Act>(smuggled.clone()).is_err(),
199                "{smuggled} smuggles a claim past M45's top-level-only scan",
200            );
201        }
202    }
203
204    #[test]
205    fn sub_is_mandatory() {
206        assert!(serde_json::from_value::<Act>(serde_json::json!({})).is_err());
207    }
208
209    /// **The reason `Deserialize` is hand-written.** serde's derive also
210    /// accepts a sequence of the fields in declaration order *and ignores
211    /// trailing elements* — so this array would otherwise parse into a
212    /// valid `Act` while carrying an unnamed payload that neither M45 nor
213    /// `deny_unknown_fields` can see. Map form only, at every depth.
214    #[test]
215    fn sequence_form_is_not_an_actor_object() {
216        for seq in [
217            serde_json::json!(["actor"]),
218            serde_json::json!(["actor", null, "smuggled"]),
219            serde_json::json!({"sub": "outer", "act": ["inner", null, "smuggled"]}),
220        ] {
221            assert!(
222                serde_json::from_value::<Act>(seq.clone()).is_err(),
223                "{seq} is not the RFC 8693 object form",
224            );
225        }
226    }
227}