Skip to main content

rustio_admin/auth/
role.rs

1//! 5-tier role hierarchy.
2//!
3//! Roles form a strict ladder; a higher role implicitly has every
4//! capability of every lower role:
5//!
6//! ```text
7//!   Developer (6) > Administrator (5) > Supervisor (4) > Staff (3) > User (2)
8//! ```
9//!
10//! Two semantic dimensions every caller cares about:
11//!
12//! - [`Role::can_access_panel`] — gates the `/admin` URL space.
13//!   Anything Staff or higher can enter; lower bounces to login or
14//!   the forbidden page.
15//! - [`Role::bypasses_group_checks`] — gates per-model permissions.
16//!   Administrator and Developer skip the `view_X`/`add_X`/… group
17//!   table lookup; Supervisor and below get checked.
18//!
19//! Roles are locked at 5 — by design. New project-specific tiers go
20//! into the M2M groups table, not into this enum.
21
22use crate::error::{Error, Result};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum Role {
26    User,
27    Staff,
28    Supervisor,
29    Administrator,
30    Developer,
31}
32
33impl Role {
34    /// Numeric rank used for `includes` comparisons. The actual values
35    /// only matter relative to each other.
36    pub fn rank(self) -> u8 {
37        match self {
38            Role::User => 2,
39            Role::Staff => 3,
40            Role::Supervisor => 4,
41            Role::Administrator => 5,
42            Role::Developer => 6,
43        }
44    }
45
46    /// `self` is allowed to do anything `other` can do.
47    pub fn includes(self, other: Role) -> bool {
48        self.rank() >= other.rank()
49    }
50
51    /// Stable, lowercase identifier. Matches the SQL `role` column.
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Role::User => "user",
55            Role::Staff => "staff",
56            Role::Supervisor => "supervisor",
57            Role::Administrator => "administrator",
58            Role::Developer => "developer",
59        }
60    }
61
62    /// Human-readable label for templates.
63    pub fn label(self) -> &'static str {
64        match self {
65            Role::User => "User",
66            Role::Staff => "Staff",
67            Role::Supervisor => "Supervisor",
68            Role::Administrator => "Administrator",
69            Role::Developer => "Developer",
70        }
71    }
72
73    /// Parse from the SQL string. Errors map to `Error::BadRequest`
74    /// so HTTP handlers can surface them directly.
75    pub fn parse(s: &str) -> Result<Self> {
76        match s {
77            "user" => Ok(Role::User),
78            "staff" => Ok(Role::Staff),
79            "supervisor" => Ok(Role::Supervisor),
80            "administrator" => Ok(Role::Administrator),
81            "developer" => Ok(Role::Developer),
82            other => Err(Error::BadRequest(format!("unknown role: {other}"))),
83        }
84    }
85
86    /// Is this role allowed into the admin panel at all?
87    /// Staff and higher pass.
88    pub fn can_access_panel(self) -> bool {
89        self.rank() >= Role::Staff.rank()
90    }
91
92    /// Bypasses per-model group permission checks. Administrator and
93    /// Developer can do anything the framework knows how to do; lower
94    /// tiers must hold the matching `<admin>.<action>_<model>` permission.
95    pub fn bypasses_group_checks(self) -> bool {
96        matches!(self, Role::Administrator | Role::Developer)
97    }
98}
99
100impl serde::Serialize for Role {
101    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
102        s.serialize_str(self.as_str())
103    }
104}
105
106impl<'de> serde::Deserialize<'de> for Role {
107    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
108        let s = <String as serde::Deserialize>::deserialize(d)?;
109        Role::parse(&s).map_err(serde::de::Error::custom)
110    }
111}
112
113impl std::fmt::Display for Role {
114    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
115        f.write_str(self.as_str())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// 25-case `Role::includes` matrix. Every (self, other) pair.
124    #[test]
125    fn includes_matrix_is_strict_ladder() {
126        let tiers = [
127            Role::User,
128            Role::Staff,
129            Role::Supervisor,
130            Role::Administrator,
131            Role::Developer,
132        ];
133        for (i, &a) in tiers.iter().enumerate() {
134            for (j, &b) in tiers.iter().enumerate() {
135                assert_eq!(
136                    a.includes(b),
137                    i >= j,
138                    "{a:?}.includes({b:?}) should be {}",
139                    i >= j
140                );
141            }
142        }
143    }
144
145    #[test]
146    fn parse_round_trips_for_every_variant() {
147        for &r in &[
148            Role::User,
149            Role::Staff,
150            Role::Supervisor,
151            Role::Administrator,
152            Role::Developer,
153        ] {
154            assert_eq!(Role::parse(r.as_str()).unwrap(), r);
155        }
156    }
157
158    #[test]
159    fn parse_rejects_unknown() {
160        assert!(Role::parse("admin").is_err());
161        assert!(Role::parse("root").is_err());
162        assert!(Role::parse("").is_err());
163    }
164
165    #[test]
166    fn can_access_panel_gates_at_staff() {
167        assert!(!Role::User.can_access_panel());
168        assert!(Role::Staff.can_access_panel());
169        assert!(Role::Supervisor.can_access_panel());
170        assert!(Role::Administrator.can_access_panel());
171        assert!(Role::Developer.can_access_panel());
172    }
173
174    #[test]
175    fn bypasses_group_checks_only_admin_and_dev() {
176        assert!(!Role::User.bypasses_group_checks());
177        assert!(!Role::Staff.bypasses_group_checks());
178        assert!(!Role::Supervisor.bypasses_group_checks());
179        assert!(Role::Administrator.bypasses_group_checks());
180        assert!(Role::Developer.bypasses_group_checks());
181    }
182
183    #[test]
184    fn label_is_capitalized_human_form() {
185        assert_eq!(Role::Administrator.label(), "Administrator");
186        assert_eq!(Role::Developer.label(), "Developer");
187        assert_eq!(Role::User.label(), "User");
188    }
189}