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// public:
44/// Forbid a user from saving an edit to their own record that drops
45/// their role below its current rank or flips `is_active` to false.
46/// Self-keep-rank is allowed; raising one's own rank is blocked
47/// separately by [`enforce_role_ceiling`].
48pub fn enforce_self_demote_safe(
49    actor: &Identity,
50    target_id: i64,
51    new_role: Role,
52    new_active: bool,
53) -> Result<()> {
54    if actor.user_id != target_id {
55        return Ok(());
56    }
57    if !new_active {
58        return Err(Error::Forbidden(
59            "You cannot deactivate yourself.".to_string(),
60        ));
61    }
62    if new_role.rank() < actor.role.rank() {
63        return Err(Error::Forbidden(
64            "You cannot demote yourself below your current authority level.".to_string(),
65        ));
66    }
67    Ok(())
68}
69
70// public:
71/// Forbid a user from modifying another user whose role is at-or-above
72/// their own. Editing one's own record is allowed; the self-* guards
73/// catch the dangerous cases.
74pub fn enforce_cross_rank_safe(actor: &Identity, target_id: i64, target_role: Role) -> Result<()> {
75    if actor.user_id == target_id {
76        return Ok(());
77    }
78    if target_role.rank() >= actor.role.rank() {
79        return Err(Error::Forbidden(
80            "You cannot modify users at or above your authority level.".to_string(),
81        ));
82    }
83    Ok(())
84}
85
86// public:
87/// Forbid a user from assigning a role with rank strictly greater
88/// than their own. Same-rank is allowed so an Admin can re-save
89/// another Admin's record (already-existing target's rank ladder is
90/// covered by [`enforce_cross_rank_safe`]).
91pub fn enforce_role_ceiling(actor: &Identity, requested_role: Role) -> Result<()> {
92    if requested_role.rank() > actor.role.rank() {
93        return Err(Error::Forbidden(
94            "You cannot assign a role higher than your own authority.".to_string(),
95        ));
96    }
97    Ok(())
98}
99
100// public:
101/// Reject changes that would empty the active-member set for any
102/// protected role. Wraps [`would_orphan_protected`] and returns a
103/// human-readable error naming the role that would be orphaned.
104pub async fn enforce_no_orphan_role(
105    db: &Db,
106    target_id: i64,
107    new_role: Role,
108    new_active: bool,
109) -> Result<()> {
110    if let Some(role) = would_orphan_protected(db, target_id, new_role, new_active).await? {
111        return Err(Error::Forbidden(format!(
112            "At least one active {} must remain.",
113            role.label()
114        )));
115    }
116    Ok(())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn ident(role: Role, user_id: i64) -> Identity {
124        Identity {
125            user_id,
126            email: format!("u{user_id}@test"),
127            role,
128            is_active: true,
129            is_demo: false,
130            demo_label: None,
131            must_change_password: false,
132            mfa_enabled: false,
133            trust_level: crate::auth::SessionTrust::Authenticated,
134        }
135    }
136
137    // ---- enforce_self_demote_safe ----
138
139    #[test]
140    fn self_demote_blocks_role_drop() {
141        let actor = ident(Role::Administrator, 1);
142        let err = enforce_self_demote_safe(&actor, 1, Role::Staff, true).unwrap_err();
143        assert!(matches!(err, Error::Forbidden(_)));
144    }
145
146    #[test]
147    fn self_demote_blocks_self_deactivate() {
148        let actor = ident(Role::Administrator, 1);
149        let err = enforce_self_demote_safe(&actor, 1, Role::Administrator, false).unwrap_err();
150        assert!(matches!(err, Error::Forbidden(_)));
151    }
152
153    #[test]
154    fn self_demote_allows_self_keep_rank() {
155        let actor = ident(Role::Administrator, 1);
156        assert!(enforce_self_demote_safe(&actor, 1, Role::Administrator, true).is_ok());
157    }
158
159    #[test]
160    fn self_demote_ignores_other_targets() {
161        let actor = ident(Role::Administrator, 1);
162        // Lowering another user's role is fine for THIS guard
163        // (cross-rank handles the inverse case).
164        assert!(enforce_self_demote_safe(&actor, 2, Role::User, true).is_ok());
165    }
166
167    // ---- enforce_cross_rank_safe ----
168
169    #[test]
170    fn cross_rank_blocks_editing_higher() {
171        let actor = ident(Role::Administrator, 1);
172        let err = enforce_cross_rank_safe(&actor, 2, Role::Developer).unwrap_err();
173        assert!(matches!(err, Error::Forbidden(_)));
174    }
175
176    #[test]
177    fn cross_rank_blocks_editing_equal() {
178        let actor = ident(Role::Administrator, 1);
179        let err = enforce_cross_rank_safe(&actor, 2, Role::Administrator).unwrap_err();
180        assert!(matches!(err, Error::Forbidden(_)));
181    }
182
183    #[test]
184    fn cross_rank_allows_editing_lower() {
185        let actor = ident(Role::Administrator, 1);
186        assert!(enforce_cross_rank_safe(&actor, 2, Role::Staff).is_ok());
187        assert!(enforce_cross_rank_safe(&actor, 2, Role::Supervisor).is_ok());
188    }
189
190    #[test]
191    fn cross_rank_allows_editing_self() {
192        let actor = ident(Role::Administrator, 1);
193        // Self-edit isn't the concern of this guard.
194        assert!(enforce_cross_rank_safe(&actor, 1, Role::Administrator).is_ok());
195    }
196
197    #[test]
198    fn cross_rank_developer_can_edit_administrator() {
199        let actor = ident(Role::Developer, 1);
200        assert!(enforce_cross_rank_safe(&actor, 2, Role::Administrator).is_ok());
201    }
202
203    // ---- enforce_role_ceiling ----
204
205    #[test]
206    fn ceiling_blocks_promote_above_self() {
207        let actor = ident(Role::Administrator, 1);
208        let err = enforce_role_ceiling(&actor, Role::Developer).unwrap_err();
209        assert!(matches!(err, Error::Forbidden(_)));
210    }
211
212    #[test]
213    fn ceiling_allows_assigning_equal_or_below() {
214        let actor = ident(Role::Administrator, 1);
215        assert!(enforce_role_ceiling(&actor, Role::Administrator).is_ok());
216        assert!(enforce_role_ceiling(&actor, Role::Supervisor).is_ok());
217        assert!(enforce_role_ceiling(&actor, Role::Staff).is_ok());
218        assert!(enforce_role_ceiling(&actor, Role::User).is_ok());
219    }
220
221    #[test]
222    fn ceiling_supervisor_cannot_create_administrator() {
223        let actor = ident(Role::Supervisor, 1);
224        let err = enforce_role_ceiling(&actor, Role::Administrator).unwrap_err();
225        assert!(matches!(err, Error::Forbidden(_)));
226    }
227
228    #[test]
229    fn ceiling_developer_can_assign_anything_inclusive() {
230        let actor = ident(Role::Developer, 1);
231        for r in [
232            Role::User,
233            Role::Staff,
234            Role::Supervisor,
235            Role::Administrator,
236            Role::Developer,
237        ] {
238            assert!(enforce_role_ceiling(&actor, r).is_ok());
239        }
240    }
241}