Skip to main content

systemprompt_models/auth/
claims.rs

1//! Self-issued JWT claim shapes and the RFC 8693 delegation chain.
2//!
3//! [`JwtClaims`] is the canonical token payload; its scope/roles/attributes
4//! fields are the transport for the platform's three authorization layers
5//! (PBAC, RBAC, ABAC). [`ActClaim`] models the recursive `act` delegation
6//! chain, capped at [`MAX_ACT_CHAIN_DEPTH`].
7
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::str::FromStr;
11use systemprompt_identifiers::{ClientId, SessionId, UserId};
12
13use super::{
14    JwtAudience, Permission, RateLimitTier, TokenType, UserType, parse_permissions,
15    permissions_to_string,
16};
17use systemprompt_identifiers::Actor;
18
19/// RFC 8693 §4.1 actor (`act`) claim.
20///
21/// Captures the immediate actor (`iss` + `sub`) that requested a token
22/// exchange and a recursive `act` link to its own delegating actor. The
23/// chain is walked outermost-first by [`ActClaim::flatten_to_chain`]:
24/// the outermost `JwtClaims.act` is the most recent delegate, and each
25/// nested `act` is the actor that delegated to it.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ActClaim {
28    pub iss: String,
29    pub sub: String,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub act: Box<Option<Self>>,
32}
33
34/// Maximum delegation-chain depth accepted by the platform. RFC 8693
35/// does not bound `act` recursion; the cap protects audit storage and
36/// makes "ever-growing delegation lineage" rejectable at decode time.
37pub const MAX_ACT_CHAIN_DEPTH: usize = 16;
38
39impl ActClaim {
40    /// Walk the `act` chain and return reconstructed [`Actor`] values in
41    /// outermost-first order: index 0 is the most recent delegate
42    /// (i.e. `self`), and the last element is the original delegating
43    /// principal. The chain is truncated at [`MAX_ACT_CHAIN_DEPTH`].
44    ///
45    /// Every link is reconstructed as [`Actor::user`] with the `sub`
46    /// claim as the [`UserId`]; `ActorKind` cannot be discerned from a
47    /// bare RFC 8693 `act` chain, so we default to `User`.
48    #[must_use]
49    pub fn flatten_to_chain(&self) -> Vec<Actor> {
50        let mut chain = Vec::new();
51        let mut cursor = Some(self);
52        while let Some(node) = cursor {
53            if chain.len() >= MAX_ACT_CHAIN_DEPTH {
54                break;
55            }
56            chain.push(Actor::user(UserId::new(node.sub.clone())));
57            cursor = node.act.as_ref().as_ref();
58        }
59        chain
60    }
61
62    /// Count nodes in the delegation chain without allocating. Returns
63    /// `MAX_ACT_CHAIN_DEPTH + 1` if the chain exceeds the cap so callers
64    /// can short-circuit with a single comparison.
65    #[must_use]
66    pub fn depth(&self) -> usize {
67        let mut depth = 0usize;
68        let mut cursor = Some(self);
69        while let Some(node) = cursor {
70            depth += 1;
71            if depth > MAX_ACT_CHAIN_DEPTH {
72                return depth;
73            }
74            cursor = node.act.as_ref().as_ref();
75        }
76        depth
77    }
78}
79
80/// Self-issued JWT claim shape.
81///
82/// Fields are grouped by who consumes them downstream. The platform runs
83/// authorization in three layers (see `internal/guides/authz.md`); each
84/// attribute field below is the transport for one of those layers. Do not
85/// delete a field without first removing its readers.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct JwtClaims {
88    pub sub: String,
89    pub iat: i64,
90    pub exp: i64,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub nbf: Option<i64>,
93    pub iss: String,
94    #[serde(
95        serialize_with = "serialize_audiences",
96        deserialize_with = "deserialize_audiences"
97    )]
98    pub aud: Vec<JwtAudience>,
99    pub jti: String,
100
101    /// PBAC scope: the `Permission` set this principal is granted. Enforced
102    /// at every route via `with_auth(scope)`; the first element is the
103    /// privilege level used by [`UserType::from_permissions`].
104    #[serde(
105        serialize_with = "serialize_scope",
106        deserialize_with = "deserialize_scope"
107    )]
108    pub scope: Vec<Permission>,
109
110    pub username: String,
111    pub email: String,
112    pub user_type: UserType,
113
114    /// RBAC attribute: role strings minted from the user row at OAuth
115    /// issuance. Consumed by `authz::resolver::resolve` for the core RBAC
116    /// check against `access_control_rules` (`rule_type = role`) and
117    /// forwarded into every `AuthzDecisionHook::evaluate` call. The only
118    /// first-class identity vector core inspects; everything else is
119    /// extension-defined via [`attributes`](Self::attributes).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub roles: Vec<String>,
122
123    /// Opaque ABAC attribute bag. The token issuer mints whatever
124    /// namespaced facts the extension authz hook needs (e.g.
125    /// `"acme.desk": "fixed-income"`, `"boeing.clearance": "ts/sci"`).
126    /// Core never interprets values — keys SHOULD be dotted-namespaced.
127    /// Forwarded verbatim into `AuthzRequest.attributes`.
128    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
129    pub attributes: BTreeMap<String, serde_json::Value>,
130
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub client_id: Option<ClientId>,
133    pub token_type: TokenType,
134    pub auth_time: i64,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub session_id: Option<SessionId>,
137
138    /// Rate-limit attribute: minted from `UserType::rate_tier` so the tier is
139    /// committed at issuance rather than re-derived per request. Read at the
140    /// rate-limit middleware via `RequestContext::rate_limit_tier()`.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub rate_limit_tier: Option<RateLimitTier>,
143
144    /// Hook attribute: the plugin id this token was minted for. Validated by
145    /// the hook-token validator in `systemprompt_security::auth::hook_token` —
146    /// a hook request whose `plugin_id` claim disagrees with the URL path's
147    /// `plugin_id` is rejected.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub plugin_id: Option<String>,
150
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub act: Option<ActClaim>,
153}
154
155fn serialize_audiences<S>(auds: &[JwtAudience], s: S) -> Result<S::Ok, S::Error>
156where
157    S: serde::Serializer,
158{
159    use serde::ser::SerializeSeq;
160    let mut seq = s.serialize_seq(Some(auds.len()))?;
161    for aud in auds {
162        seq.serialize_element(aud.as_str())?;
163    }
164    seq.end()
165}
166
167fn deserialize_audiences<'de, D>(d: D) -> Result<Vec<JwtAudience>, D::Error>
168where
169    D: serde::Deserializer<'de>,
170{
171    let strings: Vec<String> = Vec::deserialize(d)?;
172    strings
173        .iter()
174        .map(|s| JwtAudience::from_str(s).map_err(serde::de::Error::custom))
175        .collect()
176}
177
178fn serialize_scope<S>(permissions: &[Permission], s: S) -> Result<S::Ok, S::Error>
179where
180    S: serde::Serializer,
181{
182    s.serialize_str(&permissions_to_string(permissions))
183}
184
185fn deserialize_scope<'de, D>(d: D) -> Result<Vec<Permission>, D::Error>
186where
187    D: serde::Deserializer<'de>,
188{
189    let scope_string: String = String::deserialize(d)?;
190    parse_permissions(&scope_string).map_err(serde::de::Error::custom)
191}
192
193impl JwtClaims {
194    pub fn has_permission(&self, permission: Permission) -> bool {
195        self.scope.contains(&permission)
196    }
197
198    pub fn permissions(&self) -> &[Permission] {
199        &self.scope
200    }
201
202    pub fn get_permissions(&self) -> Vec<Permission> {
203        self.scope.clone()
204    }
205
206    pub fn get_scopes(&self) -> Vec<String> {
207        self.scope.iter().map(ToString::to_string).collect()
208    }
209
210    pub fn is_admin(&self) -> bool {
211        self.has_permission(Permission::Admin)
212    }
213
214    pub fn is_registered_user(&self) -> bool {
215        self.has_permission(Permission::User)
216    }
217
218    pub fn is_anonymous(&self) -> bool {
219        self.has_permission(Permission::Anonymous)
220    }
221
222    pub fn has_audience(&self, aud: &JwtAudience) -> bool {
223        self.aud.contains(aud)
224    }
225
226    pub fn has_role(&self, role: &str) -> bool {
227        self.roles.iter().any(|r| r == role)
228    }
229
230    pub fn roles(&self) -> &[String] {
231        &self.roles
232    }
233}