oxify_authz/
oauth2.rs

1//! # OAuth2 Scopes → ReBAC Mapping
2//!
3//! Bridge OAuth2/OIDC authorization with ReBAC authorization engine.
4//! Automatically create relation tuples from OAuth2 scopes and JWT claims.
5//!
6//! ## Features
7//!
8//! - **Scope Mapping**: Convert OAuth2 scopes to ReBAC relation tuples
9//! - **JWT Claim Extraction**: Extract user identity and attributes from JWT tokens
10//! - **Role Mapping**: Map OAuth2 roles to ReBAC relationships
11//! - **Organization Mapping**: Map organizational claims to resource hierarchies
12//!
13//! ## Example
14//!
15//! ```rust
16//! use oxify_authz::oauth2::{OAuth2Mapper, ScopeMapping, TokenClaims};
17//! use oxify_authz::HybridRebacEngine;
18//! use std::collections::HashMap;
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let engine = HybridRebacEngine::for_testing().await?;
22//! let mapper = OAuth2Mapper::new();
23//!
24//! // Define scope mappings
25//! let mut mappings = vec![
26//!     ScopeMapping::new("read:documents", "document", "*", "viewer"),
27//!     ScopeMapping::new("write:documents", "document", "*", "editor"),
28//! ];
29//!
30//! // Parse JWT claims
31//! let claims = TokenClaims {
32//!     sub: "user:alice".to_string(),
33//!     scope: Some("read:documents write:documents".to_string()),
34//!     roles: Some(vec!["admin".to_string()]),
35//!     groups: Some(vec!["engineering".to_string()]),
36//!     ..Default::default()
37//! };
38//!
39//! // Generate tuples from claims
40//! let tuples = mapper.claims_to_tuples(&claims, &mappings)?;
41//!
42//! // Write tuples to engine
43//! for tuple in tuples {
44//!     engine.write_tuple(tuple).await?;
45//! }
46//! # Ok(())
47//! # }
48//! ```
49
50use crate::{RelationTuple, Subject};
51use serde::{Deserialize, Serialize};
52use std::collections::HashMap;
53
54/// OAuth2/OIDC token claims
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct TokenClaims {
57    /// Subject (user ID)
58    pub sub: String,
59
60    /// Issuer
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub iss: Option<String>,
63
64    /// Audience
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub aud: Option<Vec<String>>,
67
68    /// Expiration time (Unix timestamp)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub exp: Option<i64>,
71
72    /// Issued at time (Unix timestamp)
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub iat: Option<i64>,
75
76    /// Scopes (space-separated string)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub scope: Option<String>,
79
80    /// Roles (custom claim)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub roles: Option<Vec<String>>,
83
84    /// Groups (custom claim)
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub groups: Option<Vec<String>>,
87
88    /// Organization ID (custom claim)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub org_id: Option<String>,
91
92    /// Tenant ID (custom claim)
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub tenant_id: Option<String>,
95
96    /// Custom claims
97    #[serde(flatten)]
98    pub custom: HashMap<String, serde_json::Value>,
99}
100
101/// Mapping from OAuth2 scope to ReBAC relation tuple
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ScopeMapping {
104    /// OAuth2 scope (e.g., "read:documents")
105    pub scope: String,
106
107    /// Target namespace in ReBAC
108    pub namespace: String,
109
110    /// Target object ID (supports wildcards and templates)
111    /// - "*" for all objects in namespace
112    /// - "{org_id}" for templated values from claims
113    pub object_id: String,
114
115    /// Relation to grant (e.g., "viewer", "editor")
116    pub relation: String,
117}
118
119impl ScopeMapping {
120    /// Create a new scope mapping
121    pub fn new(
122        scope: impl Into<String>,
123        namespace: impl Into<String>,
124        object_id: impl Into<String>,
125        relation: impl Into<String>,
126    ) -> Self {
127        Self {
128            scope: scope.into(),
129            namespace: namespace.into(),
130            object_id: object_id.into(),
131            relation: relation.into(),
132        }
133    }
134
135    /// Resolve object ID template with claims
136    pub fn resolve_object_id(&self, claims: &TokenClaims) -> Vec<String> {
137        if self.object_id == "*" {
138            // Wildcard - would need to query all objects
139            // For now, return empty to avoid granting universal access
140            vec![]
141        } else if self.object_id.contains('{') {
142            // Template substitution
143            let mut result = self.object_id.clone();
144
145            // Replace {org_id}
146            if let Some(ref org_id) = claims.org_id {
147                result = result.replace("{org_id}", org_id);
148            }
149
150            // Replace {tenant_id}
151            if let Some(ref tenant_id) = claims.tenant_id {
152                result = result.replace("{tenant_id}", tenant_id);
153            }
154
155            // Replace {sub}
156            result = result.replace("{sub}", &claims.sub);
157
158            vec![result]
159        } else {
160            // Literal object ID
161            vec![self.object_id.clone()]
162        }
163    }
164}
165
166/// Role to ReBAC mapping
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RoleMapping {
169    /// Role name (e.g., "admin", "manager")
170    pub role: String,
171
172    /// Resource namespace
173    pub namespace: String,
174
175    /// Object ID pattern
176    pub object_id: String,
177
178    /// Relation to grant
179    pub relation: String,
180}
181
182/// OAuth2 to ReBAC mapper
183pub struct OAuth2Mapper {
184    /// Role mappings
185    role_mappings: Vec<RoleMapping>,
186}
187
188impl OAuth2Mapper {
189    /// Create a new OAuth2 mapper
190    pub fn new() -> Self {
191        Self {
192            role_mappings: Vec::new(),
193        }
194    }
195
196    /// Add a role mapping
197    pub fn add_role_mapping(&mut self, mapping: RoleMapping) {
198        self.role_mappings.push(mapping);
199    }
200
201    /// Convert token claims to ReBAC tuples based on scope mappings
202    pub fn claims_to_tuples(
203        &self,
204        claims: &TokenClaims,
205        scope_mappings: &[ScopeMapping],
206    ) -> Result<Vec<RelationTuple>, String> {
207        let mut tuples = Vec::new();
208
209        // Parse scopes from claims
210        let scopes: Vec<&str> = claims
211            .scope
212            .as_ref()
213            .map(|s| s.split_whitespace().collect())
214            .unwrap_or_default();
215
216        // Extract subject from claims
217        let subject = if claims.sub.starts_with("user:") {
218            Subject::User(claims.sub.clone())
219        } else {
220            Subject::User(format!("user:{}", claims.sub))
221        };
222
223        // Map scopes to tuples
224        for scope in scopes {
225            for mapping in scope_mappings {
226                if mapping.scope == scope {
227                    let object_ids = mapping.resolve_object_id(claims);
228
229                    for object_id in object_ids {
230                        tuples.push(RelationTuple::new(
231                            mapping.namespace.clone(),
232                            mapping.relation.clone(),
233                            object_id,
234                            subject.clone(),
235                        ));
236                    }
237                }
238            }
239        }
240
241        // Map roles to tuples
242        if let Some(ref roles) = claims.roles {
243            for role in roles {
244                for mapping in &self.role_mappings {
245                    if &mapping.role == role {
246                        let object_id = if mapping.object_id.contains('{') {
247                            // Template resolution
248                            let mut resolved = mapping.object_id.clone();
249                            if let Some(ref org_id) = claims.org_id {
250                                resolved = resolved.replace("{org_id}", org_id);
251                            }
252                            resolved
253                        } else {
254                            mapping.object_id.clone()
255                        };
256
257                        tuples.push(RelationTuple::new(
258                            mapping.namespace.clone(),
259                            mapping.relation.clone(),
260                            object_id,
261                            subject.clone(),
262                        ));
263                    }
264                }
265            }
266        }
267
268        // Map groups to tuples (groups as UserSets)
269        if let Some(ref groups) = claims.groups {
270            for group in groups {
271                // Create membership tuples: user is member of group
272                tuples.push(RelationTuple::new(
273                    "group",
274                    "member",
275                    group.clone(),
276                    subject.clone(),
277                ));
278            }
279        }
280
281        Ok(tuples)
282    }
283
284    /// Extract organization membership from claims
285    pub fn extract_org_membership(&self, claims: &TokenClaims) -> Option<RelationTuple> {
286        claims.org_id.as_ref().map(|org_id| {
287            let subject = if claims.sub.starts_with("user:") {
288                Subject::User(claims.sub.clone())
289            } else {
290                Subject::User(format!("user:{}", claims.sub))
291            };
292
293            RelationTuple::new("organization", "member", org_id.clone(), subject)
294        })
295    }
296
297    /// Generate tuples from JWT claims (convenience method)
298    pub fn jwt_to_tuples(
299        &self,
300        jwt_payload: &str,
301        scope_mappings: &[ScopeMapping],
302    ) -> Result<Vec<RelationTuple>, String> {
303        let claims: TokenClaims = serde_json::from_str(jwt_payload)
304            .map_err(|e| format!("Failed to parse JWT claims: {}", e))?;
305
306        self.claims_to_tuples(&claims, scope_mappings)
307    }
308}
309
310impl Default for OAuth2Mapper {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_scope_mapping() {
322        let mapping = ScopeMapping::new("read:documents", "document", "doc123", "viewer");
323
324        assert_eq!(mapping.scope, "read:documents");
325        assert_eq!(mapping.namespace, "document");
326        assert_eq!(mapping.object_id, "doc123");
327        assert_eq!(mapping.relation, "viewer");
328    }
329
330    #[test]
331    fn test_template_resolution() {
332        let mapping = ScopeMapping::new("read:org_documents", "document", "org_{org_id}", "viewer");
333
334        let claims = TokenClaims {
335            sub: "alice".to_string(),
336            org_id: Some("acme".to_string()),
337            ..Default::default()
338        };
339
340        let resolved = mapping.resolve_object_id(&claims);
341        assert_eq!(resolved, vec!["org_acme"]);
342    }
343
344    #[test]
345    fn test_claims_to_tuples() {
346        let mapper = OAuth2Mapper::new();
347
348        let claims = TokenClaims {
349            sub: "alice".to_string(),
350            scope: Some("read:documents write:documents".to_string()),
351            ..Default::default()
352        };
353
354        let mappings = vec![
355            ScopeMapping::new("read:documents", "document", "doc123", "viewer"),
356            ScopeMapping::new("write:documents", "document", "doc123", "editor"),
357        ];
358
359        let tuples = mapper.claims_to_tuples(&claims, &mappings).unwrap();
360
361        assert_eq!(tuples.len(), 2);
362        assert_eq!(tuples[0].namespace, "document");
363        assert_eq!(tuples[0].relation, "viewer");
364        assert_eq!(tuples[1].relation, "editor");
365    }
366
367    #[test]
368    fn test_role_mapping() {
369        let mut mapper = OAuth2Mapper::new();
370
371        mapper.add_role_mapping(RoleMapping {
372            role: "admin".to_string(),
373            namespace: "organization".to_string(),
374            object_id: "{org_id}".to_string(),
375            relation: "owner".to_string(),
376        });
377
378        let claims = TokenClaims {
379            sub: "alice".to_string(),
380            roles: Some(vec!["admin".to_string()]),
381            org_id: Some("acme".to_string()),
382            ..Default::default()
383        };
384
385        let tuples = mapper.claims_to_tuples(&claims, &[]).unwrap();
386
387        assert_eq!(tuples.len(), 1);
388        assert_eq!(tuples[0].namespace, "organization");
389        assert_eq!(tuples[0].object_id, "acme");
390        assert_eq!(tuples[0].relation, "owner");
391    }
392
393    #[test]
394    fn test_group_mapping() {
395        let mapper = OAuth2Mapper::new();
396
397        let claims = TokenClaims {
398            sub: "alice".to_string(),
399            groups: Some(vec!["engineering".to_string(), "admins".to_string()]),
400            ..Default::default()
401        };
402
403        let tuples = mapper.claims_to_tuples(&claims, &[]).unwrap();
404
405        assert_eq!(tuples.len(), 2);
406        assert_eq!(tuples[0].namespace, "group");
407        assert_eq!(tuples[0].relation, "member");
408        assert_eq!(tuples[0].object_id, "engineering");
409    }
410
411    #[test]
412    fn test_org_membership() {
413        let mapper = OAuth2Mapper::new();
414
415        let claims = TokenClaims {
416            sub: "alice".to_string(),
417            org_id: Some("acme".to_string()),
418            ..Default::default()
419        };
420
421        let tuple = mapper.extract_org_membership(&claims).unwrap();
422
423        assert_eq!(tuple.namespace, "organization");
424        assert_eq!(tuple.relation, "member");
425        assert_eq!(tuple.object_id, "acme");
426    }
427}