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