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 and the
35    /// rank-ceiling guard. Values are spaced (100 / 300 / 600 / 900 /
36    /// 1000) so projects extending the ladder via group-rank labels
37    /// have room between tiers without colliding with a framework
38    /// role. The exact numbers are stable but should be compared
39    /// relatively, never matched literally.
40    pub const fn rank(self) -> u32 {
41        match self {
42            Role::User => 100,
43            Role::Staff => 300,
44            Role::Supervisor => 600,
45            Role::Administrator => 900,
46            Role::Developer => 1000,
47        }
48    }
49
50    /// `self` is allowed to do anything `other` can do.
51    pub fn includes(self, other: Role) -> bool {
52        self.rank() >= other.rank()
53    }
54
55    /// Stable, lowercase identifier. Matches the SQL `role` column.
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Role::User => "user",
59            Role::Staff => "staff",
60            Role::Supervisor => "supervisor",
61            Role::Administrator => "administrator",
62            Role::Developer => "developer",
63        }
64    }
65
66    /// Human-readable label for templates.
67    pub fn label(self) -> &'static str {
68        match self {
69            Role::User => "User",
70            Role::Staff => "Staff",
71            Role::Supervisor => "Supervisor",
72            Role::Administrator => "Administrator",
73            Role::Developer => "Developer",
74        }
75    }
76
77    /// Parse from the SQL string. Errors map to `Error::BadRequest`
78    /// so HTTP handlers can surface them directly.
79    pub fn parse(s: &str) -> Result<Self> {
80        match s {
81            "user" => Ok(Role::User),
82            "staff" => Ok(Role::Staff),
83            "supervisor" => Ok(Role::Supervisor),
84            "administrator" => Ok(Role::Administrator),
85            "developer" => Ok(Role::Developer),
86            other => Err(Error::BadRequest(format!("unknown role: {other}"))),
87        }
88    }
89
90    /// Is this role allowed into the admin panel at all?
91    /// Staff and higher pass.
92    pub fn can_access_panel(self) -> bool {
93        self.rank() >= Role::Staff.rank()
94    }
95
96    /// Bypasses per-model group permission checks. Administrator and
97    /// Developer can do anything the framework knows how to do; lower
98    /// tiers must hold the matching `<admin>.<action>_<model>` permission.
99    pub fn bypasses_group_checks(self) -> bool {
100        matches!(self, Role::Administrator | Role::Developer)
101    }
102}
103
104/// Roles the framework refuses to lose its last active member of.
105///
106/// The orphan guards in `auth/guards.rs` and `auth::would_orphan_role`
107/// loop over this list and reject any change that would empty the
108/// active-member set for one of these tiers — the system stays
109/// recoverable from the UI even after staff turnover.
110///
111/// Currently `[Administrator, Developer]`. Both protect the panel
112/// itself: losing every Developer locks the platform-level recovery
113/// path; losing every Administrator locks the operational recovery
114/// path. Lower tiers are not protected — projects can run with zero
115/// Supervisors / Staff / Users without breaking authority.
116pub const fn protected_roles() -> &'static [Role] {
117    &[Role::Administrator, Role::Developer]
118}
119
120impl serde::Serialize for Role {
121    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
122        s.serialize_str(self.as_str())
123    }
124}
125
126impl<'de> serde::Deserialize<'de> for Role {
127    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
128        let s = <String as serde::Deserialize>::deserialize(d)?;
129        Role::parse(&s).map_err(serde::de::Error::custom)
130    }
131}
132
133impl std::fmt::Display for Role {
134    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
135        f.write_str(self.as_str())
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// 25-case `Role::includes` matrix. Every (self, other) pair.
144    #[test]
145    fn includes_matrix_is_strict_ladder() {
146        let tiers = [
147            Role::User,
148            Role::Staff,
149            Role::Supervisor,
150            Role::Administrator,
151            Role::Developer,
152        ];
153        for (i, &a) in tiers.iter().enumerate() {
154            for (j, &b) in tiers.iter().enumerate() {
155                assert_eq!(
156                    a.includes(b),
157                    i >= j,
158                    "{a:?}.includes({b:?}) should be {}",
159                    i >= j
160                );
161            }
162        }
163    }
164
165    #[test]
166    fn parse_round_trips_for_every_variant() {
167        for &r in &[
168            Role::User,
169            Role::Staff,
170            Role::Supervisor,
171            Role::Administrator,
172            Role::Developer,
173        ] {
174            assert_eq!(Role::parse(r.as_str()).unwrap(), r);
175        }
176    }
177
178    #[test]
179    fn parse_rejects_unknown() {
180        assert!(Role::parse("admin").is_err());
181        assert!(Role::parse("root").is_err());
182        assert!(Role::parse("").is_err());
183    }
184
185    #[test]
186    fn can_access_panel_gates_at_staff() {
187        assert!(!Role::User.can_access_panel());
188        assert!(Role::Staff.can_access_panel());
189        assert!(Role::Supervisor.can_access_panel());
190        assert!(Role::Administrator.can_access_panel());
191        assert!(Role::Developer.can_access_panel());
192    }
193
194    #[test]
195    fn bypasses_group_checks_only_admin_and_dev() {
196        assert!(!Role::User.bypasses_group_checks());
197        assert!(!Role::Staff.bypasses_group_checks());
198        assert!(!Role::Supervisor.bypasses_group_checks());
199        assert!(Role::Administrator.bypasses_group_checks());
200        assert!(Role::Developer.bypasses_group_checks());
201    }
202
203    #[test]
204    fn label_is_capitalized_human_form() {
205        assert_eq!(Role::Administrator.label(), "Administrator");
206        assert_eq!(Role::Developer.label(), "Developer");
207        assert_eq!(Role::User.label(), "User");
208    }
209}