navree

Access control on routes

Deny by default: every route declares who may call it with #[public], #[authenticated], #[roles], #[permissions] or #[can], and refusals map to 401, 403 or 404.

On this page

Introduction

In Navree, a route that doesn't say who may call it doesn't exist. Every route declares its access with one or more attributes, and a route without any is a compile error; nothing is public by accident. The declaration is enforced by the request pipeline before your handler runs, so a handler never has to start with "is this user allowed?".

src/modules/reports/controller.rs

The access attributes

AttributeWho may call the routeChecked by
#[public]Anyone, signed in or notNothing: no authentication, no authorization
#[authenticated]Any signed-in userThe pipeline (needs a dyn Authenticator)
#[roles("admin", "manager")]A signed-in user with any of the rolesThe route authorizer
#[permissions("deals.export")]A signed-in user granted every listed permissionThe route authorizer
#[can(Update, Deal)]A signed-in user whose abilities allow the action on the subjectThe route authorizer
#[can(Update, Deal, from = "id")]The same, on the record whose id is the {id} path parameterThe route authorizer

Every attribute except #[public] implies authentication: a guest is refused before any role or ability is looked at. The route authorizer is navree-access's dyn RouteAuthorizer, which AccessModule registers; authorization covers how roles, permissions and abilities are defined.

Public routes

#[public] turns authentication and authorization off for the route. Use it for sign-in and sign-up, health probes, webhooks with their own signature check, and public content. #[public] can't be combined with any other access attribute, on the route or on its impl block: that's a compile error, because a public route that also asks for a role is a contradiction.

A public route still sees the user when there is one: AuthState is set for every request, so a public handler can personalise its answer for signed-in users.

Authenticated routes

#[authenticated] lets any signed-in user through. "Signed in" means the app's authenticator found a principal for the request: with AuthModule, a valid JWT in Authorization: Bearer. The handler can read the principal with CurrentPrincipal, or load the user with CurrentUser; see requests.

Roles

#[roles("admin", "manager")] lets a user through when they have at least one of the roles. Roles are assigned to users in the database by navree-access. The super-admin role (AccessOptions::super_admin_role, admin in the CRM) passes every roles check, and, because it's granted manage all, every permission and ability check too.

Permissions

#[permissions("reports.export", "reports.read")] needs every listed permission. A permission is <prefix>.<action>, and navree-access maps the prefix to its subject: with Report registered under the prefix reports (see below), reports.export means "export on Report", and reports.* means manage. A prefix nobody registered stands for itself: audit.read means "read on audit". The check passes only when the user's abilities allow the action on every record of the subject, with no conditions: a rule like "update your own deals" doesn't satisfy #[permissions("deals.update")]. Use #[can(.., from = "id")] for record-level rules.

Abilities with #[can]

#[can(Action, Subject)] checks the user's abilities, CASL-style. Without from, it's a type-level check: it passes when the user may perform the action on at least some records of the subject, so a conditional rule ("update deals you own") counts. Use it on list and create routes:

crates/crm-deals/src/controller.rs

The Accessible<Deal, Read> extractor turns the same rules into a SQL filter, so the list holds exactly the deals this user may read.

With from = "id", the authorizer loads the record whose primary key is the {id} path parameter, in the request's unit of work, and checks the action against that record. A record that doesn't exist is a 404; a record the user may not touch is a 403. The handler receives the loaded record with the Authorized<E, A> extractor, so it doesn't load it again. This is the CRM's deal update:

crates/crm-deals/src/controller.rs

from must name a parameter of the route's path (the controller prefix counts); otherwise it's a compile error. For the record to load, the entity's module imports AccessModule::for_feature::<Deal>(); see modules.

The action and the subject can be marker types or strings:

  • Actions: the markers Read, Create, Update, Delete and Manage from the prelude, your own types implementing ActionMarker, or a string such as "archive".
  • Subjects: a type implementing Subject (a SeaORM entity or any other type), or a string.
Rust

Register a subject that isn't an entity by importing AccessModule::for_subject::<Report>() in its module, so its permission prefix maps to it:

Rust

The CRM defines its own actions Move, Export and Complete this way (#[can(Move, Deal, from = "id")]). A route checks one ability: a second #[can], including one inherited from the impl block, is a compile error.

Access for a whole controller

Access attributes may also sit on the impl block, below #[controller], and then apply to every route in it. A route's own declarations add to the impl block's:

Rust

The rules for combining:

  • Roles from the impl block and the route form one list; the user needs any one of them.
  • Permissions from both form one list; the user needs all of them.
  • Different kinds add up as "and": a route with roles, permissions and #[can] passes only when all three checks pass. #[authenticated] next to any of them changes nothing, since they all need a user.
  • #[can] may sit on the impl block or on a route, not both.
  • #[public] on the impl block makes every route public, and no route may then declare anything else.

Routes without a declaration

A route that declares none of the five attributes, on itself or its impl block, doesn't compile:

Text

A #[guard] doesn't count as a declaration: a route with only a guard gets the same error, with a note saying so. Guards add checks to a route; they don't say who the route is for. See guards.

Routes written by hand with RouteDef declare access with builder methods instead: .public(), .authenticated(), .roles(&["admin"]), .permissions(&["reports.export"]) and .can("update", "Deal", Some("id")). They can't be checked at compile time, so HttpModule checks every route at boot. A route without a declaration stops the app, and the error lists every undeclared route at once:

Text

What the app needs

The declarations need someone to enforce them. HttpModule checks at boot that they have one:

  • A route that isn't public needs a dyn Authenticator. AuthModule registers one, and TestApp installs a test one when the app has none.
  • A route with roles, permissions or #[can] needs a dyn RouteAuthorizer. AccessModule registers one.

Without them, the boot fails before the server listens, naming each route:

Text

Refusals: 401, 403 and 404

The route pipeline checks access in this order, and the first refusal ends the request with an error in the NestJS format:

SituationStatusBody message
Credentials present but invalid (an expired or forged token), on any route401The authenticator's message, such as "Token expired"
A non-public route and no signed-in user401"Unauthorized"
A route #[throttle] exceeded429"Too Many Requests", with Retry-After
A guard refusedThe guard's errorThe guard's message
The user lacks the roles, the permissions or the ability403"Forbidden"
#[can(.., from = "id")] and no record has that id404"Not Found"

A guest calling GET /api/v1/reports/team gets a 401, and a signed-in user without the role gets a 403:

JSON

The first row matters for clients: an invalid token is refused before routing, so even a #[public] route answers 401 to a request that carries an expired token. Don't send stale tokens to public routes such as sign-in or refresh.

Refusals are recorded. Each 403 from the authorizer is logged on target navree::security, and when the app has an audit trail (AuditModule), it's written as an access.denied security entry, as are 403s returned by guards and by handlers. See the audit log.

Seeing who may call what

The routes command prints each route's declaration in its ACCESS column:

Text

public, auth, roles:a|b (any of), permissions:a,b (all of) and can:<action> <Subject> (with from id for record checks) are the same strings meta.access.describe() returns for a route's RouteMeta (AccessSpec::describe), which makes them easy to assert on in a test that guards your API's surface.

Testing access

Test each route as a guest and as a user without and with the right grants:

Rust

abilities is the app's ability function (here it lets every signed-in user read reports). principal(id) is a plain test user with no roles or permissions, which makes it the right client for the refused side. For the allowed side of a roles or permissions check, the user needs real grants: the CRM's feature tests sign in as its seeded admin, manager and rep accounts with crm-testkit. See HTTP tests.

Popular pages

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