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