Skip to main content

lean_ctx/http_server/team/
roles.rs

1//! RBAC roles for the Team/Org plane (EPIC 13.2).
2//!
3//! The team server already enforces fine-grained [`TeamScope`]s per token. Roles
4//! are an **ergonomic, governance-friendly layer on top**: an admin assigns a
5//! coarse role (`viewer`/`member`/`admin`/`owner`) instead of hand-picking
6//! scopes. A role expands to a set of scopes, which the existing middleware
7//! enforces unchanged — so RBAC is real and end-to-end with zero new
8//! enforcement paths.
9//!
10//! This is additive and Team/Cloud-only: it never affects the local plane.
11
12use std::collections::BTreeSet;
13
14use serde::{Deserialize, Serialize};
15
16use super::TeamScope;
17
18/// A coarse RBAC role that expands to a set of [`TeamScope`]s.
19///
20/// Roles are ordered by privilege: `Viewer` < `Member` < `Admin` ≤ `Owner`.
21/// `Owner` and `Admin` share the same *server* scopes; an Owner's additional
22/// authority (org membership, billing, plan) is a hosted control-plane concern,
23/// not a server access scope.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum TeamRole {
27    /// Read-only discovery/analysis.
28    Viewer,
29    /// Contributor: read + graph + indexing + shared knowledge + live events.
30    Member,
31    /// Full operational access, including session mutations and audit.
32    Admin,
33    /// Same server scopes as `Admin`; org governance lives on the control plane.
34    Owner,
35}
36
37impl TeamRole {
38    /// All roles, ascending by privilege.
39    #[must_use]
40    pub fn all() -> &'static [TeamRole] {
41        &[
42            TeamRole::Viewer,
43            TeamRole::Member,
44            TeamRole::Admin,
45            TeamRole::Owner,
46        ]
47    }
48
49    /// Stable wire identifier.
50    #[must_use]
51    pub fn as_str(self) -> &'static str {
52        match self {
53            TeamRole::Viewer => "viewer",
54            TeamRole::Member => "member",
55            TeamRole::Admin => "admin",
56            TeamRole::Owner => "owner",
57        }
58    }
59
60    /// Parse a role id (case-insensitive). Returns `None` for unknown ids
61    /// (callers decide whether that is an error — config validation does).
62    #[must_use]
63    pub fn parse(s: &str) -> Option<TeamRole> {
64        match s.trim().to_ascii_lowercase().as_str() {
65            "viewer" | "read" | "readonly" => Some(TeamRole::Viewer),
66            "member" | "contributor" | "write" => Some(TeamRole::Member),
67            "admin" => Some(TeamRole::Admin),
68            "owner" => Some(TeamRole::Owner),
69            _ => None,
70        }
71    }
72
73    /// The scopes this role grants. Monotonic: a higher role's scope set is a
74    /// superset of every lower role's (asserted in tests).
75    #[must_use]
76    pub fn scopes(self) -> BTreeSet<TeamScope> {
77        let mut s = BTreeSet::new();
78        match self {
79            TeamRole::Viewer => {
80                s.insert(TeamScope::Search);
81            }
82            TeamRole::Member => {
83                s.insert(TeamScope::Search);
84                s.insert(TeamScope::Graph);
85                s.insert(TeamScope::Index);
86                s.insert(TeamScope::Knowledge);
87                s.insert(TeamScope::Events);
88            }
89            TeamRole::Admin | TeamRole::Owner => {
90                for scope in TeamScope::all() {
91                    s.insert(*scope);
92                }
93            }
94        }
95        s
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn role_roundtrips_through_wire_id() {
105        for r in TeamRole::all() {
106            assert_eq!(TeamRole::parse(r.as_str()), Some(*r));
107        }
108        assert_eq!(TeamRole::parse("ADMIN"), Some(TeamRole::Admin));
109        assert_eq!(TeamRole::parse("nope"), None);
110    }
111
112    #[test]
113    fn roles_are_monotonic_in_privilege() {
114        // Each higher role's scopes ⊇ the next lower role's scopes.
115        let viewer = TeamRole::Viewer.scopes();
116        let member = TeamRole::Member.scopes();
117        let admin = TeamRole::Admin.scopes();
118        let owner = TeamRole::Owner.scopes();
119        assert!(viewer.is_subset(&member), "viewer ⊄ member");
120        assert!(member.is_subset(&admin), "member ⊄ admin");
121        assert_eq!(admin, owner, "owner shares admin's server scopes");
122    }
123
124    #[test]
125    fn viewer_is_read_only() {
126        let v = TeamRole::Viewer.scopes();
127        assert!(v.contains(&TeamScope::Search));
128        assert!(!v.contains(&TeamScope::SessionMutations));
129        assert!(!v.contains(&TeamScope::Audit));
130    }
131
132    #[test]
133    fn admin_grants_every_scope() {
134        let admin = TeamRole::Admin.scopes();
135        for scope in TeamScope::all() {
136            assert!(admin.contains(scope), "admin missing scope {scope:?}");
137        }
138    }
139
140    #[test]
141    fn token_effective_scopes_union_role_and_explicit() {
142        use super::super::TeamTokenConfig;
143        // Role-only token expands to the role's scopes.
144        let role_only = TeamTokenConfig {
145            id: "t".into(),
146            sha256_hex: "x".into(),
147            scopes: vec![],
148            role: Some(TeamRole::Viewer),
149        };
150        assert_eq!(role_only.effective_scopes(), TeamRole::Viewer.scopes());
151
152        // Explicit scope unions with the role's scopes.
153        let mixed = TeamTokenConfig {
154            id: "t".into(),
155            sha256_hex: "x".into(),
156            scopes: vec![TeamScope::Audit],
157            role: Some(TeamRole::Viewer),
158        };
159        let eff = mixed.effective_scopes();
160        assert!(eff.contains(&TeamScope::Search)); // from role
161        assert!(eff.contains(&TeamScope::Audit)); // explicit
162    }
163}