Skip to main content

rustio_core/admin/
rbac.rs

1//! Role-based access control for the admin UI (0.10+).
2//!
3//! Four built-in roles map to a per-model permission matrix over four
4//! actions. The matrix is hardcoded at this stage; DB-backed per-(role,
5//! model) overrides are a future extension.
6//!
7//! This module is self-contained at stage 3 — no middleware or handler
8//! consumes it yet. Stage 4 wires it into the admin request path and the
9//! template context.
10//!
11//! ## Source of role data
12//!
13//! Roles are stored as strings in the existing `rustio_users.role`
14//! column. No schema migration is required. [`Role::from_role_string`]
15//! resolves the column value to a typed `Role`; unknown / empty values
16//! resolve to `None`, meaning *no admin access at all* — the caller is
17//! expected to 403 in that case.
18//!
19//! ## Backward compatibility with the `role` column
20//!
21//! Pre-0.10 only `"admin"` and `"user"` were recognised.
22//!
23//! - `"admin"` → [`Role::SuperAdmin`]. The legacy admin tier had
24//!   unrestricted power; resolving it to SuperAdmin preserves that.
25//!   Projects that want the restricted-admin tier introduced in 0.10.0
26//!   should store `"restricted_admin"`.
27//! - `"user"`, empty, unknown → `None`. Pre-0.10 these users had no
28//!   admin access; they continue to have none.
29//!
30//! ## Permission matrix (defaults)
31//!
32//! "System table" = any table whose name begins with `rustio_` (the
33//! framework's own `rustio_users`, `rustio_sessions`,
34//! `rustio_admin_actions`, etc.).
35//!
36//! | role            | system tables     | app tables              |
37//! |-----------------|-------------------|-------------------------|
38//! | SuperAdmin      | view/create/edit/delete | view/create/edit/delete |
39//! | Admin           | view              | view/create/edit/delete |
40//! | Editor          | view              | view/create/edit        |
41//! | Viewer          | view              | view                    |
42
43use std::fmt;
44
45/// Framework-owned roles. `#[non_exhaustive]` because we may introduce
46/// additional built-in tiers (e.g. `Auditor`) in a minor release;
47/// downstream code must not rely on exhaustive matching.
48#[non_exhaustive]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum Role {
51    /// Unrestricted access. The legacy `"admin"` role value resolves to
52    /// this tier for back-compat.
53    SuperAdmin,
54    /// Full read/write on application models; view-only on framework
55    /// system tables (`rustio_users`, sessions, audit log).
56    Admin,
57    /// View / create / edit on application models; no delete anywhere;
58    /// view on system tables.
59    Editor,
60    /// View-only on every accessible model.
61    Viewer,
62}
63
64/// The four actions a permission gates. Mapped one-to-one to the
65/// fields of [`PermissionSet`]; kept as a typed enum so handlers can
66/// `rbac::require(ctx, "posts", Permission::Delete)?` without stringly
67/// typing.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Permission {
70    View,
71    Create,
72    Edit,
73    Delete,
74}
75
76/// Boolean flags for the four actions on a single model. Passed into
77/// the template context so the UI can hide or disable controls the
78/// user can't use. Also consulted by handlers before they act.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
80pub struct PermissionSet {
81    pub view: bool,
82    pub create: bool,
83    pub edit: bool,
84    pub delete: bool,
85}
86
87impl PermissionSet {
88    pub const NONE: Self = Self {
89        view: false,
90        create: false,
91        edit: false,
92        delete: false,
93    };
94    pub const VIEW_ONLY: Self = Self {
95        view: true,
96        create: false,
97        edit: false,
98        delete: false,
99    };
100    pub const NO_DELETE: Self = Self {
101        view: true,
102        create: true,
103        edit: true,
104        delete: false,
105    };
106    pub const ALL: Self = Self {
107        view: true,
108        create: true,
109        edit: true,
110        delete: true,
111    };
112
113    pub fn allows(&self, action: Permission) -> bool {
114        match action {
115            Permission::View => self.view,
116            Permission::Create => self.create,
117            Permission::Edit => self.edit,
118            Permission::Delete => self.delete,
119        }
120    }
121}
122
123impl Role {
124    /// Parse the stored `rustio_users.role` column value. Returns
125    /// `None` for values that grant no admin access (empty, `"user"`,
126    /// or an unknown string). Case-insensitive; accepts a few
127    /// reasonable aliases (`super_admin`, `super-admin`).
128    pub fn from_role_string(s: &str) -> Option<Role> {
129        let normalised = s.trim().to_ascii_lowercase();
130        match normalised.as_str() {
131            "superadmin" | "super_admin" | "super-admin" => Some(Role::SuperAdmin),
132            // Legacy: pre-0.10 had a single "admin" tier with full
133            // power. Resolve to SuperAdmin so no project is silently
134            // downgraded by the upgrade.
135            "admin" => Some(Role::SuperAdmin),
136            // New in 0.10: restricted-admin tier. Use this string when
137            // you want the `Role::Admin` matrix rather than the
138            // legacy-full-power behaviour.
139            "restricted_admin" | "restricted-admin" => Some(Role::Admin),
140            "editor" => Some(Role::Editor),
141            "viewer" => Some(Role::Viewer),
142            _ => None,
143        }
144    }
145
146    /// Canonical wire string for the role, matching the values
147    /// [`Self::from_role_string`] produces. Note this is **not**
148    /// lossless with the legacy mapping — `Role::SuperAdmin` serialises
149    /// to `"superadmin"`, while the legacy value `"admin"` also parses
150    /// back as `Role::SuperAdmin`.
151    pub fn as_str(self) -> &'static str {
152        match self {
153            Role::SuperAdmin => "superadmin",
154            Role::Admin => "restricted_admin",
155            Role::Editor => "editor",
156            Role::Viewer => "viewer",
157        }
158    }
159
160    /// Human-readable label for the role, suitable for rendering in the
161    /// admin header. Unlike [`Self::as_str`], this is purely cosmetic.
162    pub fn display_name(self) -> &'static str {
163        match self {
164            Role::SuperAdmin => "Super Admin",
165            Role::Admin => "Admin",
166            Role::Editor => "Editor",
167            Role::Viewer => "Viewer",
168        }
169    }
170
171    /// Resolve the permission matrix for this role on a specific model
172    /// table. `model_table` is the raw SQL table name as it appears in
173    /// the schema (e.g. `"posts"`, `"rustio_users"`). Unknown models
174    /// get the same defaults as any app table — the caller is
175    /// responsible for 404-ing models the schema doesn't know about.
176    pub fn permissions_for(self, model_table: &str) -> PermissionSet {
177        let system = is_system_table(model_table);
178        match (self, system) {
179            (Role::SuperAdmin, _) => PermissionSet::ALL,
180            (Role::Admin, true) => PermissionSet::VIEW_ONLY,
181            (Role::Admin, false) => PermissionSet::ALL,
182            (Role::Editor, true) => PermissionSet::VIEW_ONLY,
183            (Role::Editor, false) => PermissionSet::NO_DELETE,
184            (Role::Viewer, _) => PermissionSet::VIEW_ONLY,
185        }
186    }
187
188    /// Shorthand for `permissions_for(table).allows(action)`.
189    pub fn can(self, model_table: &str, action: Permission) -> bool {
190        self.permissions_for(model_table).allows(action)
191    }
192}
193
194impl fmt::Display for Role {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        f.write_str(self.as_str())
197    }
198}
199
200/// Framework tables use the `rustio_` prefix. Project tables never
201/// should — this is a contract the migration generator enforces. We
202/// treat the prefix as authoritative here; projects that smuggle
203/// `rustio_`-prefixed tables into their schema get the restrictive
204/// matrix, which is the safe default.
205pub fn is_system_table(table: &str) -> bool {
206    table.starts_with("rustio_")
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn legacy_admin_resolves_to_super_admin() {
215        assert_eq!(Role::from_role_string("admin"), Some(Role::SuperAdmin));
216        assert_eq!(Role::from_role_string("ADMIN"), Some(Role::SuperAdmin));
217    }
218
219    #[test]
220    fn legacy_user_and_unknown_resolve_to_none() {
221        assert_eq!(Role::from_role_string("user"), None);
222        assert_eq!(Role::from_role_string(""), None);
223        assert_eq!(Role::from_role_string("   "), None);
224        assert_eq!(Role::from_role_string("nobody"), None);
225    }
226
227    #[test]
228    fn canonical_strings_round_trip() {
229        for role in [Role::SuperAdmin, Role::Admin, Role::Editor, Role::Viewer] {
230            assert_eq!(
231                Role::from_role_string(role.as_str()),
232                Some(role),
233                "round-trip failed for {role:?}"
234            );
235        }
236    }
237
238    #[test]
239    fn super_admin_can_do_everything_everywhere() {
240        let r = Role::SuperAdmin;
241        assert_eq!(r.permissions_for("posts"), PermissionSet::ALL);
242        assert_eq!(r.permissions_for("rustio_users"), PermissionSet::ALL);
243        assert_eq!(r.permissions_for("rustio_sessions"), PermissionSet::ALL);
244    }
245
246    #[test]
247    fn admin_cannot_write_system_tables() {
248        let r = Role::Admin;
249        assert_eq!(r.permissions_for("posts"), PermissionSet::ALL);
250        assert_eq!(r.permissions_for("rustio_users"), PermissionSet::VIEW_ONLY);
251        assert!(!r.can("rustio_users", Permission::Delete));
252        assert!(!r.can("rustio_users", Permission::Edit));
253        assert!(r.can("rustio_users", Permission::View));
254    }
255
256    #[test]
257    fn editor_cannot_delete_anywhere() {
258        let r = Role::Editor;
259        assert!(!r.can("posts", Permission::Delete));
260        assert!(r.can("posts", Permission::Edit));
261        assert!(r.can("posts", Permission::Create));
262        assert!(!r.can("rustio_users", Permission::Edit));
263    }
264
265    #[test]
266    fn viewer_only_views() {
267        let r = Role::Viewer;
268        assert_eq!(r.permissions_for("posts"), PermissionSet::VIEW_ONLY);
269        assert_eq!(r.permissions_for("rustio_users"), PermissionSet::VIEW_ONLY);
270    }
271
272    #[test]
273    fn is_system_table_matches_prefix() {
274        assert!(is_system_table("rustio_users"));
275        assert!(is_system_table("rustio_admin_actions"));
276        assert!(!is_system_table("posts"));
277        assert!(!is_system_table("my_rustio_table"));
278    }
279
280    #[test]
281    fn display_name_is_humanised() {
282        assert_eq!(Role::SuperAdmin.display_name(), "Super Admin");
283        assert_eq!(Role::Admin.display_name(), "Admin");
284    }
285
286    #[test]
287    fn permission_set_allows_matches_field() {
288        let ps = PermissionSet::NO_DELETE;
289        assert!(ps.allows(Permission::View));
290        assert!(ps.allows(Permission::Create));
291        assert!(ps.allows(Permission::Edit));
292        assert!(!ps.allows(Permission::Delete));
293    }
294}