This version is in beta. Some features may change before release.

Permissions (RBAC)

Groups, permissions, and content types for umbral. The has_perm query layer.

umbral-permissions ships the role-based access control primitives - ContentType, Permission, Group, and the three M2M tables linking users to permissions both directly and via groups. The has_perm query layer lets any handler check has_perm(user_id, "blog.publish_post") against the union of direct and group-mediated grants. A permission_required(perm) axum layer wraps the lookup so a Router subtree can be gated declaratively without the handler touching has_perm directly.

RLS predicate injection - using permissions to constrain DB rows - is umbral-rls's job; this plugin provides the data the RLS layer reads.

Register the plugin

Code
rust
use umbral::prelude::*;
use umbral_auth::AuthPlugin;
use umbral_permissions::PermissionsPlugin;
 
App::builder()
.plugin(AuthPlugin::default())
.plugin(PermissionsPlugin::default()) // standard perms auto-create on boot
.build()?;

PermissionsPlugin::on_ready walks the registered models and creates four standard permissions per model on first boot: add_<model>, change_<model>, delete_<model>, view_<model>.

Data model

ModelPurpose
ContentTypeOne row per registered Model. Holds app_label (plugin name) and model (lowercased struct name).
PermissionOne row per permission. The primary key is the composite codename string (<app_label>.<verb>_<model>, e.g. blog.add_post) - there is no separate integer id. Carries content_type_id (a ForeignKey<ContentType>) and a human-readable name. The four standard ones auto-create; custom ones land via direct INSERT or future Meta.permissions declarations.

The standard codename is <app_label>.<verb>_<model> (e.g. blog.add_post). The app_label is the model's #[umbral(plugin = "...")] value, surfaced via Model::APP_LABEL; a model with no plugin attribute defaults to "app". Because the label comes from the attribute rather than from splitting the table name, two distinct models never collide on a codename.

| Group | Named permission bundle. Users in a group inherit every permission the group holds. | | UserGroup | M2M: which users belong to which groups. user_id: String (PK-agnostic). | | UserPermission | M2M: direct grants that bypass groups. user_id: String. Use sparingly. |

Plus one framework-managed M2M junction:

Junction tableBacking field
permissions_group_permissionsGroup.permissions: M2M<Permission> - the typed M2M replaces the old GroupPermission model.

Why user_id is String, not i64 or ForeignKey<U>

The plugin doesn't own a User struct. Different apps wire different user models - some with i64 PKs, some with uuid::Uuid, some with slug-style String. Storing the column as TEXT (max 64 chars - covers UUIDs at 36 and i64-as-string at 20) lets any PK type round-trip. The trade-off vs ForeignKey<U>: deleting a user does NOT cascade-delete their UserGroup/UserPermission rows. Apps that need that guarantee add a per-user cleanup hook in their user-delete path.

Checking permissions

Code
rust
use umbral_permissions::{has_perm, user_perms};
 
// One specific permission. `&user.id().to_string()` for an i64 PK;
// for a Uuid PK the same `.to_string()` gives the canonical
// hyphenated form. The perm layer takes `&str` - pass whatever
// stringified shape your user PK lands in.
if !has_perm(&user.id().to_string(), "blog.publish_post").await? {
return Err((StatusCode::FORBIDDEN, "missing publish permission").into());
}
 
// All permissions a user has (for caching in a request scope)
let perms: HashSet<String> = user_perms(&user.id().to_string()).await?;
if perms.contains("blog.publish_post") && perms.contains("blog.delete_post") {
// ...
}

has_perm runs two queries: a direct-grant lookup on permissions_userpermission, and (if no direct match) a group-mediated check that asks the auto-generated permissions_group_permissions junction whether any of the user's groups holds the codename. The group-mediated half is one round-trip regardless of group count via the macro-emitted Group::permissions_contains_any helper.

Superuser shortcut

has_perm does NOT read is_superuser from the user row - this crate doesn't depend on umbral-auth (see "Design notes" below). The caller is responsible for the superuser bypass at the handler level:

Code
rust
if user.is_superuser() {
// skip the has_perm check entirely
} else if !has_perm(&user.id().to_string(), "blog.publish_post").await? {
return Err(StatusCode::FORBIDDEN.into());
}

Or use the convenience wrapper:

Code
rust
use umbral_permissions::has_perm_for_superuser;
 
let allowed = has_perm_for_superuser(
&user.id().to_string(),
user.is_superuser(),
"blog.publish_post",
).await?;

In practice you'd wrap this in a helper function on your app side.

Custom permissions

Standard permissions auto-create. Custom ones (e.g., "blog.feature_post" for a non-CRUD action) need a manual INSERT, typically in a data migration:

Code
rust
use umbral_permissions::{Permission, ContentType};
use umbral::orm::ForeignKey;
 
let ct = ContentType::objects()
.filter(content_type::APP_LABEL.eq("blog"))
.filter(content_type::MODEL.eq("post"))
.get().await?;
 
Permission::objects().on(&pool).create(Permission {
// The primary key is the composite codename string `<app_label>.<name>`
// (there is no separate `id` field - gap #60).
codename: "blog.feature_post".to_string(),
content_type_id: ForeignKey::new(ct.id),
name: "Can feature post on homepage".to_string(),
}).await?;

Design notes

Why has_perm is a free function, not a UserModel trait method

Putting has_perm on the UserModel trait would require umbral-auth to call into umbral-permissions (to query permissions) and umbral-permissions to read UserModel from umbral-auth (for the receiver type). That's a circular crate dependency Cargo refuses to compile.

The clean resolution: has_perm(user_id: &str, codename: &str) is a free function that takes the stringified user ID. The dependency arrow stays one-way (umbral-permissionsumbral-auth would be needed if at all, but isn't). Apps using a custom user model work without adapters - including ones with UUID, slug, or any other PK type, because the &str parameter accepts whatever stringified form the caller passes.

Three different M2M shapes, three different reasons

  • Group.permissions: M2M<Permission> - a framework-managed M2M field on the typed Group model. The migration engine auto-generates the permissions_group_permissions junction (with the right typed PK columns for Group's i64 PK and Permission's String codename PK); the macro emits typed helpers Group::permissions_contains_any / Group::permissions_union_for so application code never has to spell the junction name. This is what the perm-query layer's group-mediated check rides on.
  • UserGroup / UserPermission - explicit join models, kept because the plugin is user-agnostic (no User struct to attach M2M<...> to) and user_id needs to stay a string to round-trip any PK type. ForeignKey<Group> / ForeignKey<Permission> on the join row sides carry their typed FK guarantees.

Gating routes with permission_required

Mount the layer on any Router subtree to require a specific permission for every route inside it. The layer resolves the session cookie, looks up the user id, and calls has_perm - no handler-level boilerplate.

Code
rust
use umbral::prelude::*;
use umbral_permissions::{permission_required, permission_required_html};
 
// REST subtree - 401 JSON if not logged in, 403 JSON if logged-in but lacks perm.
let api = Router::new()
.route("/api/posts/{id}/publish", post(publish_handler))
.layer(permission_required("blog.publish_post"));
 
// HTML subtree - 302 to /login?next=... if not logged in; 403 JSON on no-perm.
let admin = Router::new()
.route("/admin/posts/{id}/publish", post(publish_handler))
.layer(permission_required_html("blog.publish_post", "/login"));

Superuser bypass

When the layer fires, it first checks whether the row in auth_user has is_superuser = 1 - superusers pass every permission_required check without consulting the perm tables. This is best-effort and silent: custom user models that don't carry an is_superuser column simply never trigger the bypass. If you want explicit superuser-only gates instead, use a separate is_superuser_required() middleware (not shipped at v1; the convention is to add a LoggedIn<U> extractor inside the handler that returns 403 when !user.is_superuser()).

Composition with login_required

permission_required does the auth check itself, so you can stack it on top of login_required_html("/login") for a single composed pipeline, or use it standalone. The rejection responses are designed to play together: an unauthenticated request to a permission-gated HTML subtree gets the same 302 → /login?next=<uri> shape that LoginRequiredLayer would produce.

Code
rust
// Two-layer pipeline: login first, then perm. Either misstep produces the
// right user-facing response without the handler being involved.
let gated = Router::new()
.route("/admin/posts/{id}/publish", post(publish_handler))
.layer(permission_required("blog.publish_post"))
.layer(login_required_html("/login"));

In practice the single-layer form permission_required_html(perm, login_url) is usually enough - it captures both concerns in one place.

What's not shipped at v0.0.1

  • Built-in admin UI for editing group → permission and user → group assignments. The RBAC tables (permissions_contenttype, permissions_permission, permissions_group, permissions_usergroup, permissions_userpermission, plus the framework-managed permissions_group_permissions M2M junction) are visible to the admin as standard models; an assignment-matrix UI (a permission-grid widget) lands in a follow-up.
  • Meta.permissions = [...] syntax for declaring custom perms on a model - custom perms ship via direct INSERT in a data migration today.
  • RLS predicate injection from permissions - that's umbral-rls's responsibility.
  • Object-level permissions (per-row grants) - this would land as a follow-up crate.

See also

pluginsauthpermissionsrbac