Skip to main content

rustio_admin/auth/
guards.rs

1//! Authority guards — server-side enforcement of the rank model.
2//!
3//! Every authority mutation (user create / update / delete, future
4//! group / permission / recovery flows) routes through one of these
5//! pure verdict functions before touching the DB. UI hiding is a
6//! courtesy, not a security boundary; the framework refuses unsafe
7//! state changes here regardless of what the form said.
8//!
9//! The guards encode five invariants:
10//!
11//! 1. **Self-demote / self-deactivate are blocked.** A signed-in user
12//!    cannot lower their own role nor flip themselves to inactive
13//!    (matches the existing self-delete rule). Self-keep-rank is fine.
14//!
15//! 2. **Cross-rank protection.** A user cannot edit another user
16//!    whose role rank is at-or-above their own. Editing one's own
17//!    record is not blocked by this guard (the self-* guards cover
18//!    that), and editing a strictly lower-rank target is allowed.
19//!
20//! 3. **Role ceiling.** A user cannot grant a role with a rank
21//!    strictly above their own — even to themselves. Equal is
22//!    allowed (an Admin keeping their own Admin role on save).
23//!
24//! 4. **Protected-role orphan prevention.** Lives in
25//!    [`super::would_orphan_protected`]; the guard wrapper here
26//!    converts the resolved orphan-role into a clear human message.
27//!
28//! 5. _(deferred to a later phase)_ Permission ceiling — a user
29//!    cannot grant permissions they themselves don't hold. Today the
30//!    group routes are gated by `Role::Administrator`, who bypasses
31//!    group checks, so this guard is unreachable; reinstate when
32//!    delegated group management lands.
33//!
34//! All five return [`Error::Forbidden`] on rejection so the HTTP
35//! layer renders a 403 with the supplied reason.
36
37use crate::error::{Error, Result};
38use crate::orm::Db;
39
40use super::role::Role;
41use super::users::{would_orphan_protected, Identity};
42
43/// Forbid a user from saving an edit to their own record that drops
44/// their role below its current rank or flips `is_active` to false.
45/// Self-keep-rank is allowed; raising one's own rank is blocked
46/// separately by [`enforce_role_ceiling`].
47pub fn enforce_self_demote_safe(
48    actor: &Identity,
49    target_id: i64,
50    new_role: Role,
51    new_active: bool,
52) -> Result<()> {
53    if actor.user_id != target_id {
54        return Ok(());
55    }
56    if !new_active {
57        return Err(Error::Forbidden(
58            "You cannot deactivate yourself.".to_string(),
59        ));
60    }
61    if new_role.rank() < actor.role.rank() {
62        return Err(Error::Forbidden(
63            "You cannot demote yourself below your current authority level.".to_string(),
64        ));
65    }
66    Ok(())
67}
68
69/// Forbid a user from modifying another user whose role is at-or-above
70/// their own. Editing one's own record is allowed; the self-* guards
71/// catch the dangerous cases.
72pub fn enforce_cross_rank_safe(actor: &Identity, target_id: i64, target_role: Role) -> Result<()> {
73    if actor.user_id == target_id {
74        return Ok(());
75    }
76    if target_role.rank() >= actor.role.rank() {
77        return Err(Error::Forbidden(
78            "You cannot modify users at or above your authority level.".to_string(),
79        ));
80    }
81    Ok(())
82}
83
84/// Forbid a user from assigning a role with rank strictly greater
85/// than their own. Same-rank is allowed so an Admin can re-save
86/// another Admin's record (already-existing target's rank ladder is
87/// covered by [`enforce_cross_rank_safe`]).
88pub fn enforce_role_ceiling(actor: &Identity, requested_role: Role) -> Result<()> {
89    if requested_role.rank() > actor.role.rank() {
90        return Err(Error::Forbidden(
91            "You cannot assign a role higher than your own authority.".to_string(),
92        ));
93    }
94    Ok(())
95}
96
97/// Reject changes that would empty the active-member set for any
98/// protected role. Wraps [`would_orphan_protected`] and returns a
99/// human-readable error naming the role that would be orphaned.
100pub async fn enforce_no_orphan_role(
101    db: &Db,
102    target_id: i64,
103    new_role: Role,
104    new_active: bool,
105) -> Result<()> {
106    if let Some(role) = would_orphan_protected(db, target_id, new_role, new_active).await? {
107        return Err(Error::Forbidden(format!(
108            "At least one active {} must remain.",
109            role.label()
110        )));
111    }
112    Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn ident(role: Role, user_id: i64) -> Identity {
120        Identity {
121            user_id,
122            email: format!("u{user_id}@test"),
123            role,
124            is_active: true,
125            is_demo: false,
126            demo_label: None,
127            must_change_password: false,
128            mfa_enabled: false,
129            trust_level: crate::auth::SessionTrust::Authenticated,
130        }
131    }
132
133    // ---- enforce_self_demote_safe ----
134
135    #[test]
136    fn self_demote_blocks_role_drop() {
137        let actor = ident(Role::Administrator, 1);
138        let err = enforce_self_demote_safe(&actor, 1, Role::Staff, true).unwrap_err();
139        assert!(matches!(err, Error::Forbidden(_)));
140    }
141
142    #[test]
143    fn self_demote_blocks_self_deactivate() {
144        let actor = ident(Role::Administrator, 1);
145        let err = enforce_self_demote_safe(&actor, 1, Role::Administrator, false).unwrap_err();
146        assert!(matches!(err, Error::Forbidden(_)));
147    }
148
149    #[test]
150    fn self_demote_allows_self_keep_rank() {
151        let actor = ident(Role::Administrator, 1);
152        assert!(enforce_self_demote_safe(&actor, 1, Role::Administrator, true).is_ok());
153    }
154
155    #[test]
156    fn self_demote_ignores_other_targets() {
157        let actor = ident(Role::Administrator, 1);
158        // Lowering another user's role is fine for THIS guard
159        // (cross-rank handles the inverse case).
160        assert!(enforce_self_demote_safe(&actor, 2, Role::User, true).is_ok());
161    }
162
163    // ---- enforce_cross_rank_safe ----
164
165    #[test]
166    fn cross_rank_blocks_editing_higher() {
167        let actor = ident(Role::Administrator, 1);
168        let err = enforce_cross_rank_safe(&actor, 2, Role::Developer).unwrap_err();
169        assert!(matches!(err, Error::Forbidden(_)));
170    }
171
172    #[test]
173    fn cross_rank_blocks_editing_equal() {
174        let actor = ident(Role::Administrator, 1);
175        let err = enforce_cross_rank_safe(&actor, 2, Role::Administrator).unwrap_err();
176        assert!(matches!(err, Error::Forbidden(_)));
177    }
178
179    #[test]
180    fn cross_rank_allows_editing_lower() {
181        let actor = ident(Role::Administrator, 1);
182        assert!(enforce_cross_rank_safe(&actor, 2, Role::Staff).is_ok());
183        assert!(enforce_cross_rank_safe(&actor, 2, Role::Supervisor).is_ok());
184    }
185
186    #[test]
187    fn cross_rank_allows_editing_self() {
188        let actor = ident(Role::Administrator, 1);
189        // Self-edit isn't the concern of this guard.
190        assert!(enforce_cross_rank_safe(&actor, 1, Role::Administrator).is_ok());
191    }
192
193    #[test]
194    fn cross_rank_developer_can_edit_administrator() {
195        let actor = ident(Role::Developer, 1);
196        assert!(enforce_cross_rank_safe(&actor, 2, Role::Administrator).is_ok());
197    }
198
199    // ---- enforce_role_ceiling ----
200
201    #[test]
202    fn ceiling_blocks_promote_above_self() {
203        let actor = ident(Role::Administrator, 1);
204        let err = enforce_role_ceiling(&actor, Role::Developer).unwrap_err();
205        assert!(matches!(err, Error::Forbidden(_)));
206    }
207
208    #[test]
209    fn ceiling_allows_assigning_equal_or_below() {
210        let actor = ident(Role::Administrator, 1);
211        assert!(enforce_role_ceiling(&actor, Role::Administrator).is_ok());
212        assert!(enforce_role_ceiling(&actor, Role::Supervisor).is_ok());
213        assert!(enforce_role_ceiling(&actor, Role::Staff).is_ok());
214        assert!(enforce_role_ceiling(&actor, Role::User).is_ok());
215    }
216
217    #[test]
218    fn ceiling_supervisor_cannot_create_administrator() {
219        let actor = ident(Role::Supervisor, 1);
220        let err = enforce_role_ceiling(&actor, Role::Administrator).unwrap_err();
221        assert!(matches!(err, Error::Forbidden(_)));
222    }
223
224    #[test]
225    fn ceiling_developer_can_assign_anything_inclusive() {
226        let actor = ident(Role::Developer, 1);
227        for r in [
228            Role::User,
229            Role::Staff,
230            Role::Supervisor,
231            Role::Administrator,
232            Role::Developer,
233        ] {
234            assert!(enforce_role_ceiling(&actor, r).is_ok());
235        }
236    }
237}