Skip to main content

this/core/
auth.rs

1//! Authorization system for this-rs
2//!
3//! Provides context-based authorization with multiple auth types:
4//! - User authentication
5//! - Owner-based access
6//! - Service-to-service
7//! - Admin access
8
9use anyhow::Result;
10use async_trait::async_trait;
11use axum::http::Request;
12use uuid::Uuid;
13
14/// Authorization context extracted from a request
15#[derive(Debug, Clone)]
16pub enum AuthContext {
17    /// Authenticated user
18    User {
19        user_id: Uuid,
20        tenant_id: Uuid,
21        roles: Vec<String>,
22    },
23
24    /// Owner of a specific resource
25    Owner {
26        user_id: Uuid,
27        tenant_id: Uuid,
28        resource_id: Uuid,
29        resource_type: String,
30    },
31
32    /// Service-to-service communication
33    Service {
34        service_name: String,
35        tenant_id: Option<Uuid>,
36    },
37
38    /// System administrator
39    Admin { admin_id: Uuid },
40
41    /// No authentication (public access)
42    Anonymous,
43}
44
45impl AuthContext {
46    /// Get tenant_id from context if available
47    pub fn tenant_id(&self) -> Option<Uuid> {
48        match self {
49            AuthContext::User { tenant_id, .. } => Some(*tenant_id),
50            AuthContext::Owner { tenant_id, .. } => Some(*tenant_id),
51            AuthContext::Service { tenant_id, .. } => *tenant_id,
52            AuthContext::Admin { .. } => None,
53            AuthContext::Anonymous => None,
54        }
55    }
56
57    /// Check if context represents an admin
58    pub fn is_admin(&self) -> bool {
59        matches!(self, AuthContext::Admin { .. })
60    }
61
62    /// Check if context represents a service
63    pub fn is_service(&self) -> bool {
64        matches!(self, AuthContext::Service { .. })
65    }
66
67    /// Get user_id if available
68    pub fn user_id(&self) -> Option<Uuid> {
69        match self {
70            AuthContext::User { user_id, .. } => Some(*user_id),
71            AuthContext::Owner { user_id, .. } => Some(*user_id),
72            _ => None,
73        }
74    }
75}
76
77/// Authorization policy for an operation
78#[derive(Debug, Clone)]
79pub enum AuthPolicy {
80    /// Public access (no auth required)
81    Public,
82
83    /// Any authenticated user
84    Authenticated,
85
86    /// Owner of the resource only
87    Owner,
88
89    /// User must have one of these roles
90    HasRole(Vec<String>),
91
92    /// Service-to-service only
93    ServiceOnly,
94
95    /// Admin only
96    AdminOnly,
97
98    /// Combination of policies (AND)
99    And(Vec<AuthPolicy>),
100
101    /// Combination of policies (OR)
102    Or(Vec<AuthPolicy>),
103
104    /// Custom policy function
105    Custom(fn(&AuthContext) -> bool),
106}
107
108impl AuthPolicy {
109    /// Check if auth context satisfies this policy
110    pub fn check(&self, context: &AuthContext) -> bool {
111        match self {
112            AuthPolicy::Public => true,
113
114            AuthPolicy::Authenticated => !matches!(context, AuthContext::Anonymous),
115
116            AuthPolicy::Owner => matches!(context, AuthContext::Owner { .. }),
117
118            AuthPolicy::HasRole(required_roles) => match context {
119                AuthContext::User { roles, .. } => required_roles.iter().any(|r| roles.contains(r)),
120                _ => false,
121            },
122
123            AuthPolicy::ServiceOnly => context.is_service(),
124
125            AuthPolicy::AdminOnly => context.is_admin(),
126
127            AuthPolicy::And(policies) => policies.iter().all(|p| p.check(context)),
128
129            AuthPolicy::Or(policies) => policies.iter().any(|p| p.check(context)),
130
131            AuthPolicy::Custom(f) => f(context),
132        }
133    }
134
135    /// Parse policy from string (for YAML config)
136    pub fn parse_policy(s: &str) -> Self {
137        match s {
138            "public" => AuthPolicy::Public,
139            "authenticated" => AuthPolicy::Authenticated,
140            "owner" => AuthPolicy::Owner,
141            "service_only" => AuthPolicy::ServiceOnly,
142            "admin_only" => AuthPolicy::AdminOnly,
143            s if s.starts_with("role:") => {
144                let role = s.strip_prefix("role:").unwrap().to_string();
145                AuthPolicy::HasRole(vec![role])
146            }
147            s if s.starts_with("owner_or_role:") => {
148                let role = s.strip_prefix("owner_or_role:").unwrap().to_string();
149                AuthPolicy::Or(vec![AuthPolicy::Owner, AuthPolicy::HasRole(vec![role])])
150            }
151            _ => AuthPolicy::Authenticated, // Default
152        }
153    }
154}
155
156/// Trait for auth providers
157#[async_trait]
158pub trait AuthProvider: Send + Sync {
159    /// Extract auth context from HTTP request
160    async fn extract_context<B>(&self, req: &Request<B>) -> Result<AuthContext>;
161
162    /// Check if user is owner of a resource
163    async fn is_owner(
164        &self,
165        user_id: &Uuid,
166        resource_id: &Uuid,
167        resource_type: &str,
168    ) -> Result<bool>;
169
170    /// Check if user has a role
171    async fn has_role(&self, user_id: &Uuid, role: &str) -> Result<bool>;
172}
173
174/// Default no-auth provider (for development)
175pub struct NoAuthProvider;
176
177#[async_trait]
178impl AuthProvider for NoAuthProvider {
179    async fn extract_context<B>(&self, _req: &Request<B>) -> Result<AuthContext> {
180        Ok(AuthContext::Anonymous)
181    }
182
183    async fn is_owner(&self, _: &Uuid, _: &Uuid, _: &str) -> Result<bool> {
184        Ok(true)
185    }
186
187    async fn has_role(&self, _: &Uuid, _: &str) -> Result<bool> {
188        Ok(false)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_policy_check() {
198        let user_context = AuthContext::User {
199            user_id: Uuid::new_v4(),
200            tenant_id: Uuid::new_v4(),
201            roles: vec!["admin".to_string()],
202        };
203
204        assert!(AuthPolicy::Authenticated.check(&user_context));
205        assert!(AuthPolicy::HasRole(vec!["admin".into()]).check(&user_context));
206        assert!(!AuthPolicy::Owner.check(&user_context));
207
208        let anon_context = AuthContext::Anonymous;
209        assert!(AuthPolicy::Public.check(&anon_context));
210        assert!(!AuthPolicy::Authenticated.check(&anon_context));
211    }
212
213    #[test]
214    fn test_policy_from_str() {
215        match AuthPolicy::parse_policy("public") {
216            AuthPolicy::Public => (),
217            _ => panic!("Expected Public"),
218        }
219
220        match AuthPolicy::parse_policy("role:admin") {
221            AuthPolicy::HasRole(roles) => assert_eq!(roles, vec!["admin"]),
222            _ => panic!("Expected HasRole"),
223        }
224    }
225
226    // --- AuthPolicy::check ---
227
228    #[test]
229    fn test_policy_check_and_both_pass() {
230        let ctx = AuthContext::User {
231            user_id: Uuid::new_v4(),
232            tenant_id: Uuid::new_v4(),
233            roles: vec!["editor".to_string()],
234        };
235        let policy = AuthPolicy::And(vec![
236            AuthPolicy::Authenticated,
237            AuthPolicy::HasRole(vec!["editor".into()]),
238        ]);
239        assert!(policy.check(&ctx));
240    }
241
242    #[test]
243    fn test_policy_check_and_one_fails() {
244        let ctx = AuthContext::User {
245            user_id: Uuid::new_v4(),
246            tenant_id: Uuid::new_v4(),
247            roles: vec!["viewer".to_string()],
248        };
249        let policy = AuthPolicy::And(vec![
250            AuthPolicy::Authenticated,
251            AuthPolicy::HasRole(vec!["admin".into()]),
252        ]);
253        assert!(!policy.check(&ctx));
254    }
255
256    #[test]
257    fn test_policy_check_or_one_passes() {
258        let ctx = AuthContext::Admin {
259            admin_id: Uuid::new_v4(),
260        };
261        let policy = AuthPolicy::Or(vec![AuthPolicy::ServiceOnly, AuthPolicy::AdminOnly]);
262        assert!(policy.check(&ctx));
263    }
264
265    #[test]
266    fn test_policy_check_or_both_fail() {
267        let ctx = AuthContext::User {
268            user_id: Uuid::new_v4(),
269            tenant_id: Uuid::new_v4(),
270            roles: vec![],
271        };
272        let policy = AuthPolicy::Or(vec![AuthPolicy::ServiceOnly, AuthPolicy::AdminOnly]);
273        assert!(!policy.check(&ctx));
274    }
275
276    #[test]
277    fn test_policy_check_custom_true() {
278        fn always_true(_ctx: &AuthContext) -> bool {
279            true
280        }
281        let policy = AuthPolicy::Custom(always_true);
282        assert!(policy.check(&AuthContext::Anonymous));
283    }
284
285    #[test]
286    fn test_policy_check_custom_false() {
287        fn always_false(_ctx: &AuthContext) -> bool {
288            false
289        }
290        let policy = AuthPolicy::Custom(always_false);
291        assert!(!policy.check(&AuthContext::Anonymous));
292    }
293
294    #[test]
295    fn test_policy_check_service_only() {
296        let service_ctx = AuthContext::Service {
297            service_name: "billing".to_string(),
298            tenant_id: None,
299        };
300        assert!(AuthPolicy::ServiceOnly.check(&service_ctx));
301
302        let user_ctx = AuthContext::User {
303            user_id: Uuid::new_v4(),
304            tenant_id: Uuid::new_v4(),
305            roles: vec![],
306        };
307        assert!(!AuthPolicy::ServiceOnly.check(&user_ctx));
308    }
309
310    #[test]
311    fn test_policy_check_admin_only() {
312        let admin_ctx = AuthContext::Admin {
313            admin_id: Uuid::new_v4(),
314        };
315        assert!(AuthPolicy::AdminOnly.check(&admin_ctx));
316
317        let user_ctx = AuthContext::User {
318            user_id: Uuid::new_v4(),
319            tenant_id: Uuid::new_v4(),
320            roles: vec![],
321        };
322        assert!(!AuthPolicy::AdminOnly.check(&user_ctx));
323    }
324
325    #[test]
326    fn test_policy_check_owner() {
327        let owner_ctx = AuthContext::Owner {
328            user_id: Uuid::new_v4(),
329            tenant_id: Uuid::new_v4(),
330            resource_id: Uuid::new_v4(),
331            resource_type: "document".to_string(),
332        };
333        assert!(AuthPolicy::Owner.check(&owner_ctx));
334
335        let user_ctx = AuthContext::User {
336            user_id: Uuid::new_v4(),
337            tenant_id: Uuid::new_v4(),
338            roles: vec![],
339        };
340        assert!(!AuthPolicy::Owner.check(&user_ctx));
341    }
342
343    // --- parse_policy ---
344
345    #[test]
346    fn test_parse_policy_authenticated() {
347        assert!(matches!(
348            AuthPolicy::parse_policy("authenticated"),
349            AuthPolicy::Authenticated
350        ));
351    }
352
353    #[test]
354    fn test_parse_policy_service_only() {
355        assert!(matches!(
356            AuthPolicy::parse_policy("service_only"),
357            AuthPolicy::ServiceOnly
358        ));
359    }
360
361    #[test]
362    fn test_parse_policy_admin_only() {
363        assert!(matches!(
364            AuthPolicy::parse_policy("admin_only"),
365            AuthPolicy::AdminOnly
366        ));
367    }
368
369    #[test]
370    fn test_parse_policy_owner() {
371        assert!(matches!(
372            AuthPolicy::parse_policy("owner"),
373            AuthPolicy::Owner
374        ));
375    }
376
377    #[test]
378    fn test_parse_policy_owner_or_role() {
379        match AuthPolicy::parse_policy("owner_or_role:manager") {
380            AuthPolicy::Or(policies) => {
381                assert_eq!(policies.len(), 2);
382                assert!(matches!(policies[0], AuthPolicy::Owner));
383                match &policies[1] {
384                    AuthPolicy::HasRole(roles) => assert_eq!(roles, &vec!["manager".to_string()]),
385                    other => panic!("Expected HasRole, got {:?}", other),
386                }
387            }
388            other => panic!("Expected Or policy, got {:?}", other),
389        }
390    }
391
392    #[test]
393    fn test_parse_policy_unknown_defaults_to_authenticated() {
394        assert!(matches!(
395            AuthPolicy::parse_policy("something_unknown"),
396            AuthPolicy::Authenticated
397        ));
398    }
399
400    // --- AuthContext accessors ---
401
402    #[test]
403    fn test_auth_context_tenant_id_user() {
404        let tid = Uuid::new_v4();
405        let ctx = AuthContext::User {
406            user_id: Uuid::new_v4(),
407            tenant_id: tid,
408            roles: vec![],
409        };
410        assert_eq!(ctx.tenant_id(), Some(tid));
411    }
412
413    #[test]
414    fn test_auth_context_tenant_id_owner() {
415        let tid = Uuid::new_v4();
416        let ctx = AuthContext::Owner {
417            user_id: Uuid::new_v4(),
418            tenant_id: tid,
419            resource_id: Uuid::new_v4(),
420            resource_type: "item".to_string(),
421        };
422        assert_eq!(ctx.tenant_id(), Some(tid));
423    }
424
425    #[test]
426    fn test_auth_context_tenant_id_service_with_tenant() {
427        let tid = Uuid::new_v4();
428        let ctx = AuthContext::Service {
429            service_name: "svc".to_string(),
430            tenant_id: Some(tid),
431        };
432        assert_eq!(ctx.tenant_id(), Some(tid));
433    }
434
435    #[test]
436    fn test_auth_context_tenant_id_service_without_tenant() {
437        let ctx = AuthContext::Service {
438            service_name: "svc".to_string(),
439            tenant_id: None,
440        };
441        assert_eq!(ctx.tenant_id(), None);
442    }
443
444    #[test]
445    fn test_auth_context_tenant_id_admin() {
446        let ctx = AuthContext::Admin {
447            admin_id: Uuid::new_v4(),
448        };
449        assert_eq!(ctx.tenant_id(), None);
450    }
451
452    #[test]
453    fn test_auth_context_tenant_id_anonymous() {
454        assert_eq!(AuthContext::Anonymous.tenant_id(), None);
455    }
456
457    #[test]
458    fn test_auth_context_user_id() {
459        let uid = Uuid::new_v4();
460        let user_ctx = AuthContext::User {
461            user_id: uid,
462            tenant_id: Uuid::new_v4(),
463            roles: vec![],
464        };
465        assert_eq!(user_ctx.user_id(), Some(uid));
466
467        let owner_uid = Uuid::new_v4();
468        let owner_ctx = AuthContext::Owner {
469            user_id: owner_uid,
470            tenant_id: Uuid::new_v4(),
471            resource_id: Uuid::new_v4(),
472            resource_type: "doc".to_string(),
473        };
474        assert_eq!(owner_ctx.user_id(), Some(owner_uid));
475
476        assert_eq!(AuthContext::Anonymous.user_id(), None);
477        assert_eq!(
478            AuthContext::Admin {
479                admin_id: Uuid::new_v4()
480            }
481            .user_id(),
482            None
483        );
484        assert_eq!(
485            AuthContext::Service {
486                service_name: "x".to_string(),
487                tenant_id: None
488            }
489            .user_id(),
490            None
491        );
492    }
493
494    #[test]
495    fn test_auth_context_is_admin() {
496        assert!(
497            AuthContext::Admin {
498                admin_id: Uuid::new_v4()
499            }
500            .is_admin()
501        );
502        assert!(!AuthContext::Anonymous.is_admin());
503    }
504
505    #[test]
506    fn test_auth_context_is_service() {
507        assert!(
508            AuthContext::Service {
509                service_name: "svc".to_string(),
510                tenant_id: None
511            }
512            .is_service()
513        );
514        assert!(!AuthContext::Anonymous.is_service());
515    }
516
517    // --- NoAuthProvider ---
518
519    #[tokio::test]
520    async fn test_no_auth_provider_extract_context() {
521        let provider = NoAuthProvider;
522        let req = Request::builder()
523            .body(())
524            .expect("failed to build request");
525        let ctx = provider
526            .extract_context(&req)
527            .await
528            .expect("extract_context should succeed");
529        assert!(matches!(ctx, AuthContext::Anonymous));
530    }
531
532    #[tokio::test]
533    async fn test_no_auth_provider_is_owner() {
534        let provider = NoAuthProvider;
535        let result = provider
536            .is_owner(&Uuid::new_v4(), &Uuid::new_v4(), "resource")
537            .await
538            .expect("is_owner should succeed");
539        assert!(result);
540    }
541
542    #[tokio::test]
543    async fn test_no_auth_provider_has_role() {
544        let provider = NoAuthProvider;
545        let result = provider
546            .has_role(&Uuid::new_v4(), "admin")
547            .await
548            .expect("has_role should succeed");
549        assert!(!result);
550    }
551}