1use crate::{Error, Result};
34use std::collections::HashSet;
35
36#[cfg(feature = "group-scope")]
37use std::collections::HashMap;
38
39#[cfg(feature = "group-scope")]
40use crate::models::group::GroupRole;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Permission {
45 Read,
47 Write,
49 Admin,
51}
52
53impl Permission {
54 #[must_use]
56 pub const fn as_str(&self) -> &'static str {
57 match self {
58 Self::Read => "read",
59 Self::Write => "write",
60 Self::Admin => "admin",
61 }
62 }
63
64 #[must_use]
66 pub fn parse(s: &str) -> Option<Self> {
67 match s.to_lowercase().as_str() {
68 "read" => Some(Self::Read),
69 "write" => Some(Self::Write),
70 "admin" => Some(Self::Admin),
71 _ => None,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
81pub struct AuthContext {
82 subject: Option<String>,
84 scopes: HashSet<String>,
86 is_local: bool,
88 org_name: Option<String>,
90 org_role: Option<String>,
92 #[cfg(feature = "group-scope")]
94 group_roles: HashMap<String, String>,
95}
96
97impl Default for AuthContext {
98 fn default() -> Self {
102 Self::local()
103 }
104}
105
106impl AuthContext {
107 #[must_use]
111 pub fn local() -> Self {
112 Self {
113 subject: None,
114 scopes: HashSet::new(),
115 is_local: true,
116 org_name: None,
117 org_role: None,
118 #[cfg(feature = "group-scope")]
119 group_roles: HashMap::new(),
120 }
121 }
122
123 #[must_use]
129 pub fn from_scopes(scopes: Vec<String>) -> Self {
130 Self {
131 subject: None,
132 scopes: scopes.into_iter().collect(),
133 is_local: false,
134 org_name: None,
135 org_role: None,
136 #[cfg(feature = "group-scope")]
137 group_roles: HashMap::new(),
138 }
139 }
140
141 #[must_use]
143 pub fn builder() -> AuthContextBuilder {
144 AuthContextBuilder::default()
145 }
146
147 #[must_use]
149 pub fn with_subject(mut self, subject: impl Into<String>) -> Self {
150 self.subject = Some(subject.into());
151 self
152 }
153
154 #[must_use]
156 pub fn subject(&self) -> Option<&str> {
157 self.subject.as_deref()
158 }
159
160 #[must_use]
162 pub const fn is_local(&self) -> bool {
163 self.is_local
164 }
165
166 #[must_use]
168 pub fn org_name(&self) -> Option<&str> {
169 self.org_name.as_deref()
170 }
171
172 #[must_use]
174 pub fn org_role(&self) -> Option<&str> {
175 self.org_role.as_deref()
176 }
177
178 #[must_use]
180 pub fn has_org_access(&self) -> bool {
181 if self.is_local {
183 return true;
184 }
185 self.scopes.contains("org:read")
187 || self.scopes.contains("org:write")
188 || self.scopes.contains("*")
189 }
190
191 #[must_use]
193 pub fn has_scope(&self, scope: &str) -> bool {
194 if self.is_local {
196 return true;
197 }
198 if self.scopes.contains("*") {
200 return true;
201 }
202 self.scopes.contains(scope)
203 }
204
205 #[must_use]
207 pub fn has_permission(&self, permission: Permission) -> bool {
208 self.has_scope(permission.as_str())
209 }
210
211 #[must_use]
213 pub fn has_any_permission(&self, permissions: &[Permission]) -> bool {
214 permissions.iter().any(|p| self.has_permission(*p))
215 }
216
217 pub fn require(&self, permission: Permission) -> Result<()> {
223 if self.has_permission(permission) {
224 tracing::debug!(
225 subject = ?self.subject,
226 permission = permission.as_str(),
227 is_local = self.is_local,
228 "Authorization granted"
229 );
230 Ok(())
231 } else {
232 tracing::warn!(
233 subject = ?self.subject,
234 permission = permission.as_str(),
235 scopes = ?self.scopes,
236 "Authorization denied"
237 );
238 Err(Error::Unauthorized(format!(
239 "Permission '{}' required",
240 permission.as_str()
241 )))
242 }
243 }
244
245 pub fn require_any(&self, permissions: &[Permission]) -> Result<()> {
251 if self.has_any_permission(permissions) {
252 Ok(())
253 } else {
254 let required: Vec<_> = permissions.iter().map(Permission::as_str).collect();
255 Err(Error::Unauthorized(format!(
256 "One of permissions {required:?} required"
257 )))
258 }
259 }
260
261 #[cfg(feature = "group-scope")]
272 #[must_use]
273 pub fn get_group_role(&self, group_id: &str) -> Option<GroupRole> {
274 if self.is_local {
276 return Some(GroupRole::Admin);
277 }
278 if self.scopes.contains("*") {
280 return Some(GroupRole::Admin);
281 }
282 self.group_roles
284 .get(group_id)
285 .and_then(|role| GroupRole::parse(role))
286 }
287
288 #[cfg(feature = "group-scope")]
299 #[must_use]
300 pub fn has_group_permission(&self, group_id: &str, min_role: GroupRole) -> bool {
301 let Some(role) = self.get_group_role(group_id) else {
302 return false;
303 };
304 match min_role {
305 GroupRole::Admin => role.can_manage(),
306 GroupRole::Write => role.can_write(),
307 GroupRole::Read => role.can_read(),
308 }
309 }
310
311 #[cfg(feature = "group-scope")]
322 pub fn require_group_role(&self, group_id: &str, min_role: GroupRole) -> Result<()> {
323 if self.has_group_permission(group_id, min_role) {
324 tracing::debug!(
325 subject = ?self.subject,
326 group_id = group_id,
327 required_role = min_role.as_str(),
328 is_local = self.is_local,
329 "Group authorization granted"
330 );
331 Ok(())
332 } else {
333 tracing::warn!(
334 subject = ?self.subject,
335 group_id = group_id,
336 required_role = min_role.as_str(),
337 actual_role = ?self.get_group_role(group_id),
338 "Group authorization denied"
339 );
340 Err(Error::Unauthorized(format!(
341 "Role '{}' required in group '{group_id}'",
342 min_role.as_str()
343 )))
344 }
345 }
346}
347
348#[derive(Debug, Default)]
350pub struct AuthContextBuilder {
351 subject: Option<String>,
352 scopes: HashSet<String>,
353 is_local: bool,
354 org_name: Option<String>,
355 org_role: Option<String>,
356 #[cfg(feature = "group-scope")]
357 group_roles: HashMap<String, String>,
358}
359
360impl AuthContextBuilder {
361 #[must_use]
363 pub fn subject(mut self, subject: impl Into<String>) -> Self {
364 self.subject = Some(subject.into());
365 self
366 }
367
368 #[must_use]
370 pub fn scope(mut self, scope: impl Into<String>) -> Self {
371 self.scopes.insert(scope.into());
372 self
373 }
374
375 #[must_use]
377 pub fn scopes(mut self, scopes: impl IntoIterator<Item = impl Into<String>>) -> Self {
378 for scope in scopes {
379 self.scopes.insert(scope.into());
380 }
381 self
382 }
383
384 #[must_use]
386 pub const fn local(mut self) -> Self {
387 self.is_local = true;
388 self
389 }
390
391 #[must_use]
393 pub fn org_name(mut self, name: impl Into<String>) -> Self {
394 self.org_name = Some(name.into());
395 self
396 }
397
398 #[must_use]
400 pub fn org_role(mut self, role: impl Into<String>) -> Self {
401 self.org_role = Some(role.into());
402 self
403 }
404
405 #[cfg(feature = "group-scope")]
412 #[must_use]
413 pub fn group_role(mut self, group_id: impl Into<String>, role: impl Into<String>) -> Self {
414 self.group_roles.insert(group_id.into(), role.into());
415 self
416 }
417
418 #[must_use]
420 pub fn build(self) -> AuthContext {
421 AuthContext {
422 subject: self.subject,
423 scopes: self.scopes,
424 is_local: self.is_local,
425 org_name: self.org_name,
426 org_role: self.org_role,
427 #[cfg(feature = "group-scope")]
428 group_roles: self.group_roles,
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn test_local_context_has_all_permissions() {
439 let ctx = AuthContext::local();
440
441 assert!(ctx.has_permission(Permission::Read));
442 assert!(ctx.has_permission(Permission::Write));
443 assert!(ctx.has_permission(Permission::Admin));
444 assert!(ctx.require(Permission::Admin).is_ok());
445 }
446
447 #[test]
448 fn test_default_is_local() {
449 let ctx = AuthContext::default();
450 assert!(ctx.is_local());
451 assert!(ctx.has_permission(Permission::Admin));
452 }
453
454 #[test]
455 fn test_from_scopes() {
456 let ctx = AuthContext::from_scopes(vec!["read".to_string(), "write".to_string()]);
457
458 assert!(ctx.has_permission(Permission::Read));
459 assert!(ctx.has_permission(Permission::Write));
460 assert!(!ctx.has_permission(Permission::Admin));
461 }
462
463 #[test]
464 fn test_require_denied() {
465 let ctx = AuthContext::from_scopes(vec!["read".to_string()]);
466
467 assert!(ctx.require(Permission::Read).is_ok());
468 assert!(ctx.require(Permission::Write).is_err());
469 }
470
471 #[test]
472 fn test_wildcard_scope() {
473 let ctx = AuthContext::from_scopes(vec!["*".to_string()]);
474
475 assert!(ctx.has_permission(Permission::Read));
476 assert!(ctx.has_permission(Permission::Write));
477 assert!(ctx.has_permission(Permission::Admin));
478 }
479
480 #[test]
481 fn test_with_subject() {
482 let ctx = AuthContext::from_scopes(vec!["read".to_string()]).with_subject("user-123");
483
484 assert_eq!(ctx.subject(), Some("user-123"));
485 }
486
487 #[test]
488 fn test_builder() {
489 let ctx = AuthContext::builder()
490 .subject("test-user")
491 .scope("read")
492 .scope("write")
493 .build();
494
495 assert_eq!(ctx.subject(), Some("test-user"));
496 assert!(ctx.has_permission(Permission::Read));
497 assert!(ctx.has_permission(Permission::Write));
498 assert!(!ctx.has_permission(Permission::Admin));
499 }
500
501 #[test]
502 fn test_builder_scopes() {
503 let ctx = AuthContext::builder().scopes(vec!["read", "admin"]).build();
504
505 assert!(ctx.has_permission(Permission::Read));
506 assert!(!ctx.has_permission(Permission::Write));
507 assert!(ctx.has_permission(Permission::Admin));
508 }
509
510 #[test]
511 fn test_has_any_permission() {
512 let ctx = AuthContext::from_scopes(vec!["read".to_string()]);
513
514 assert!(ctx.has_any_permission(&[Permission::Read, Permission::Write]));
515 assert!(!ctx.has_any_permission(&[Permission::Write, Permission::Admin]));
516 }
517
518 #[test]
519 fn test_require_any() {
520 let ctx = AuthContext::from_scopes(vec!["read".to_string()]);
521
522 assert!(
523 ctx.require_any(&[Permission::Read, Permission::Write])
524 .is_ok()
525 );
526 assert!(
527 ctx.require_any(&[Permission::Write, Permission::Admin])
528 .is_err()
529 );
530 }
531
532 #[test]
533 fn test_permission_parse() {
534 assert_eq!(Permission::parse("read"), Some(Permission::Read));
535 assert_eq!(Permission::parse("WRITE"), Some(Permission::Write));
536 assert_eq!(Permission::parse("Admin"), Some(Permission::Admin));
537 assert_eq!(Permission::parse("unknown"), None);
538 }
539
540 #[test]
541 fn test_permission_as_str() {
542 assert_eq!(Permission::Read.as_str(), "read");
543 assert_eq!(Permission::Write.as_str(), "write");
544 assert_eq!(Permission::Admin.as_str(), "admin");
545 }
546
547 #[test]
550 #[cfg(feature = "group-scope")]
551 fn test_local_context_has_admin_group_role() {
552 use crate::models::group::GroupRole;
553
554 let ctx = AuthContext::local();
555
556 assert_eq!(ctx.get_group_role("any-group"), Some(GroupRole::Admin));
557 assert!(ctx.has_group_permission("any-group", GroupRole::Admin));
558 assert!(ctx.has_group_permission("any-group", GroupRole::Write));
559 assert!(ctx.has_group_permission("any-group", GroupRole::Read));
560 }
561
562 #[test]
563 #[cfg(feature = "group-scope")]
564 fn test_wildcard_scope_has_admin_group_role() {
565 use crate::models::group::GroupRole;
566
567 let ctx = AuthContext::from_scopes(vec!["*".to_string()]);
568
569 assert_eq!(ctx.get_group_role("any-group"), Some(GroupRole::Admin));
570 assert!(ctx.has_group_permission("any-group", GroupRole::Admin));
571 }
572
573 #[test]
574 #[cfg(feature = "group-scope")]
575 fn test_builder_with_group_role() {
576 use crate::models::group::GroupRole;
577
578 let ctx = AuthContext::builder()
579 .subject("test-user")
580 .group_role("group-123", "write")
581 .build();
582
583 assert_eq!(ctx.get_group_role("group-123"), Some(GroupRole::Write));
584 assert!(ctx.has_group_permission("group-123", GroupRole::Write));
585 assert!(ctx.has_group_permission("group-123", GroupRole::Read));
586 assert!(!ctx.has_group_permission("group-123", GroupRole::Admin));
587 }
588
589 #[test]
590 #[cfg(feature = "group-scope")]
591 fn test_group_role_not_found() {
592 use crate::models::group::GroupRole;
593
594 let ctx = AuthContext::builder()
595 .subject("test-user")
596 .group_role("group-123", "read")
597 .build();
598
599 assert_eq!(ctx.get_group_role("group-456"), None);
601 assert!(!ctx.has_group_permission("group-456", GroupRole::Read));
602 }
603
604 #[test]
605 #[cfg(feature = "group-scope")]
606 fn test_require_group_role_success() {
607 use crate::models::group::GroupRole;
608
609 let ctx = AuthContext::builder()
610 .group_role("group-123", "admin")
611 .build();
612
613 assert!(
614 ctx.require_group_role("group-123", GroupRole::Admin)
615 .is_ok()
616 );
617 assert!(
618 ctx.require_group_role("group-123", GroupRole::Write)
619 .is_ok()
620 );
621 assert!(ctx.require_group_role("group-123", GroupRole::Read).is_ok());
622 }
623
624 #[test]
625 #[cfg(feature = "group-scope")]
626 fn test_require_group_role_denied() {
627 use crate::models::group::GroupRole;
628
629 let ctx = AuthContext::builder()
630 .group_role("group-123", "read")
631 .build();
632
633 assert!(ctx.require_group_role("group-123", GroupRole::Read).is_ok());
634 assert!(
635 ctx.require_group_role("group-123", GroupRole::Write)
636 .is_err()
637 );
638 assert!(
639 ctx.require_group_role("group-123", GroupRole::Admin)
640 .is_err()
641 );
642 }
643
644 #[test]
645 #[cfg(feature = "group-scope")]
646 fn test_require_group_role_not_member() {
647 use crate::models::group::GroupRole;
648
649 let ctx = AuthContext::builder().subject("test-user").build();
650
651 assert!(
653 ctx.require_group_role("group-123", GroupRole::Read)
654 .is_err()
655 );
656 }
657
658 #[test]
659 #[cfg(feature = "group-scope")]
660 fn test_multiple_group_roles() {
661 use crate::models::group::GroupRole;
662
663 let ctx = AuthContext::builder()
664 .subject("test-user")
665 .group_role("group-1", "admin")
666 .group_role("group-2", "write")
667 .group_role("group-3", "read")
668 .build();
669
670 assert_eq!(ctx.get_group_role("group-1"), Some(GroupRole::Admin));
671 assert_eq!(ctx.get_group_role("group-2"), Some(GroupRole::Write));
672 assert_eq!(ctx.get_group_role("group-3"), Some(GroupRole::Read));
673
674 assert!(ctx.has_group_permission("group-1", GroupRole::Admin));
676 assert!(ctx.has_group_permission("group-2", GroupRole::Write));
677 assert!(!ctx.has_group_permission("group-2", GroupRole::Admin));
678 assert!(ctx.has_group_permission("group-3", GroupRole::Read));
679 assert!(!ctx.has_group_permission("group-3", GroupRole::Write));
680 }
681}