Skip to main content

communitas_core/permissions/
access_level.rs

1//! Access level definitions for granular permissions.
2
3use serde::{Deserialize, Serialize};
4use std::cmp::Ordering;
5use std::str::FromStr;
6
7/// Granular access level for a resource type.
8///
9/// Access levels are ordered from most restrictive to least restrictive:
10/// `NotVisible` < `ReadOnly` < `Edit`
11///
12/// This ordering allows permission checks to verify if a user has
13/// "at least" a certain level of access.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum AccessLevel {
17    /// Resource is hidden - member cannot see it exists.
18    ///
19    /// Use this for sensitive resources that should be completely
20    /// invisible to certain members (e.g., admin settings for guests).
21    NotVisible,
22
23    /// Read-only access - can view but not modify.
24    ///
25    /// Members with this level can:
26    /// - View resource content
27    /// - List resources
28    /// - Read metadata
29    ///
30    /// Members cannot:
31    /// - Create new resources
32    /// - Modify existing resources
33    /// - Delete resources
34    ReadOnly,
35
36    /// Full access - can view, create, modify, and delete.
37    ///
38    /// Members with this level have complete control over the resource type,
39    /// subject to other constraints (e.g., cannot delete resources created
40    /// by others without additional permissions).
41    Edit,
42}
43
44impl Default for AccessLevel {
45    /// Default access level is `NotVisible` (secure by default).
46    fn default() -> Self {
47        AccessLevel::NotVisible
48    }
49}
50
51impl AccessLevel {
52    /// Check if this access level allows the required level.
53    ///
54    /// Returns `true` if `self` is at least as permissive as `required`.
55    ///
56    /// # Examples
57    ///
58    /// ```
59    /// use communitas_core::permissions::AccessLevel;
60    ///
61    /// assert!(AccessLevel::Edit.allows(AccessLevel::ReadOnly));
62    /// assert!(AccessLevel::Edit.allows(AccessLevel::Edit));
63    /// assert!(!AccessLevel::ReadOnly.allows(AccessLevel::Edit));
64    /// assert!(!AccessLevel::NotVisible.allows(AccessLevel::ReadOnly));
65    /// ```
66    pub fn allows(self, required: AccessLevel) -> bool {
67        self >= required
68    }
69
70    /// Check if this access level can view the resource.
71    ///
72    /// Returns `true` for `ReadOnly` and `Edit` levels.
73    pub fn can_view(self) -> bool {
74        matches!(self, AccessLevel::ReadOnly | AccessLevel::Edit)
75    }
76
77    /// Check if this access level can modify the resource.
78    ///
79    /// Returns `true` only for `Edit` level.
80    pub fn can_edit(self) -> bool {
81        matches!(self, AccessLevel::Edit)
82    }
83
84    /// Get the numeric rank for ordering (0 = NotVisible, 1 = ReadOnly, 2 = Edit).
85    fn rank(self) -> u8 {
86        match self {
87            AccessLevel::NotVisible => 0,
88            AccessLevel::ReadOnly => 1,
89            AccessLevel::Edit => 2,
90        }
91    }
92
93    /// Convert to string representation.
94    pub fn as_str(self) -> &'static str {
95        match self {
96            AccessLevel::NotVisible => "not_visible",
97            AccessLevel::ReadOnly => "read_only",
98            AccessLevel::Edit => "edit",
99        }
100    }
101}
102
103/// Error when parsing an invalid access level string.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ParseAccessLevelError {
106    /// The invalid input string.
107    pub invalid_value: String,
108}
109
110impl std::fmt::Display for ParseAccessLevelError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        write!(
113            f,
114            "invalid access level '{}': expected 'not_visible', 'read_only', or 'edit'",
115            self.invalid_value
116        )
117    }
118}
119
120impl std::error::Error for ParseAccessLevelError {}
121
122impl FromStr for AccessLevel {
123    type Err = ParseAccessLevelError;
124
125    /// Parse from string representation.
126    ///
127    /// Accepts: "not_visible", "read_only", "edit" (case-insensitive).
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        match s.to_lowercase().as_str() {
130            "not_visible" | "notvisible" | "hidden" => Ok(AccessLevel::NotVisible),
131            "read_only" | "readonly" | "read" | "view" => Ok(AccessLevel::ReadOnly),
132            "edit" | "write" | "full" => Ok(AccessLevel::Edit),
133            _ => Err(ParseAccessLevelError {
134                invalid_value: s.to_string(),
135            }),
136        }
137    }
138}
139
140impl PartialOrd for AccessLevel {
141    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
142        Some(self.cmp(other))
143    }
144}
145
146impl Ord for AccessLevel {
147    fn cmp(&self, other: &Self) -> Ordering {
148        self.rank().cmp(&other.rank())
149    }
150}
151
152impl std::fmt::Display for AccessLevel {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        write!(f, "{}", self.as_str())
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_access_level_ordering() {
164        assert!(AccessLevel::NotVisible < AccessLevel::ReadOnly);
165        assert!(AccessLevel::ReadOnly < AccessLevel::Edit);
166        assert!(AccessLevel::NotVisible < AccessLevel::Edit);
167    }
168
169    #[test]
170    fn test_allows() {
171        // Edit allows everything
172        assert!(AccessLevel::Edit.allows(AccessLevel::Edit));
173        assert!(AccessLevel::Edit.allows(AccessLevel::ReadOnly));
174        assert!(AccessLevel::Edit.allows(AccessLevel::NotVisible));
175
176        // ReadOnly allows itself and below
177        assert!(AccessLevel::ReadOnly.allows(AccessLevel::ReadOnly));
178        assert!(AccessLevel::ReadOnly.allows(AccessLevel::NotVisible));
179        assert!(!AccessLevel::ReadOnly.allows(AccessLevel::Edit));
180
181        // NotVisible only allows itself
182        assert!(AccessLevel::NotVisible.allows(AccessLevel::NotVisible));
183        assert!(!AccessLevel::NotVisible.allows(AccessLevel::ReadOnly));
184        assert!(!AccessLevel::NotVisible.allows(AccessLevel::Edit));
185    }
186
187    #[test]
188    fn test_can_view_and_edit() {
189        assert!(!AccessLevel::NotVisible.can_view());
190        assert!(!AccessLevel::NotVisible.can_edit());
191
192        assert!(AccessLevel::ReadOnly.can_view());
193        assert!(!AccessLevel::ReadOnly.can_edit());
194
195        assert!(AccessLevel::Edit.can_view());
196        assert!(AccessLevel::Edit.can_edit());
197    }
198
199    #[test]
200    fn test_from_str() {
201        assert_eq!(
202            "not_visible".parse::<AccessLevel>().unwrap(),
203            AccessLevel::NotVisible
204        );
205        assert_eq!(
206            "read_only".parse::<AccessLevel>().unwrap(),
207            AccessLevel::ReadOnly
208        );
209        assert_eq!("edit".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
210        assert_eq!("EDIT".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
211        assert!("invalid".parse::<AccessLevel>().is_err());
212    }
213
214    #[test]
215    fn test_default() {
216        assert_eq!(AccessLevel::default(), AccessLevel::NotVisible);
217    }
218
219    #[test]
220    fn test_serialization() {
221        let level = AccessLevel::ReadOnly;
222        let json = serde_json::to_string(&level).unwrap();
223        assert_eq!(json, "\"read_only\"");
224
225        let parsed: AccessLevel = serde_json::from_str(&json).unwrap();
226        assert_eq!(parsed, level);
227    }
228
229    #[test]
230    fn test_display() {
231        assert_eq!(format!("{}", AccessLevel::NotVisible), "not_visible");
232        assert_eq!(format!("{}", AccessLevel::ReadOnly), "read_only");
233        assert_eq!(format!("{}", AccessLevel::Edit), "edit");
234    }
235
236    #[test]
237    fn test_as_str() {
238        assert_eq!(AccessLevel::NotVisible.as_str(), "not_visible");
239        assert_eq!(AccessLevel::ReadOnly.as_str(), "read_only");
240        assert_eq!(AccessLevel::Edit.as_str(), "edit");
241    }
242
243    #[test]
244    fn test_parse_error_display() {
245        let err: Result<AccessLevel, _> = "invalid_level".parse();
246        assert!(err.is_err());
247        let err = err.unwrap_err();
248        assert!(err.to_string().contains("invalid_level"));
249        assert!(err.to_string().contains("not_visible"));
250        assert!(err.to_string().contains("read_only"));
251        assert!(err.to_string().contains("edit"));
252    }
253
254    #[test]
255    fn test_from_str_aliases() {
256        // Test all alias forms
257        assert_eq!(
258            "hidden".parse::<AccessLevel>().unwrap(),
259            AccessLevel::NotVisible
260        );
261        assert_eq!(
262            "notvisible".parse::<AccessLevel>().unwrap(),
263            AccessLevel::NotVisible
264        );
265
266        assert_eq!(
267            "readonly".parse::<AccessLevel>().unwrap(),
268            AccessLevel::ReadOnly
269        );
270        assert_eq!(
271            "read".parse::<AccessLevel>().unwrap(),
272            AccessLevel::ReadOnly
273        );
274        assert_eq!(
275            "view".parse::<AccessLevel>().unwrap(),
276            AccessLevel::ReadOnly
277        );
278
279        assert_eq!("write".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
280        assert_eq!("full".parse::<AccessLevel>().unwrap(), AccessLevel::Edit);
281    }
282
283    #[test]
284    fn test_rank() {
285        // Test internal ordering via PartialOrd
286        assert!(AccessLevel::NotVisible < AccessLevel::ReadOnly);
287        assert!(AccessLevel::ReadOnly < AccessLevel::Edit);
288        assert!(AccessLevel::NotVisible < AccessLevel::Edit);
289
290        // Test equality
291        assert_eq!(
292            AccessLevel::Edit.cmp(&AccessLevel::Edit),
293            std::cmp::Ordering::Equal
294        );
295        assert_eq!(
296            AccessLevel::ReadOnly.cmp(&AccessLevel::NotVisible),
297            std::cmp::Ordering::Greater
298        );
299    }
300
301    #[test]
302    fn test_partial_ord() {
303        assert!(
304            AccessLevel::NotVisible.partial_cmp(&AccessLevel::ReadOnly)
305                == Some(std::cmp::Ordering::Less)
306        );
307        assert!(
308            AccessLevel::Edit.partial_cmp(&AccessLevel::ReadOnly)
309                == Some(std::cmp::Ordering::Greater)
310        );
311        assert!(
312            AccessLevel::ReadOnly.partial_cmp(&AccessLevel::ReadOnly)
313                == Some(std::cmp::Ordering::Equal)
314        );
315    }
316
317    #[test]
318    fn test_allows_not_visible() {
319        let level = AccessLevel::NotVisible;
320        assert!(level.allows(AccessLevel::NotVisible));
321        assert!(!level.allows(AccessLevel::ReadOnly));
322        assert!(!level.allows(AccessLevel::Edit));
323    }
324}