Skip to main content

modkit_auth/
claims.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5/// Represents a permission from JWT claims.
6/// Serializes to format: `"{tenant_id}:{resource_pattern}:{resource_id}:{action}"`
7/// where `tenant_id` and `resource_id` are "*" if None.
8/// This is compatible with `modkit_security::Permission`.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct Permission {
11    /// Optional tenant ID the permission applies to
12    /// e.g., a specific tenant UUID
13    tenant_id: Option<Uuid>,
14
15    /// A pattern that can include wildcards to match multiple resources
16    /// examples:
17    ///   - `gts.x.core.events.topic.v1~vendor.*`
18    ///   - `gts.x.mod.v1~x.file_parser.v1`
19    resource_pattern: String,
20
21    /// Optional specific resource ID the permission applies to
22    /// e.g., a specific topic or file UUID
23    resource_id: Option<Uuid>,
24
25    /// The action that can be performed on the resource
26    /// e.g., "publish", "subscribe", "edit"
27    action: String,
28}
29
30impl Permission {
31    #[must_use]
32    pub fn builder() -> PermissionBuilder {
33        PermissionBuilder::default()
34    }
35
36    #[must_use]
37    pub fn tenant_id(&self) -> Option<Uuid> {
38        self.tenant_id
39    }
40
41    #[must_use]
42    pub fn resource_pattern(&self) -> &str {
43        &self.resource_pattern
44    }
45
46    #[must_use]
47    pub fn resource_id(&self) -> Option<Uuid> {
48        self.resource_id
49    }
50
51    #[must_use]
52    pub fn action(&self) -> &str {
53        &self.action
54    }
55}
56
57impl serde::Serialize for Permission {
58    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
59    where
60        S: serde::Serializer,
61    {
62        let tenant_id_str = self
63            .tenant_id
64            .map_or_else(|| "*".to_owned(), |id| id.to_string());
65        let resource_id_str = self
66            .resource_id
67            .map_or_else(|| "*".to_owned(), |id| id.to_string());
68        let s = format!(
69            "{}:{}:{}:{}",
70            tenant_id_str, self.resource_pattern, resource_id_str, self.action
71        );
72        serializer.serialize_str(&s)
73    }
74}
75
76impl<'de> serde::Deserialize<'de> for Permission {
77    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
78    where
79        D: serde::Deserializer<'de>,
80    {
81        let s = String::deserialize(deserializer)?;
82        let parts: Vec<&str> = s.splitn(4, ':').collect();
83
84        if parts.len() != 4 {
85            return Err(serde::de::Error::custom(format!(
86                "Expected format 'tenant_id:resource_pattern:resource_id:action', got: {s}"
87            )));
88        }
89
90        let tenant_id = if parts[0] == "*" {
91            None
92        } else {
93            Some(Uuid::parse_str(parts[0]).map_err(serde::de::Error::custom)?)
94        };
95
96        let resource_id = if parts[2] == "*" {
97            None
98        } else {
99            Some(Uuid::parse_str(parts[2]).map_err(serde::de::Error::custom)?)
100        };
101
102        let action = parts[3];
103        if !action
104            .chars()
105            .all(|c| c.is_ascii_alphanumeric() || c == '_')
106        {
107            return Err(serde::de::Error::custom(format!(
108                "Action must contain only alphanumeric characters and underscores, got: {action}"
109            )));
110        }
111
112        Ok(Permission {
113            tenant_id,
114            resource_pattern: parts[1].to_owned(),
115            resource_id,
116            action: action.to_owned(),
117        })
118    }
119}
120
121/// Builder for creating `Permission` instances
122#[derive(Default)]
123pub struct PermissionBuilder {
124    tenant_id: Option<Uuid>,
125    resource_pattern: Option<String>,
126    resource_id: Option<Uuid>,
127    action: Option<String>,
128}
129
130impl PermissionBuilder {
131    #[must_use]
132    pub fn tenant_id(mut self, tenant_id: Uuid) -> Self {
133        self.tenant_id = Some(tenant_id);
134        self
135    }
136
137    #[must_use]
138    pub fn resource_pattern(mut self, resource_pattern: &str) -> Self {
139        self.resource_pattern = Some(resource_pattern.to_owned());
140        self
141    }
142
143    #[must_use]
144    pub fn resource_id(mut self, resource_id: Uuid) -> Self {
145        self.resource_id = Some(resource_id);
146        self
147    }
148
149    #[must_use]
150    pub fn action(mut self, action: &str) -> Self {
151        self.action = Some(action.to_owned());
152        self
153    }
154
155    /// Build the permission
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if:
160    /// - `resource_pattern` is not set
161    /// - `action` is not set
162    /// - `action` contains characters other than alphanumeric, underscore, or wildcard (*)
163    pub fn build(self) -> Result<Permission, PermissionBuildError> {
164        let resource_pattern = self
165            .resource_pattern
166            .ok_or(PermissionBuildError::MissingResourcePattern)?;
167
168        let action = self.action.ok_or(PermissionBuildError::MissingAction)?;
169
170        // Validate action contains only alphanumeric characters, underscores, or wildcard
171        if !action
172            .chars()
173            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '*')
174        {
175            return Err(PermissionBuildError::InvalidAction(action));
176        }
177
178        Ok(Permission {
179            tenant_id: self.tenant_id,
180            resource_pattern,
181            resource_id: self.resource_id,
182            action,
183        })
184    }
185}
186
187/// Error type for `PermissionBuilder`
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum PermissionBuildError {
190    MissingResourcePattern,
191    MissingAction,
192    InvalidAction(String),
193}
194
195impl std::fmt::Display for PermissionBuildError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::MissingResourcePattern => write!(f, "resource_pattern is required"),
199            Self::MissingAction => write!(f, "action is required"),
200            Self::InvalidAction(action) => write!(
201                f,
202                "Action must contain only alphanumeric characters and underscores, got: {action}"
203            ),
204        }
205    }
206}
207
208impl std::error::Error for PermissionBuildError {}
209
210/// JWT claims representation that's provider-agnostic
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Claims {
213    /// Issuer - the `iss` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1>
214    pub issuer: String,
215
216    /// Subject - the `sub` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2>
217    pub subject: Uuid,
218
219    /// Audiences - the `aud` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3>
220    pub audiences: Vec<String>,
221
222    /// Expiration time - the `exp` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4>
223    #[serde(
224        skip_serializing_if = "Option::is_none",
225        with = "time::serde::rfc3339::option"
226    )]
227    pub expires_at: Option<OffsetDateTime>,
228
229    /// Not before time - the `nbf` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5>
230    #[serde(
231        skip_serializing_if = "Option::is_none",
232        with = "time::serde::rfc3339::option"
233    )]
234    pub not_before: Option<OffsetDateTime>,
235
236    /// Issued At - the `iat` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6>
237    #[serde(
238        skip_serializing_if = "Option::is_none",
239        with = "time::serde::rfc3339::option"
240    )]
241    pub issued_at: Option<OffsetDateTime>,
242
243    /// JWT ID - the `jti` claim. See <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7>
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub jwt_id: Option<String>,
246
247    /* modkit - specific claims */
248    /// Tenant ID - the `tenant_id` claim
249    pub tenant_id: Uuid,
250
251    /// User roles
252    #[serde(default)]
253    pub permissions: Vec<Permission>,
254
255    /// Additional provider-specific claims
256    #[serde(flatten)]
257    pub extras: serde_json::Map<String, serde_json::Value>,
258}
259
260impl Claims {
261    /// Check if the token has expired
262    #[must_use]
263    pub fn is_expired(&self) -> bool {
264        if let Some(exp) = self.expires_at {
265            OffsetDateTime::now_utc() >= exp
266        } else {
267            false
268        }
269    }
270
271    /// Check if the token is valid yet (nbf check)
272    #[must_use]
273    pub fn is_valid_yet(&self) -> bool {
274        if let Some(nbf) = self.not_before {
275            OffsetDateTime::now_utc() >= nbf
276        } else {
277            true
278        }
279    }
280}
281
282#[cfg(test)]
283#[cfg_attr(coverage_nightly, coverage(off))]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_expiration_check() {
289        let mut claims = Claims {
290            issuer: "test".to_owned(),
291            subject: Uuid::new_v4(),
292            audiences: vec!["api".to_owned()],
293            expires_at: Some(OffsetDateTime::now_utc() + time::Duration::hours(1)),
294            not_before: None,
295            issued_at: None,
296            jwt_id: None,
297            tenant_id: Uuid::new_v4(),
298            permissions: vec![],
299            extras: serde_json::Map::new(),
300        };
301
302        assert!(!claims.is_expired());
303
304        claims.expires_at = Some(OffsetDateTime::now_utc() - time::Duration::hours(1));
305        assert!(claims.is_expired());
306    }
307
308    #[test]
309    fn test_nbf_check() {
310        let mut claims = Claims {
311            subject: Uuid::new_v4(),
312            issuer: "test".to_owned(),
313            audiences: vec!["api".to_owned()],
314            expires_at: None,
315            not_before: Some(OffsetDateTime::now_utc() - time::Duration::hours(1)),
316            issued_at: None,
317            jwt_id: None,
318            tenant_id: Uuid::new_v4(),
319            permissions: vec![],
320            extras: serde_json::Map::new(),
321        };
322
323        assert!(claims.is_valid_yet());
324
325        claims.not_before = Some(OffsetDateTime::now_utc() + time::Duration::hours(1));
326        assert!(!claims.is_valid_yet());
327    }
328}