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//!
8//! The `purpose` claim is the caller purpose bound by the token's issuer
9//! (in-process minting only), forwarded to the credential broker as
10//! `x-systemprompt-caller`.
11//!
12//! Copyright (c) systemprompt.io — Business Source License 1.1.
13//! See <https://systemprompt.io> for licensing details.
14
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17use std::str::FromStr;
18use systemprompt_identifiers::{ClientId, PluginId, SessionId, UserId};
19
20use super::{
21    JwtAudience, Permission, RateLimitTier, TokenType, UserType, parse_permissions,
22    permissions_to_string,
23};
24use systemprompt_identifiers::Actor;
25
26/// RFC 8693 §4.1 actor (`act`) claim.
27///
28/// Captures the immediate actor (`iss` + `sub`) that requested a token
29/// exchange and a recursive `act` link to its own delegating actor. The
30/// chain is walked outermost-first by [`ActClaim::flatten_to_chain`]:
31/// the outermost `JwtClaims.act` is the most recent delegate, and each
32/// nested `act` is the actor that delegated to it.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ActClaim {
35    pub iss: String,
36    pub sub: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub act: Box<Option<Self>>,
39}
40
41pub const MAX_ACT_CHAIN_DEPTH: usize = 16;
42
43impl ActClaim {
44    #[must_use]
45    pub fn flatten_to_chain(&self) -> Vec<Actor> {
46        let mut chain = Vec::new();
47        let mut cursor = Some(self);
48        while let Some(node) = cursor {
49            if chain.len() >= MAX_ACT_CHAIN_DEPTH {
50                break;
51            }
52            chain.push(Actor::user(UserId::new(node.sub.clone())));
53            cursor = node.act.as_ref().as_ref();
54        }
55        chain
56    }
57
58    #[must_use]
59    pub fn depth(&self) -> usize {
60        let mut depth = 0usize;
61        let mut cursor = Some(self);
62        while let Some(node) = cursor {
63            depth += 1;
64            if depth > MAX_ACT_CHAIN_DEPTH {
65                return depth;
66            }
67            cursor = node.act.as_ref().as_ref();
68        }
69        depth
70    }
71}
72
73/// Self-issued JWT claim shape: the RFC 7519 registered claims and the
74/// platform's custom claims, flattened into one JSON object on the wire.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct JwtClaims {
77    #[serde(flatten)]
78    pub registered: RegisteredClaims,
79    #[serde(flatten)]
80    pub custom: CustomClaims,
81}
82
83/// RFC 7519 §4.1 registered claims.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RegisteredClaims {
86    pub sub: String,
87    pub iat: i64,
88    pub exp: i64,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub nbf: Option<i64>,
91    pub iss: String,
92    #[serde(
93        serialize_with = "serialize_audiences",
94        deserialize_with = "deserialize_audiences"
95    )]
96    pub aud: Vec<JwtAudience>,
97    pub jti: String,
98}
99
100/// Platform claims grouped by who consumes them downstream.
101///
102/// The platform runs authorization in three layers (see
103/// `internal/guides/authz.md`); the scope, roles and attributes fields are the
104/// transport for one of those layers each. Do not delete a field without first
105/// removing its readers.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct CustomClaims {
108    #[serde(
109        serialize_with = "serialize_scope",
110        deserialize_with = "deserialize_scope"
111    )]
112    pub scope: Vec<Permission>,
113
114    pub username: String,
115    pub email: String,
116    pub user_type: UserType,
117
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub roles: Vec<String>,
120
121    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
122    // JSON: ABAC attribute values are declared per deployment in the authz policy YAML.
123    pub attributes: BTreeMap<String, serde_json::Value>,
124
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub client_id: Option<ClientId>,
127    pub token_type: TokenType,
128    pub auth_time: i64,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub session_id: Option<SessionId>,
131
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub rate_limit_tier: Option<RateLimitTier>,
134
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub plugin_id: Option<PluginId>,
137
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub act: Option<ActClaim>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub purpose: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub interaction: Option<TokenInteraction>,
144    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
145    pub browser_session: bool,
146}
147
148/// The `interaction` claim: how the caller of a token is attended.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum TokenInteraction {
152    Interactive,
153    NonInteractive,
154}
155
156fn serialize_audiences<S>(auds: &[JwtAudience], s: S) -> Result<S::Ok, S::Error>
157where
158    S: serde::Serializer,
159{
160    use serde::ser::SerializeSeq;
161    let mut seq = s.serialize_seq(Some(auds.len()))?;
162    for aud in auds {
163        seq.serialize_element(aud.as_str())?;
164    }
165    seq.end()
166}
167
168fn deserialize_audiences<'de, D>(d: D) -> Result<Vec<JwtAudience>, D::Error>
169where
170    D: serde::Deserializer<'de>,
171{
172    let strings: Vec<String> = Vec::deserialize(d)?;
173    strings
174        .iter()
175        .map(|s| JwtAudience::from_str(s).map_err(serde::de::Error::custom))
176        .collect()
177}
178
179fn serialize_scope<S>(permissions: &[Permission], s: S) -> Result<S::Ok, S::Error>
180where
181    S: serde::Serializer,
182{
183    s.serialize_str(&permissions_to_string(permissions))
184}
185
186fn deserialize_scope<'de, D>(d: D) -> Result<Vec<Permission>, D::Error>
187where
188    D: serde::Deserializer<'de>,
189{
190    let scope_string: String = String::deserialize(d)?;
191    parse_permissions(&scope_string).map_err(serde::de::Error::custom)
192}
193
194impl JwtClaims {
195    pub fn has_permission(&self, permission: Permission) -> bool {
196        self.custom.scope.contains(&permission)
197    }
198
199    pub fn permissions(&self) -> &[Permission] {
200        &self.custom.scope
201    }
202
203    pub fn get_permissions(&self) -> Vec<Permission> {
204        self.custom.scope.clone()
205    }
206
207    pub fn get_scopes(&self) -> Vec<String> {
208        self.custom.scope.iter().map(ToString::to_string).collect()
209    }
210
211    pub fn is_admin(&self) -> bool {
212        self.has_permission(Permission::Admin)
213    }
214
215    pub fn is_registered_user(&self) -> bool {
216        self.has_permission(Permission::User)
217    }
218
219    pub fn is_anonymous(&self) -> bool {
220        self.has_permission(Permission::Anonymous)
221    }
222
223    pub fn has_audience(&self, aud: &JwtAudience) -> bool {
224        self.registered.aud.contains(aud)
225    }
226
227    pub fn has_role(&self, role: &str) -> bool {
228        self.custom.roles.iter().any(|r| r == role)
229    }
230
231    pub fn roles(&self) -> &[String] {
232        &self.custom.roles
233    }
234}