lean_ctx/http_server/team/
roles.rs1use std::collections::BTreeSet;
13
14use serde::{Deserialize, Serialize};
15
16use super::TeamScope;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum TeamRole {
27 Viewer,
29 Member,
31 Admin,
33 Owner,
35}
36
37impl TeamRole {
38 #[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 #[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 #[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 #[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 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 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 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)); assert!(eff.contains(&TeamScope::Audit)); }
163}