Authorization

Who the caller is, from any authenticator — and, as relationship-based access control, what they may do.

Authentication says a request is allowed in; authorization says what its caller may do. rws gives every authenticator's caller one shape, authz::Identity, which the rest of the request can read. Since 18.14.0.

Identity

Each authenticator runs the rest of the request as the caller it authenticated:

Authenticator Subject Notes
JwtLayer user:<sub> .identity_claim("email") to use another claim; a valid token without it has no identity
OidcAuth user:<sub> from the session's ID token claims
SamlSp user:<NameID>
BasicAuthLayer user:<name> also from an htpasswd file
ForwardAuthLayer user:<value> .identity_header("X-User-Id"): a header in the auth service's 2xx answer
MCP (require_bearer_tokens, require_oauth) user:<sub> tool handlers included

A value from a token, header or login is always a user id, taken as is (user:alice@example.com, user:service:billing) — it can't name another type or a set like group:eng#member; a value with #, a space or a control character, empty or over 256 characters, gives no identity. Identity::new("service:billing", …) in your own code can name another type. Handlers read the caller with Identity::current():

use rust_web_server::app::App;
use rust_web_server::auth::JwtLayer;
use rust_web_server::authz::Identity;
use rust_web_server::core::New;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};

let app = App::with_state(())
    .get("/me", |_, _, _, _| match Identity::current() {
        Some(me) => Response::text(STATUS_CODE_REASON_PHRASE.n200_ok, me.subject()),
        None => Response::text(STATUS_CODE_REASON_PHRASE.n401_unauthorized, "who are you?"),
    })
    .wrap(JwtLayer::new(b"secret"));

Identity has subject() (user:alice), subject_type(), subject_id(), source() (jwt, oidc, saml, basic, forward, mcp), claims() (the raw JSON, when there were claims) and claim(name).

It is not a header. The authenticator runs the rest of the request inside authz::with_identity(identity, || …) — a value tied to the thread (or, with authz::scope_identity, to the async task) for that call — so a client can't send one, and nothing leaks into the next request. Your own authenticator can do the same:

use rust_web_server::authz::{with_identity, Identity};

// inside a Middleware::handle, after checking credentials
with_identity(Identity::new("service:billing", "api-key"), || next.execute(request, connection))

Async handlers (App::with_async_state) see the caller too. Work you hand to another thread yourself (a spawned thread, an MCP task) doesn't carry it along; pass it explicitly.

Relationships

What a caller may do is answered from relationship tuples and a schema, Zanzibar style. A tuple says a subject has a relation to an object:

doc:42#owner@user:olga                 olga owns doc 42
group:eng#member@user:gina             gina is in eng
doc:42#editor@group:eng#member         every member of eng edits doc 42
doc:42#parent@folder:specs             doc 42 is in folder specs

The schema says, per object type, what each relation is made of — terms joined by |:

Term Means
user tuples naming a user:… directly
group#member tuples naming group:…#member: those members
owner whoever has owner on the same object
parent->viewer the viewers of whatever this object's parent is
use rust_web_server::authz::{Authorizer, MemoryStore, Schema};

let schema = Schema::new()
    .relation("group", "member", "user | group#member")?   // groups in groups
    .relation("folder", "viewer", "user | group#member")?
    .relation("doc", "parent", "folder")?
    .relation("doc", "owner", "user")?
    .relation("doc", "editor", "user | group#member | owner")?
    .relation("doc", "viewer", "user | editor | parent->viewer")?;
let authz = Authorizer::new(schema, MemoryStore::new())?;

authz.write_str("doc:42#owner@user:olga")?;
authz.check("user:olga", "viewer", "doc:42")?;            // true: owner → editor → viewer
authz.explain("user:olga", "viewer", "doc:42")?;          // the path that allowed it
  • A bare name is a type when no relation of that type has it, otherwise a relation on the same object. Types used only as subjects (user) need no definition. Authorizer::new validates the schema: every group#member, parent->viewer and relation must exist.
  • Writes are checked against it: doc:42#viewer@folder:x is refused when viewer doesn't take folders.
  • A check walks the graph without recursion, ignores cycles, and stops with AuthzError::Limit after 25 relations deep or 1000 tuple reads — treat that as a "no".
  • Names are letters, digits, _, - and .; ids anything but #, spaces and control characters (@ and : are fine); both up to 256 characters.

Tuples live in a TupleStore: MemoryStore (lost on restart) or SqlStore (below).

Storing tuples in a database

SqlStore keeps tuples in a rws_authz_tuples table — SQLite, Postgres or MySQL through the model layer (a model-sqlite, model-postgres or model-mysql feature):

use rust_web_server::authz::{Authorizer, SqlStore};

let store = SqlStore::from_env()?;            // RWS_DB_BACKEND, RWS_DB_NAME, …
// or SqlStore::open(DbConfig { … })
let authz = Authorizer::new(schema, store)?;
authz.write_str("doc:42#owner@user:olga")?;   // stays across restarts
  • The table (and an index on object and relation) is created if missing; each row's key is the SHA-256 of the tuple, so writes are idempotent.
  • Queries run on a runtime of the store's own (rws-authz-sql), so checks work from any handler, async server or not.
  • Reads are cached for 1 s; this process's writes clear the cache at once. A tuple another process changes is seen within that second.
  • SqlStore::new(pool) takes a pool you already have; it must outlive the runtime it was made on.

In the config file, [authz] store = "sql" uses RWS_DB_* (opened once per process; seed tuples written at start and on reload). rws --check checks the schema and tuples without connecting.

Protecting routes

AuthzLayer lets a request through when its caller has a relation to an object named from the request: {path:N} (the Nth path segment, percent-decoded), {query:name}, {header:Name}. The first layer wrapped is the outermost, so wrap the authenticator first — it sets the caller this layer checks:

use rust_web_server::app::App;
use rust_web_server::auth::JwtLayer;
use rust_web_server::authz::AuthzLayer;
use rust_web_server::core::New;

let app = App::new()
    .wrap(JwtLayer::new(b"secret"))                                   // outermost: who is calling
    .wrap(AuthzLayer::new(authz.clone(), "viewer", "doc:{path:2}")?); // then: may they? /docs/42 → doc:42

A path with a . or .. segment (raw or percent-encoded) or an encoded / names no object, so it's refused: a proxied backend that resolves /docs/42/../7 would otherwise serve doc 7 after a check on doc 42. {query:…} and {header:…} are whatever the client sent — use a header only when something in front (forward auth's copy_header) sets it.

No caller → 401; not allowed, an object the request doesn't name, or a check that hit a limit (logged) → 403. AuthzLayer::with(authz, |request| …) picks the relation and object in code instead.

In the config file

[authz]
tuples = [                               # written at every start and reload
  "group:eng#member@user:gina",
  "doc:42#editor@group:eng#member",
]
[authz.type.group]
member = "user | group#member"
[authz.type.doc]
owner  = "user"
editor = "user | group#member | owner"
viewer = "user | editor"

[[route]]
[route.match]
path = "/docs/*"
[route.action]
type = "proxy"
upstream = "docs"
[route.middleware.auth]
type = "jwt"
secret_env = "JWT_SECRET"
[route.middleware.authz]
relation = "viewer"
object   = "doc:{path:2}"
  • [authz] is read at start and on every reload; rws --check validates the schema, every seed tuple, and each route's relation and template against it, and warns about an authz route with no auth (here or in [middleware]) — every request would be 401.
  • The tuple store (memory) lives as long as the process: tuples written at runtime survive a reload; removing a seed tuple from the file doesn't delete it.
  • An [authz] that can't be built makes authz routes answer 503 (logged), never lets them through.
  • [middleware.authz] in the global table applies to every request.

Managing tuples at runtime

The admin API lists, adds and removes tuples and explains a check (GET/POST/DELETE /admin/api/v1/authz/tuples, POST /admin/api/v1/authz/check); the admin UI has the same on its Controls page, and the binary's MCP endpoint has authz_check, authz_tuples, authz_write and authz_delete. They manage the config file's [authz]; a library app hands over its own with admin::ops::register_authorizer(authz.clone()). Writes are checked against the schema. Most tuples are written by the application itself, as things are shared — the runtime tools are for operators and fixes.

Edit this page on GitHub