Getting started

Everything you need to go from zero to authorizing requests with Praetor, on one page.

Praetor is a small, strongly typed authorization library for Rust built around composable can and cannot rules. It defines authorization mechanics, not your application's authorization model: actions, subjects, claims, roles, permissions, and JWT formats all stay in your application. Praetor composes the rules you give it, matches them against a request, and returns a decision.

claims + rules + action + subject/resource → allow / deny

This guide builds a complete, framework-free example step by step. Read it top to bottom, or jump to any section from the sidebar.

Installation

Add Praetor to your project with Cargo:

cargo add praetor

Or add it to your Cargo.toml directly:

[dependencies]
praetor = "0.1"
Note. Praetor targets Rust 1.88+ and has zero dependencies. It is #![forbid(unsafe_code)].

Core concepts

Praetor revolves around a handful of ideas. You supply the first three as your own types; Praetor provides the rest.

  • Claims (C) — the authenticated state you build rules from: a user id, roles, permissions, tenancy. Whatever you decoded from a validated token.
  • Action (A) — what the caller wants to do, e.g. Read, Update, Delete. Any type that is PartialEq.
  • Subject (S) — the logical thing an action applies to, e.g. Message, Room. Any type that is PartialEq.
  • Resource (R) — a concrete instance (&message) that maps to a subject via the AbilitySubject trait. Conditional rules run against it.
  • Ability — an immutable set of rules bound to one set of claims. You ask it questions with can, cannot, and authorize.
A logical subject answers “can this user update messages at all?” A concrete resource answers “can this user update this message?” Praetor handles both through one interface.

1 · Define your model

Praetor is generic over your types, so start by declaring the actions and subjects your application cares about, plus the claims you build rules from. These are plain Rust enums and structs — no macros, no traits to derive beyond PartialEq for the action and subject.

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action { Read, Update, Delete }

#[derive(Clone, Copy, PartialEq, Eq)]
enum Subject { Message }

// The claims you decoded from a validated token.
struct Claims { user_id: u64 }

// A concrete resource loaded from storage before authorization.
struct Message { author_id: u64, deleted: bool }

To let a Message instance be passed directly to a permission check, implement AbilitySubject to map it to its logical subject:

use praetor::AbilitySubject;

impl AbilitySubject<Subject> for Message {
    fn subject_type(&self) -> Subject { Subject::Message }
}
Why this trait? It is what lets a single can method accept either Subject::Message or &message. The resource reports which subject it belongs to, and Praetor takes it from there.

2 · Build an ability

An Ability is an immutable set of rules bound to one set of claims. Create a builder with Ability::builder(claims), add rules, then call build(). There are four rule methods:

  • can(action, subject) — an unconditional allow.
  • cannot(action, subject) — an unconditional deny.
  • can_if(action, subject, predicate) — a conditional allow.
  • cannot_if(action, subject, predicate) — a conditional deny.
use praetor::Ability;

let ability = Ability::builder(Claims { user_id: 1 })
    .can(Action::Read, Subject::Message)
    .can_if(Action::Update, Subject::Message, |claims, m: &Message| {
        claims.user_id == m.author_id
    })
    .cannot_if(Action::Read, Subject::Message, |_, m: &Message| m.deleted)
    .build();

The rule methods take &mut self and return &mut Self, so they chain fluently — but they also read naturally when you build rules conditionally from claims:

let mut ability = Ability::builder(claims);
if ability.claims().is_admin {
    ability.can(Action::Delete, Subject::Message);
}
let ability = ability.build();
Tip. Use ability.claims() on the builder to drive rule construction from authenticated state (roles, permissions, tenancy). The claims are also available on the finished ability.

3 · Check permissions

Ask the ability questions with one unified interface. Every method accepts either a logical subject or a concrete resource reference as its target.

let mine = Message { author_id: 1, deleted: false };

// Subject-level check — no instance required.
assert!(ability.can(Action::Read, Subject::Message));

// Instance-level check — the predicate runs against `mine`.
assert!(ability.can(Action::Update, &mine));

// `cannot` is the negation of `can`.
assert!(ability.cannot(Action::Delete, &mine));

When you want an error instead of a boolean — for ?-style flows in handlers — use authorize. It returns Ok(()) when allowed and Err(Forbidden) when denied.

use praetor::Forbidden;

ability.authorize(Action::Update, &mine)?; // proceeds when allowed

match ability.authorize(Action::Delete, &mine) {
    Ok(()) => { /* proceed */ }
    Err(Forbidden) => { /* map to HTTP 403 */ }
}
Forbidden is intentionally opaque: it carries no rule or claims detail and implements std::error::Error. Map it to whatever your framework needs.

4 · Conditional rules

Unconditional rules (can / cannot) match on action and subject alone. Conditional rules (can_if / cannot_if) additionally run a strongly typed closure against a concrete resource instance:

// Fn(&Claims, &Message) -> bool
.can_if(Action::Update, Subject::Message, |claims, m: &Message| {
    claims.user_id == m.author_id
})

Two rules make conditional behavior predictable:

  • A conditional rule only matches when a concrete instance is supplied. A subject-only check (can(Update, Subject::Message)) never matches one, because there is no resource to evaluate.
  • The predicate is bound to a specific resource type. If you pass a different concrete type that happens to map to the same subject, the predicate simply does not match — it is not run against the wrong type.
Heterogeneous resources. A single ability can hold predicates for many resource types (Message, Room, ...). Praetor stores them via internal type erasure that stays invisible to your code — every closure you write remains fully typed.

5 · Evaluation semantics

When you ask a question, Praetor walks the rules and applies two simple laws:

Default deny

If no rule matches the request, the answer is deny. Nothing is permitted implicitly.

Last matching rule wins

Rules are evaluated in reverse order, so a later rule overrides an earlier one.

Together these let you layer a broad grant and then carve out a precise exception:

let ability = Ability::builder(())
    .can(Action::Read, Subject::Message)
    .cannot_if(Action::Read, Subject::Message, |_, m: &Message| m.deleted)
    .build();

let deleted = Message { author_id: 1, deleted: true };
assert!(ability.cannot(Action::Read, &deleted)); // later deny wins

// The conditional deny needs an instance, so a subject-only check
// still falls through to the unconditional allow.
assert!(ability.can(Action::Read, Subject::Message));

6 · Resources & subjects

The distinction between a subject and a resource is the key to Praetor's single-method API:

  • Pass a subject (Subject::Message) to ask a coarse, instance-free question. Only unconditional rules can match.
  • Pass a resource reference (&message) to ask about a specific object. Both unconditional and conditional rules can match, and predicates receive the instance.

A resource opts into being a valid target by implementing AbilitySubject<S>, which reports its logical subject. That mapping is the only glue required — the resource itself is stored type-erased internally and never exposed to callers.

Ownership. An Ability owns its claims, so it moves cleanly between layers — for example, into an Axum request extension — and every check has the claims on hand for its predicates.

7 · A full example

This mirrors how an application uses Praetor after it has validated a token and decoded its claims: build an ability from those claims, then authorize both subject- and instance-level requests. This is the messages example from the repository.

use std::collections::HashSet;
use praetor::{Ability, AbilitySubject};

#[derive(Clone, Copy, PartialEq, Eq)]
enum Action { Read, Update, Delete }

#[derive(Clone, Copy, PartialEq, Eq)]
enum Subject { Message }

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Permission { ReadMessages, EditOwnMessages }

struct Claims { user_id: u64, permissions: HashSet<Permission> }
struct Message { author_id: u64, deleted: bool, system: bool }

impl AbilitySubject<Subject> for Message {
    fn subject_type(&self) -> Subject { Subject::Message }
}

// Build an ability from authenticated claims. This logic belongs to the
// application; Praetor only evaluates the rules it is given.
fn build_ability(claims: Claims) -> Ability<Action, Subject, Claims> {
    let mut ability = Ability::builder(claims);

    if ability.claims().permissions.contains(&Permission::ReadMessages) {
        ability.can(Action::Read, Subject::Message);
    }
    if ability.claims().permissions.contains(&Permission::EditOwnMessages) {
        ability.can_if(Action::Update, Subject::Message, |claims, m: &Message| {
            claims.user_id == m.author_id
        });
    }

    // Later rules override earlier ones: deleted messages are never readable,
    // and system messages are never deletable, regardless of other grants.
    ability.cannot_if(Action::Read, Subject::Message, |_, m: &Message| m.deleted);
    ability.cannot_if(Action::Delete, Subject::Message, |_, m: &Message| m.system);

    ability.build()
}

fn main() {
    let claims = Claims {
        user_id: 1,
        permissions: HashSet::from([
            Permission::ReadMessages,
            Permission::EditOwnMessages,
        ]),
    };
    let ability = build_ability(claims);

    let mine = Message { author_id: 1, deleted: false, system: false };
    let theirs = Message { author_id: 2, deleted: false, system: false };
    let deleted = Message { author_id: 1, deleted: true, system: false };

    assert!(ability.can(Action::Read, Subject::Message)); // subject-level grant
    assert!(ability.can(Action::Update, &mine));          // own message
    assert!(ability.cannot(Action::Update, &theirs));     // someone else's
    assert!(ability.cannot(Action::Read, &deleted));      // later deny wins
}

Run the example from a clone of the repository with:

cargo run --example messages

Framework integration

Praetor is framework-independent by design. A typical web flow looks like this, with your framework doing the work Praetor deliberately leaves out:

  1. Validate the incoming token and decode it into your Claims type. (Your code.)
  2. Call build_ability(claims) and stash the Ability in request state — for example, an Axum request extension. (Your code.)
  3. Load the resource the request targets from storage. (Your code.)
  4. In the handler, call ability.authorize(action, &resource)? and map Forbidden to an HTTP 403. (Praetor + your code.)
Because the decision is a pure, in-memory operation, it is trivial to unit-test: construct claims, build an ability, and assert on can / cannot with no HTTP or database in sight.

Using Axum? The optional axum feature wires this flow through a request for you — see the Axum integration guide.

What's next

You now have everything needed to model actions and subjects, build abilities, and authorize requests. From here:

  • Wire Praetor into a web app with the Axum integration guide — authorize a request in a single line.
  • Browse the complete API on docs.rs.
  • Read the source and open issues on GitHub.

← Back home