navree

Authorization

Roles, permissions and CASL-compatible abilities, checked on routes, on loaded records and in SQL list filters, and shared with the front end.

On this page

Authentication tells you who is calling; authorization decides what they may do. navree-access combines Laravel-style roles and permissions with CASL-style abilities. You write the rules once, in Rust, and Navree uses them in four places:

  • on routes, before the handler runs (#[can(Update, Post, from = "id")]);
  • on a loaded record, in your services;
  • in SQL, so a list returns exactly the rows the user may see;
  • in the browser, where @casl/ability hides the buttons a user can't use. The API still checks every request.

Setting up

Featureaccess The access feature of the navree facade (part of the default full feature) brings navree-access. Add AccessModule to your app root with your abilities function and, optionally, a super-admin role:

src/main.rs

AccessModule is global and needs DatabaseModule. It provides AccessService (manage roles and permissions) and AbilityFactory (each user's compiled abilities), and it registers navree-http's route authorizer, which checks #[roles], #[permissions] and #[can]. Routes that declare those need the module: without it the app refuses to boot. A module that imports plain AccessModule reuses this configured instance.

The migration set access creates the tables roles, permissions, role_permissions, user_roles, user_permissions and access_meta. It runs after auth.

Concepts

TermMeaning
SubjectA type of thing, named in PascalCase: Post, Deal. An entity becomes a subject by implementing Subject.
Actionread, create, update, delete, manage (any action), or your own: publish, move.
RuleAllows (can) or denies (cannot) an action on a subject, optionally only where conditions on the record hold and only for some fields.
AbilityOne user's rules, in order. Navree builds it from your abilities function.
Role, permissionStored in the database and managed at run time. A permission such as posts.publish becomes a rule.

Abilities use the rule model of CASL: the same rules, in the same JSON, mean the same thing in Rust and in the browser.

Subjects

Implement Subject for each entity you want to check, and register it with AccessModule::for_feature in its module:

src/modules/posts/entity.rs
src/modules/posts/mod.rs

for_feature maps the permission prefix to the subject, and lets routes load the entity's records by primary key (a UUID, an integer or a string). A subject that isn't an entity, such as Report, registers with AccessModule::for_subject::<S>() for its permission prefix alone. Users are a subject too: navree::auth::user::Entity is User, with the prefix users. Import AccessModule::for_feature::<navree::auth::user::Entity>() to check routes against users and to make users.* permissions apply.

Defining abilities

The abilities function receives the user's context and adds rules:

src/abilities.rs

AbilityCtx holds:

Field
userSome(AccessUser { id, roles }) for a signed-in user (has_role, has_any_role); None for guests
permissionsThe user's permission strings, through their roles and granted directly, sorted
team_idAlways None: teams aren't implemented yet
permission_subjectsThe permission prefix of every registered subject, for grant_permissions

The function is a plain fn(&AbilityCtx, &mut Rules): it can't capture state or query the database. Everything a rule decides on must be a role, a permission, or a column of the record being checked.

A custom action is a string, plus a marker type for route attributes and extractors:

src/modules/posts/mod.rs

The built-in actions have constants (READ, CREATE, UPDATE, DELETE, MANAGE), marker types (Read, Create, Update, Delete, Manage) and ALL, the subject that matches every subject. All are in the prelude.

The last matching rule wins

A check scans the rules from last to first, and the first rule that matches the action, the subject, the field and the record decides. No matching rule means no. So put cannot rules after the can rules they restrict: in the example, an author may delete their own posts, except locked ones.

Rule partMatches
can, cannotAllows, or denies
ActionThe action, or any action when the rule says manage
SubjectThe subject, or any subject when the rule says all
.when(..)Records whose columns satisfy the conditions
.fields(..)Checks of those fields (see Fields)
.because(..)Nothing: a reason that access:explain and the front end can show

A check without a record is a type-level check: "may this user update some post?". It follows CASL: a conditional can rule counts as a match, because some records may be allowed, and a conditional cannot rule is skipped. ability.can(UPDATE, "Post", None) is therefore true for an author, although they may update only their own posts.

Rules::build panics on an invalid rule (an unsupported operator, say), because rules are code. Rules::try_build returns the RuleError instead. In the module, an invalid rule makes every request of that user a 500, so test your abilities function (see Testing).

Conditions

Conditions compare the record's columns with values:

Rust
MethodCASL operator
eq, ne$eq, $ne
lt, lte, gt, gte$lt, $lte, $gt, $gte
is_in([..]), not_in([..])$in, $nin
  • Conditions combine with .and(..), and several .when(..) calls on one rule combine the same way. There is no $or: write two rules.
  • Only top-level columns work. Nested fields (author.id), $exists, $regex and the other MongoDB operators are rejected.
  • Values can be booleans, integers, floats, strings, Uuids, DateTime<Utc>s, serde_json::Values and Options, where None is null. Integers must be safe in JavaScript (at most 2^53 − 1); pass larger ones as strings.
  • Types are never converted: 7 doesn't equal "7". UUIDs compare as hyphenated lower-case strings, and timestamps as RFC 3339 UTC strings with microseconds, so text order is time order.
  • eq(None) matches null. ne(value) matches null columns too, like SQL's IS DISTINCT FROM. is_in and not_in match null only through, or unless, a null in the list. Range operators never match null.

Fields

.fields([..]) limits a rule to some fields. In the example, an author may update the title and body of their own posts, and nothing else. A field-limited rule changes field checks only:

  • ability.can(UPDATE, "Post", Some(&post)) is true: the author may update the post;
  • ability.can_field(UPDATE, "Post", Some(&post), "author_id") is false: they may not change its author.

A field-limited cannot rule never denies a whole-record check, only checks of its fields, as in CASL. ability.permitted_fields(action, subject, record, &all_fields) lists the allowed ones. In a handler, Authorized::check_fields turns the check into a 403:

Rust
JSON

Roles and permissions

Permission strings

A permission is a string <prefix>.<action>. ctx.grant_permissions(rules) adds one unconditional can rule for each permission the user has:

PermissionRule
posts.publishcan publish Post (when a subject has the prefix posts)
posts.*can manage Post: every action
audit.readcan read audit: a prefix no subject claims stands for itself

The last row is how framework permissions work: the audit log route requires audit.read, and the role management routes require roles.manage. Grant them to a role like any other permission.

Without an abilities function, AccessOptions::default() uses default_abilities, which grants the user's permissions and nothing else.

A permission has at most 128 characters: ASCII letters, digits, _, -, ., : and *, starting with a letter or a digit, with a non-empty prefix and action. A role name has at most 64 of the same characters, without *.

Managing roles in code

AccessService manages roles. Writes take the unit of work to run in: a request's Uow, or one from db.uow(..) in jobs, seeders and commands.

Rust
Method
create_role(&uow, name, description), delete_role(&uow, name)Deleting a role removes its grants and assignments
grant(&uow, role, permission), revoke(&uow, role, permission)Change one permission of a role
sync_role_permissions(&uow, role, permissions)Make the role's permissions exactly these
assign_role(&uow, user, role), remove_role(&uow, user, role)Change one role of a user
sync_user_roles(&uow, user, roles)Make the user's roles exactly these
grant_to_user(&uow, user, permission), revoke_from_user(&uow, user, permission)Permissions given to a user directly
role(name), roles(), roles_of(user), permissions_of(user)Read roles and permissions (RoleInfo has id, name, description, permissions, created_at)
explain(user, action, subject, id)The rule that decides a check; see Explaining a decision

The grant, revoke, assign and remove methods return false when there was nothing to change, and do nothing else, so seeders can run again safely. The CRM's crm-roles seeder creates the missing roles and calls sync_role_permissions for each.

Each change that does something:

  • bumps the access version in the same transaction, so every instance rebuilds its cached abilities on the next request (see Caching);
  • writes an access.* entry to the audit log, when the app has one, once it commits;
  • ends the real-time subscriptions of the users it affects (the user whose roles or direct permissions changed, or the members of a role that lost permissions), so they must authorize their private channels again.

Console commands

Shell
Text

access:role <name> [description] and access:grant <role> <permission> say so when the role or the permission already exists. access:explain <user> <action> <subject> [id] explains a type-level check, or a record check when you give an id.

The management routes

AccessModule mounts routes to manage roles over HTTP, under /api/access. Each requires the permission roles.manage:

RouteBodySuccess
GET /api/access/roles200: {"data": [roles with their permissions]}
POST /api/access/roles{"name", "description"?}201: the role
PUT /api/access/roles/{role}/permissions{"permissions": [..]}200: the role
DELETE /api/access/roles/{role}204
PUT /api/access/users/{user}/roles{"roles": [..]}200: {"user_id", "roles"}
JSON

An unknown role is a 404 ("The role x does not exist."), an existing name a 409, and an invalid name or an unknown role in a body a 400 validation error.

A super-admin role

With super_admin_role: Some("admin".to_owned()), users with the admin role get one more rule, can manage all, after the rules your function defines. Being last, it beats every cannot. They also pass every #[roles(..)] check.

The role is an ordinary role: create it and assign it like any other. The CRM makes admin its super-admin role, and its abilities function adds nothing for admins, so an admin's rules are only:

JSON

Checking routes

Route attributes declare who may call a route; Access control on routes covers them in full. Every declaration except #[public] requires a signed-in user first.

AttributePasses when
#[roles("admin", "editor")]The user has any of the roles (the super-admin role always passes)
#[permissions("posts.export")]An unconditional rule grants the permission's action on its subject. Conditional rules don't count
#[can(Create, Post)]The type-level check passes: the user may create some posts
#[can(Update, Post, from = "id")]The post whose id is the id path parameter exists and the rules allow updating it

A refusal is 403 Forbidden, and with an audit log in the app it is recorded as an access.denied security entry naming the check, the route and the user:

JSON

A 403 that a handler returns itself (from check_fields, say) is recorded too, with "check": "handler". Hand-written routes use .roles(&[..]), .permissions(&[..]) and .can("update", "Post", Some("id")) on RouteDef.

Guests never pass #[can], even when a rule allows guests: make such a route #[public] and filter what it returns with Accessible, as the posts index below does.

Record checks

Authorized

With from = "id", the route authorizer loads the record before the handler runs and hands it over as Authorized<E, A>:

src/modules/posts/controller.rs

For a from route, the authorizer:

  1. runs the type-level check;
  2. loads the record by its primary key in the request's unit of work, with SELECT ... FOR UPDATE on PostgreSQL (on SQLite the write transaction already serialises writers). The test suite runs on SQLite, so the FOR UPDATE load hasn't run against PostgreSQL yet;
  3. answers 404 when there is no such record, or when the id can't be one (not a UUID for a UUID key, say);
  4. checks the rules against the record, and answers 403 when they say no;
  5. keeps the record for the handler.

Authorized<E, A> dereferences to the model and has into_inner(), model(), record() (the JSON the conditions saw), ability() and check_fields(..). The extractor must match the route: Authorized<Post, Update> on a route without #[can(Update, Post, from = ..)] is a 500 that says so. So is a from route for a subject registered without for_feature.

Conditions see the record as JSON with one member per column, named like the column. UUIDs are hyphenated lower-case strings, timestamps RFC 3339 UTC strings with microseconds, and dates YYYY-MM-DD; navree::access::record_json(&model) builds the same JSON.

Checks in services

Some decisions depend on the input as well as the record. The Abilities extractor gives the user's compiled rules (Arc<Ability>), and Authorized::ability() the same; check them with can:

Rust

The CRM's deal service does this when a PATCH changes a deal's stage, which needs move as well as update:

apps/crm-api/crates/crm-deals/src/service.rs

CurrentAccess gives more: the user's roles, permissions, access version, whether they are the super_admin, and the ability. Both extractors compute the user's access once per request, shared with the route authorizer.

List filters in SQL

A list endpoint must return only the rows the user may see, and it must agree with the record check, or users see records they then can't open. Accessible<E, A> compiles the same rules into one SQL CASE expression, so a row is listed exactly when can(A, E, row) would be true. Apply it to a SeaORM Select, as the index route above does. The CRM lists deals the same way:

apps/crm-api/crates/crm-deals/src/service.rs

The CASE lists the conditional rules from last to first, so its first WHEN that holds is the rule a record check would pick. For a CRM sales rep deleting deals, the filter is:

SQL

An unconditional rule turns into the ELSE (a rep reads every deal, so their read filter is TRUE), and an action with no rules into FALSE. The filter never yields NULL, so it is safe to negate.

  • Columns are qualified with the entity's table, and values are bound with the column's type, so UUID and timestamp columns compare like the stored values. A rule field that isn't a column of the entity is SQL NULL, as it is null in a record check.
  • The SQL is written for PostgreSQL and for SQLite 3.39 or newer. The tests run it on SQLite and only check the PostgreSQL form as generated text. String comparisons agree with record checks under binary collation: SQLite's default, and COLLATE "C" on PostgreSQL.
  • Accessible::condition() returns the expression for queries you build yourself, and ability.sql_filter_with(action, subject, &columns) takes your own SqlColumns mapping (for joins or renamed columns). ability.sql_filter(action, subject) uses bare column names.

Abilities in the front end

GET /api/me/abilities returns the signed-in user's rules in CASL's JSON format, which createMongoAbility in @casl/ability accepts as is. For the author in the posts example:

HTTP

The ETag is "<user>-<access version>-<hash of the rules>": it changes with any role or permission change, and when a deploy changes the rules. Send it back in If-None-Match to get a 304 Not Modified.

The CRM's front end builds its ability from that array. Every API resource carries its subject type in __type ("Deal", "User"), so CASL can check API records directly:

apps/crm-web/src/lib/casl/ability.ts
apps/crm-web/src/app/(app)/companies/[id]/company-detail.tsx

The browser's checks only decide what to show. Navree differs from @casl/ability in a few corners, which can only affect the UI because the API checks again:

  • a column the record lacks is null to Navree and undefined to CASL, which $eq: null doesn't match;
  • range operators never match null or a value of another type in Navree, where JavaScript would compare them;
  • strings order by code point in Navree, by UTF-16 code unit in JavaScript.

Ability::to_casl_json() and Ability::from_casl_json(..) convert rules yourself. Parsing accepts only the operators listed under Conditions.

Tokens with limited abilities

A custom authentication strategy can limit what a request may do by setting token_abilities on the Principal it returns: a list of permission strings such as ["deals.read"]. The user's ability is then narrowed to the rules that grant those permissions; "*" keeps everything, and a malformed entry grants nothing. Rules keep their order and conditions, so a token never grants more than its user has. Access tokens from the JWT strategy are not limited.

Caching and consistency

Each request reads the access version (one small query). A user's roles, permissions and compiled rules are cached in memory under their id and that version, for up to 10,000 users, and dropped after 10 minutes without use. Every change made through AccessService bumps the version in the same transaction as the change, so once it commits every instance rebuilds the abilities on the next request. No cache messages pass between instances.

Rows changed in the database directly don't bump the version, so the cache won't notice them. Go through AccessService, or call AbilityFactory::clear_cache(), which clears only the current process. A change to the abilities function takes effect when the app restarts.

Explaining a decision

access:explain and AccessService::explain show the rule that decides a check, with its number in definition order:

Text
Text

explain returns an Explanation with allowed, the deciding rule and the user's roles; it prints as above. ability.relevant_rule(action, subject, record, field) gives the deciding rule in code.

The CRM's abilities

The reference CRM has three roles: admin (the super-admin role), manager and rep. Managers hold six permissions (companies.manage, contacts.manage, contacts.export, deals.manage, activities.manage and users.read); reps and admins hold none. Everything else is in its abilities function:

apps/crm-api/crates/crm-core/src/abilities.rs
  • Everyone with a CRM role reads everything and creates records. Reps change and delete only what they own (owner_id), and complete the activities they own or are assigned.
  • Managers get manage on every customer record through their permissions, so the owner conditions don't limit them.
  • Won and lost deals are kept for reporting. The last rule stops even a manager from deleting one; only the admin's manage all, added after it, beats it. Reps can't move a closed deal either, or they could reopen it and then delete it.
  • Users without a CRM role, and guests, get no rules at all.

The deal routes check these rules with #[can], and the lists filter with Accessible:

apps/crm-api/crates/crm-deals/src/controller.rs

Move, Export and Complete are the CRM's custom action markers, in crm-core/src/actions.rs. The last two rules a rep's front end receives:

JSON

A tour of the CRM shows these rules at work in the app.

Errors

AccessErrors returned from handlers become these responses:

ErrorResponse
RoleNotFound404 "The role x does not exist."
RecordNotFound404
RoleExists409 "The role x already exists."
UnknownRoles400, a field error on roles
InvalidName400, a field error on the field
DbAs database errors map (see Errors)
Rules, Config500

Testing

Test your abilities function as a plain function, with a context for each kind of user:

src/abilities.rs

AbilityCtx::guest() builds a guest's context. The super-admin rule is added by the module, not by your function, so add rules.can(MANAGE, ALL) yourself to test it. The CRM's crm-core/src/abilities.rs checks a table of every action, subject and role this way.

For HTTP tests, give the test user roles through AccessService and send requests as them: with TestApp::acting_as when the app has no AuthModule, or with a real token (see Authentication). See HTTP tests.

Limits

  • Conditions are ANDed comparisons on the record's own columns: no $or, no nested fields, no relations.
  • The abilities function can't read the database or other services.
  • Roles are global. Teams and per-team roles are not implemented.
  • The OpenAPI document has no schemas for GET /api/me/abilities and the /api/access routes.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks