use serde::Serialize;
use umbral::web::{IntoResponse, Response, StatusCode};
use umbral_auth::AuthUser;
#[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",
}
}
}
fn codename(plugin: &str, table: &str, action: Action) -> String {
format!("{plugin}.{verb}_{table}", verb = action.codename_verb())
}
pub(crate) fn permissions_installed() -> bool {
if !umbral::migrate::is_initialised() {
return false;
}
umbral::migrate::registered_plugins()
.iter()
.any(|p| p == "permissions")
}
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();
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
})
}
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())
}
}
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
})
}
pub(crate) async fn require_codename(user: &AuthUser, codename: &str) -> Result<(), Response> {
require_codename_msg(user, codename, "umbral-admin: permission denied").await
}
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())
}
}
#[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 {
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)),
}
}
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() {
assert_eq!(
codename("user_mgmt", "auth_user", Action::Change),
"user_mgmt.change_auth_user"
);
}
#[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"
);
}
#[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"
);
}
#[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);
}
#[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);
}
#[test]
fn from_codenames_does_not_bleed_across_models() {
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", "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"
);
}
#[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"
);
}
#[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"
);
}
}