Skip to main content

subcog/services/
auth.rs

1//! Service-layer authorization (CRIT-006).
2//!
3//! Provides authorization context that can be passed to service methods
4//! for fine-grained access control. This complements MCP-layer JWT auth
5//! by enforcing permissions at the service boundary.
6//!
7//! # Design Principles
8//!
9//! - **Opt-in**: Services work without auth context (CLI/local use)
10//! - **Defense in depth**: Complements transport-layer auth
11//! - **Audit trail**: All authorization decisions are logged
12//!
13//! # Usage
14//!
15//! ```rust,ignore
16//! use subcog::services::auth::{AuthContext, Permission};
17//!
18//! // Create context from JWT claims
19//! let ctx = AuthContext::from_scopes(vec!["read".to_string(), "write".to_string()])
20//!     .with_subject("user-123");
21//!
22//! // Check permission before operation
23//! ctx.require(Permission::Write)?;
24//!
25//! // Or use the builder pattern
26//! let ctx = AuthContext::builder()
27//!     .subject("user-123")
28//!     .scope("read")
29//!     .scope("write")
30//!     .build();
31//! ```
32
33use 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/// Permissions for service operations.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub enum Permission {
45    /// Read operations (recall, status, list).
46    Read,
47    /// Write operations (capture, enrich, delete).
48    Write,
49    /// Admin operations (sync, reindex, consolidate).
50    Admin,
51}
52
53impl Permission {
54    /// Returns the scope string for this permission.
55    #[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    /// Parses a scope string into a permission.
65    #[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/// Authorization context for service operations.
77///
78/// Carries identity and permission information through the service layer.
79/// Can be created from JWT claims or constructed directly for testing.
80#[derive(Debug, Clone)]
81pub struct AuthContext {
82    /// Subject identifier (user ID, service account, etc.).
83    subject: Option<String>,
84    /// Granted scopes/permissions.
85    scopes: HashSet<String>,
86    /// Whether this is a local/CLI context (implicitly trusted).
87    is_local: bool,
88    /// Organization name (for org-scoped operations).
89    org_name: Option<String>,
90    /// Role within the organization (admin, member, etc.).
91    org_role: Option<String>,
92    /// Group roles (`group_id` → role string).
93    #[cfg(feature = "group-scope")]
94    group_roles: HashMap<String, String>,
95}
96
97impl Default for AuthContext {
98    /// Creates a default context that allows all operations.
99    ///
100    /// This is used for CLI/local access where there's no authentication.
101    fn default() -> Self {
102        Self::local()
103    }
104}
105
106impl AuthContext {
107    /// Creates a local context with full permissions.
108    ///
109    /// Used for CLI access where the user has local filesystem access.
110    #[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    /// Creates a context from a list of scope strings.
124    ///
125    /// # Arguments
126    ///
127    /// * `scopes` - List of scope strings (e.g., `["read", "write"]`).
128    #[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    /// Creates a builder for constructing an auth context.
142    #[must_use]
143    pub fn builder() -> AuthContextBuilder {
144        AuthContextBuilder::default()
145    }
146
147    /// Sets the subject identifier.
148    #[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    /// Returns the subject identifier.
155    #[must_use]
156    pub fn subject(&self) -> Option<&str> {
157        self.subject.as_deref()
158    }
159
160    /// Returns whether this is a local/CLI context.
161    #[must_use]
162    pub const fn is_local(&self) -> bool {
163        self.is_local
164    }
165
166    /// Returns the organization name if set.
167    #[must_use]
168    pub fn org_name(&self) -> Option<&str> {
169        self.org_name.as_deref()
170    }
171
172    /// Returns the organization role if set.
173    #[must_use]
174    pub fn org_role(&self) -> Option<&str> {
175        self.org_role.as_deref()
176    }
177
178    /// Returns whether this context has org access.
179    #[must_use]
180    pub fn has_org_access(&self) -> bool {
181        // Local contexts have org access if org is configured
182        if self.is_local {
183            return true;
184        }
185        // Remote contexts need org:read or org:write scope
186        self.scopes.contains("org:read")
187            || self.scopes.contains("org:write")
188            || self.scopes.contains("*")
189    }
190
191    /// Checks if the context has a specific scope.
192    #[must_use]
193    pub fn has_scope(&self, scope: &str) -> bool {
194        // Local contexts have all permissions
195        if self.is_local {
196            return true;
197        }
198        // Wildcard scope grants everything
199        if self.scopes.contains("*") {
200            return true;
201        }
202        self.scopes.contains(scope)
203    }
204
205    /// Checks if the context has a specific permission.
206    #[must_use]
207    pub fn has_permission(&self, permission: Permission) -> bool {
208        self.has_scope(permission.as_str())
209    }
210
211    /// Checks if the context has any of the specified permissions.
212    #[must_use]
213    pub fn has_any_permission(&self, permissions: &[Permission]) -> bool {
214        permissions.iter().any(|p| self.has_permission(*p))
215    }
216
217    /// Requires a specific permission, returning an error if not granted.
218    ///
219    /// # Errors
220    ///
221    /// Returns `Error::Unauthorized` if the permission is not granted.
222    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    /// Requires any of the specified permissions.
246    ///
247    /// # Errors
248    ///
249    /// Returns `Error::Unauthorized` if none of the permissions are granted.
250    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    /// Returns the user's role in a specific group.
262    ///
263    /// # Arguments
264    ///
265    /// * `group_id` - The group identifier.
266    ///
267    /// # Returns
268    ///
269    /// `Some(GroupRole)` if the user has a role in the group, `None` otherwise.
270    /// For local contexts, always returns `Some(GroupRole::Admin)`.
271    #[cfg(feature = "group-scope")]
272    #[must_use]
273    pub fn get_group_role(&self, group_id: &str) -> Option<GroupRole> {
274        // Local contexts have admin access to all groups
275        if self.is_local {
276            return Some(GroupRole::Admin);
277        }
278        // Wildcard scope grants admin to all groups
279        if self.scopes.contains("*") {
280            return Some(GroupRole::Admin);
281        }
282        // Look up the specific group role
283        self.group_roles
284            .get(group_id)
285            .and_then(|role| GroupRole::parse(role))
286    }
287
288    /// Checks if the user has at least the specified role in a group.
289    ///
290    /// # Arguments
291    ///
292    /// * `group_id` - The group identifier.
293    /// * `min_role` - The minimum required role.
294    ///
295    /// # Returns
296    ///
297    /// `true` if the user has sufficient permissions, `false` otherwise.
298    #[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    /// Requires at least the specified role in a group.
312    ///
313    /// # Arguments
314    ///
315    /// * `group_id` - The group identifier.
316    /// * `min_role` - The minimum required role.
317    ///
318    /// # Errors
319    ///
320    /// Returns `Error::Unauthorized` if the user doesn't have the required role.
321    #[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/// Builder for constructing an [`AuthContext`].
349#[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    /// Sets the subject identifier.
362    #[must_use]
363    pub fn subject(mut self, subject: impl Into<String>) -> Self {
364        self.subject = Some(subject.into());
365        self
366    }
367
368    /// Adds a scope.
369    #[must_use]
370    pub fn scope(mut self, scope: impl Into<String>) -> Self {
371        self.scopes.insert(scope.into());
372        self
373    }
374
375    /// Adds multiple scopes.
376    #[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    /// Marks this as a local context.
385    #[must_use]
386    pub const fn local(mut self) -> Self {
387        self.is_local = true;
388        self
389    }
390
391    /// Sets the organization name.
392    #[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    /// Sets the organization role.
399    #[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    /// Sets a group role for the user.
406    ///
407    /// # Arguments
408    ///
409    /// * `group_id` - The group identifier
410    /// * `role` - The role in that group (admin, write, read)
411    #[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    /// Builds the auth context.
419    #[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    // Group permission tests (only compiled with group-scope feature)
548
549    #[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        // Different group ID returns None
600        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        // No group roles set, should fail
652        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        // Check permissions hierarchy
675        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}