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
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
| Model | Purpose |
|---|---|
ContentType | One row per registered Model. Holds app_label (plugin name) and model (lowercased struct name). |
Permission | One 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 table | Backing field |
|---|---|
permissions_group_permissions | Group.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
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:
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:
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:
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-permissions → umbral-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 typedGroupmodel. The migration engine auto-generates thepermissions_group_permissionsjunction (with the right typed PK columns for Group'si64PK and Permission'sStringcodename PK); the macro emits typed helpersGroup::permissions_contains_any/Group::permissions_union_forso 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 attachM2M<...>to) anduser_idneeds 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.
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.
// 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-managedpermissions_group_permissionsM2M 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
umbral-authplugin docs - theUserModeltrait and theLoggedIn<U>extractor this composes with.umbral-rlsplugin docs - row-level security; reads permissions for predicate injection.- Web → Auth gating -
login_requiredpatterns.