umbral-admin 0.0.12

Auto-generated CRUD admin UI for umbral models.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Per-model permission checks for the admin handlers.
//!
//! Feature #75. Bridges `umbral-permissions::has_perm_for_superuser`
//! into the admin's handler + template surface so an editor without
//! `<plugin>.change_<model>` can't reach the edit form (via direct URL)
//! and never sees the Edit / Delete / Save buttons (via the template
//! ctx).
//!
//! **Graceful no-op when permissions aren't installed.** If
//! `PermissionsPlugin` is not registered with the framework, every
//! check returns `true` so the admin reverts to pre-#75 behaviour
//! (staff-only via `require_staff`). The opt-in is "install the
//! permissions plugin"; nothing in `AdminPlugin` needs flipping.
//!
//! Codename convention follows the permissions plugin's
//! `add_<model>` / `change_<model>` / `delete_<model>` / `view_<model>`
//! auto-creation, scoped by the plugin name that registered the model
//! (e.g. `"blog.change_post"`).

use serde::Serialize;
use umbral::web::{IntoResponse, Response, StatusCode};
use umbral_auth::AuthUser;

/// CRUD actions the admin enforces. Matches the four standard
/// permissions `PermissionsPlugin::on_ready` auto-creates per model.
#[derive(Debug, Clone, Copy)]
pub(crate) enum Action {
    View,
    Add,
    Change,
    Delete,
}

impl Action {
    fn codename_verb(self) -> &'static str {
        match self {
            Self::View => "view",
            Self::Add => "add",
            Self::Change => "change",
            Self::Delete => "delete",
        }
    }
}

/// Compute the codename string the permissions plugin keys against —
/// `"<plugin>.<verb>_<table>"`. Lives here so the templating layer and
/// the handler layer agree on the exact key.
fn codename(plugin: &str, table: &str, action: Action) -> String {
    format!("{plugin}.{verb}_{table}", verb = action.codename_verb())
}

/// Returns `true` when the permissions plugin is registered with the
/// framework. Read once per request from the in-memory plugin list, so
/// the cost is a single Vec scan with no I/O.
///
/// Returns `false` (not installed → allow) when the model registry
/// hasn't been initialised yet, which happens in unit tests that never
/// call `App::build()`.
pub(crate) fn permissions_installed() -> bool {
    if !umbral::migrate::is_initialised() {
        return false;
    }
    umbral::migrate::registered_plugins()
        .iter()
        .any(|p| p == "permissions")
}

/// Run one permission check for `(plugin, table, action)`. Returns
/// `true` when:
///   - permissions plugin isn't installed (no-op fallback);
///   - the user is a superuser;
///   - the user has the codename either directly or via a group.
pub(crate) async fn check(user: &AuthUser, plugin: &str, table: &str, action: Action) -> bool {
    if !permissions_installed() {
        return true;
    }
    let perm = codename(plugin, table, action);
    let user_id = user.id.to_string();
    // A DB-layer error here would be the permissions tables missing or
    // a transient pool issue. Either way the safe behaviour is "deny" —
    // refusing a 403 over leaking write access to an unprivileged user.
    umbral_permissions::has_perm_for_superuser(&user_id, user.is_superuser, &perm)
        .await
        .unwrap_or_else(|err| {
            tracing::warn!(
                user_id = user_id.as_str(),
                perm = perm.as_str(),
                error = %err,
                "permission check failed; denying by default"
            );
            false
        })
}

/// Handler-side guard. Returns a 403 [`Response`] when the user lacks
/// the required permission, otherwise `Ok(())` so the caller can `?`
/// the result on a single line.
pub(crate) async fn require(
    user: &AuthUser,
    plugin: &str,
    table: &str,
    action: Action,
) -> Result<(), Response> {
    if check(user, plugin, table, action).await {
        Ok(())
    } else {
        Err((StatusCode::FORBIDDEN, "umbral-admin: permission denied").into_response())
    }
}

/// Check an arbitrary permission codename directly (not the
/// `(plugin, table, action)` triple) — used by custom admin views, which
/// aren't model-bound. Returns `true` when permissions aren't installed
/// (staff-only baseline), the user is a superuser, or the user holds the
/// codename directly / via a group.
pub(crate) async fn has_codename(user: &AuthUser, codename: &str) -> bool {
    if !permissions_installed() {
        return true;
    }
    let user_id = user.id.to_string();
    umbral_permissions::has_perm_for_superuser(&user_id, user.is_superuser, codename)
        .await
        .unwrap_or_else(|err| {
            tracing::warn!(
                user_id = user_id.as_str(),
                perm = codename,
                error = %err,
                "codename permission check failed; denying by default"
            );
            false
        })
}

/// Handler-side guard for a raw codename. `Ok(())` when allowed, else a
/// 403 [`Response`]. Mirrors [`require`] for the model-bound path.
pub(crate) async fn require_codename(user: &AuthUser, codename: &str) -> Result<(), Response> {
    require_codename_msg(user, codename, "umbral-admin: permission denied").await
}

/// [`require_codename`] with a caller-supplied 403 body. The bulk-action
/// dispatcher reaches for this so its denial names the action explicitly
/// ("permission denied for this action") while still routing through the
/// one codename-check path (no-op when permissions absent, superuser-pass,
/// deny-on-DB-error) — the check must not fork per call site.
pub(crate) async fn require_codename_msg(
    user: &AuthUser,
    codename: &str,
    denied_msg: &'static str,
) -> Result<(), Response> {
    if has_codename(user, codename).await {
        Ok(())
    } else {
        Err((StatusCode::FORBIDDEN, denied_msg).into_response())
    }
}

/// Per-(user, model) permission bundle passed to templates. Serializes
/// as `{can_view, can_add, can_change, can_delete}` so template guards
/// stay declarative: `{% if perms.can_change %}…{% endif %}`.
#[derive(Debug, Clone, Copy, Serialize)]
pub(crate) struct AdminPerms {
    pub can_view: bool,
    pub can_add: bool,
    pub can_change: bool,
    pub can_delete: bool,
}

impl AdminPerms {
    /// Resolve all four flags from a pre-loaded codename set.
    ///
    /// Pure, no I/O — called by [`Self::load`] after the single
    /// `user_perms` query and tested directly in unit tests.
    fn from_codenames(
        codenames: &std::collections::HashSet<String>,
        plugin: &str,
        table: &str,
    ) -> Self {
        Self {
            can_view: codenames.contains(&codename(plugin, table, Action::View)),
            can_add: codenames.contains(&codename(plugin, table, Action::Add)),
            can_change: codenames.contains(&codename(plugin, table, Action::Change)),
            can_delete: codenames.contains(&codename(plugin, table, Action::Delete)),
        }
    }

    /// Probe all four standard actions for one (user, plugin, table)
    /// tuple. Loads the user's permission set once (one DB query) and
    /// resolves all flags in memory — no per-action queries.
    pub(crate) async fn load(user: &AuthUser, plugin: &str, table: &str) -> Self {
        if !permissions_installed() || user.is_superuser {
            return Self {
                can_view: true,
                can_add: true,
                can_change: true,
                can_delete: true,
            };
        }
        let user_id = user.id.to_string();
        let perms = match umbral_permissions::user_perms(&user_id).await {
            Ok(perms) => perms,
            Err(err) => {
                tracing::warn!(
                    user_id = user_id.as_str(),
                    error = %err,
                    "permission set load failed; denying admin model actions by default"
                );
                return Self {
                    can_view: false,
                    can_add: false,
                    can_change: false,
                    can_delete: false,
                };
            }
        };
        Self::from_codenames(&perms, plugin, table)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    #[test]
    fn codename_follows_expected_shape() {
        assert_eq!(codename("blog", "post", Action::View), "blog.view_post");
        assert_eq!(codename("blog", "post", Action::Add), "blog.add_post");
        assert_eq!(codename("blog", "post", Action::Change), "blog.change_post");
        assert_eq!(codename("blog", "post", Action::Delete), "blog.delete_post");
    }

    #[test]
    fn codename_keeps_plugin_dot_table_separation() {
        // Plugin and table names can include underscores; the only
        // structural separators are the literal `.` and `_<table>`.
        assert_eq!(
            codename("user_mgmt", "auth_user", Action::Change),
            "user_mgmt.change_auth_user"
        );
    }

    // -----------------------------------------------------------------------
    // AdminPerms::from_codenames — pure flag resolution, no DB, no async.
    //
    // These tests confirm that the in-memory resolution layer (which is the
    // whole point of the one-query fix: load once, check in memory) maps
    // codenames to the correct boolean flags. The `load` function calls
    // `from_codenames` after the single `user_perms` query, so verifying
    // this function verifies the flag logic without any I/O.
    // -----------------------------------------------------------------------

    /// A user with only `view_<model>` in their codename set gets can_view=true
    /// and the other three flags false. Confirms per-flag granularity.
    #[test]
    fn from_codenames_view_only() {
        let codenames: HashSet<String> = ["blog.view_post".to_string()].into_iter().collect();
        let perms = AdminPerms::from_codenames(&codenames, "blog", "post");
        assert!(
            perms.can_view,
            "expected can_view=true with view_post codename"
        );
        assert!(!perms.can_add, "expected can_add=false without add_post");
        assert!(
            !perms.can_change,
            "expected can_change=false without change_post"
        );
        assert!(
            !perms.can_delete,
            "expected can_delete=false without delete_post"
        );
    }

    /// A user with change + delete but NOT view or add gets exactly those two.
    #[test]
    fn from_codenames_change_and_delete_subset() {
        let codenames: HashSet<String> = [
            "shop.change_product".to_string(),
            "shop.delete_product".to_string(),
        ]
        .into_iter()
        .collect();
        let perms = AdminPerms::from_codenames(&codenames, "shop", "product");
        assert!(!perms.can_view, "no view_product → can_view must be false");
        assert!(!perms.can_add, "no add_product → can_add must be false");
        assert!(
            perms.can_change,
            "change_product present → can_change must be true"
        );
        assert!(
            perms.can_delete,
            "delete_product present → can_delete must be true"
        );
    }

    /// A user with ALL four codenames gets all four flags true.
    #[test]
    fn from_codenames_full_set() {
        let codenames: HashSet<String> = [
            "blog.view_post".to_string(),
            "blog.add_post".to_string(),
            "blog.change_post".to_string(),
            "blog.delete_post".to_string(),
        ]
        .into_iter()
        .collect();
        let perms = AdminPerms::from_codenames(&codenames, "blog", "post");
        assert!(perms.can_view);
        assert!(perms.can_add);
        assert!(perms.can_change);
        assert!(perms.can_delete);
    }

    /// An empty codename set → all flags false.
    #[test]
    fn from_codenames_empty_set_denies_all() {
        let codenames: HashSet<String> = HashSet::new();
        let perms = AdminPerms::from_codenames(&codenames, "blog", "post");
        assert!(!perms.can_view);
        assert!(!perms.can_add);
        assert!(!perms.can_change);
        assert!(!perms.can_delete);
    }

    /// Codenames for a DIFFERENT model in the same plugin do NOT bleed into
    /// the checked model's flags. This guards against accidental prefix matches.
    #[test]
    fn from_codenames_does_not_bleed_across_models() {
        let codenames: HashSet<String> = [
            // `post` perms — should NOT affect `comment` flags
            "blog.view_post".to_string(),
            "blog.add_post".to_string(),
            "blog.change_post".to_string(),
            "blog.delete_post".to_string(),
        ]
        .into_iter()
        .collect();
        let perms = AdminPerms::from_codenames(&codenames, "blog", "comment");
        assert!(
            !perms.can_view,
            "post perm must not bleed into comment.can_view"
        );
        assert!(
            !perms.can_add,
            "post perm must not bleed into comment.can_add"
        );
        assert!(
            !perms.can_change,
            "post perm must not bleed into comment.can_change"
        );
        assert!(
            !perms.can_delete,
            "post perm must not bleed into comment.can_delete"
        );
    }

    /// Codenames from a different plugin don't grant access for the target plugin.
    #[test]
    fn from_codenames_does_not_bleed_across_plugins() {
        let codenames: HashSet<String> = [
            "other_plugin.view_post".to_string(),
            "other_plugin.add_post".to_string(),
        ]
        .into_iter()
        .collect();
        let perms = AdminPerms::from_codenames(&codenames, "blog", "post");
        assert!(
            !perms.can_view,
            "other plugin's perm must not grant blog.view_post"
        );
        assert!(
            !perms.can_add,
            "other plugin's perm must not grant blog.add_post"
        );
    }

    // has_codename / require_codename: when the permissions plugin is NOT
    // installed (the unit-test process), both must allow (staff-only baseline).
    #[tokio::test]
    async fn codename_checks_allow_when_permissions_absent() {
        use chrono::Utc;
        let user = umbral_auth::AuthUser {
            id: 1,
            username: "staff".to_string(),
            email: "staff@example.com".to_string(),
            password_hash: "!".to_string(),
            is_active: true,
            is_staff: true,
            is_superuser: false,
            date_joined: Utc::now(),
            last_login: None,
            email_verified_at: None,
        };
        assert!(
            super::has_codename(&user, "reports.view_sales").await,
            "absent permissions plugin → allow"
        );
        assert!(
            super::require_codename(&user, "reports.view_sales")
                .await
                .is_ok(),
            "require_codename Ok when allowed"
        );
    }
}