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}