This version is in beta. Some features may change before release.

Model exposure

Which models become API endpoints, the built-in block-list (auth_user, session, the permissions/tasks/admin tables), and the include_only / exclude / expose controls.

umbral-rest walks the model registry and mounts /api/<table>/ for every registered model: exposure is opt-out, not opt-in. Add RestPlugin::default() and every model you've registered gets List + Retrieve + Create + Update + Delete, except a small set of framework-internal tables that are blocked by default. Writes are gated by the default ReadOnly permission, so an unconfigured resource is read-only until you opt in (see Permissions).

Code
rust
App::builder()
.model::<Article>()
.model::<Order>()
.plugin(umbral_rest::RestPlugin::default())
.build()?;
// → /api/article/ and /api/order/ are live. auth_user / session are NOT.

The default block-list

A set of framework-internal tables is refused even though every other model is served. The list (DEFAULT_BLOCKED_TABLES in plugins/umbral-rest/src/lib.rs):

TableWhy it's blocked
auth_userServing it would leak password hashes and let PATCH /api/auth_user/1 flip is_staff / is_superuser.
sessionServing it would leak session identifiers: anyone reading the table could hijack a live session.
umbral_migrationsThe migration ledger. Internal bookkeeping, never a public resource.
permissions_permission, permissions_contenttype, permissions_group, permissions_usergroup, permissions_userpermissionThe authorization tables from umbral-permissions. Exposing them would let a client read or rewrite who-can-do-what.
task_rowThe umbral-tasks queue rows - internal job state, not a public resource.
admin_audit_logThe admin's audit trail. Internal bookkeeping.

A request to a blocked table 404s; the route is never mounted. This is the backstop that keeps the credential store (and the framework's own bookkeeping tables) off the API even though the plugin auto-exposes everything else.

Info

Beyond the table block-list, the field password_hash is always stripped from every response, on every table, and can never be re-exposed (not even with .expose(...)). It's a hard, un-overridable denylist entry - the last line of defence if a custom credential model slips past the table block-list.

Controlling what's exposed

Four builder methods, applied in a strict precedence order.

include_only([...])

Allowlist. ONLY the named tables are served; every other model, blocked or not, is denied. The tightest control.

exclude([...])

Blocklist. Adds your own tables to the default block-list. Use for a business model you don't want on the API.

expose([...])

Opt INTO a default-blocked table (auth_user / session). The escape hatch: you're saying 'I know what I'm doing.'

ResourceConfig::hide(col)

Strips a column from responses and write bodies. Pair with expose to serve auth_user without the password_hash column.

Code
rust
// Tighten to a fixed allowlist; nothing else is reachable.
RestPlugin::new().include_only(["article", "order"]);
 
// Block one of your own models that would otherwise be served.
RestPlugin::default().exclude(["internal_audit_log"]);
 
// Deliberately serve the user list, without the password hash.
RestPlugin::default()
.expose(["auth_user"])
.resource(ResourceConfig::new("auth_user").hide("password_hash"));

Info
hide (and transform / computed) also applies to a table when it appears NESTED under an ?include='d relation. For example ?include=created_by hydrates auth_user under created_by, and the password_hash hidden on auth_user is stripped from that nested object too, at every hop.

Registering many resources at once

REST customization is best defined per app - next to the models it describes - rather than piled into main.rs. Each plugin exports a Vec<ResourceConfig> and the app registers them in one .resources(...) call instead of a .resource(...) per model:

Code
rust
// plugins/blog/src/lib.rs - the blog app's REST surface, next to its models
pub fn rest_resources() -> Vec<umbral_rest::ResourceConfig> {
vec![
ResourceConfig::new("post").hide("draft_notes"),
ResourceConfig::new("comment"),
]
}
 
// main.rs - one call per app, no per-model boilerplate
RestPlugin::default()
.resources(blog::rest_resources())
.resources(shop::rest_resources());

.resources(iter) accepts any IntoIterator<Item = ResourceConfig> (a Vec, an array, …) and is exactly equivalent to calling .resource(...) once per item - additive, same precedence.

Precedence

allow(table) decides exposure top-down. The first matching rule wins:

  1. include_only: if set, only listed tables pass; everything else is denied outright.
  2. expose: a default-blocked table you've opted into (still subject to exclude).
  3. default block-list: the framework-internal tables above (auth_user, session, umbral_migrations, the permissions_* tables, task_row, admin_audit_log) are denied.
  4. exclude: your own additions are denied.
  5. otherwise served.

So an auth_user that's on expose but also on exclude stays blocked: an explicit "no" beats an explicit "yes".

Warning

The block-list matches by table name, not by type. umbral-rest discovers models dynamically and never names the AuthUser Rust type (it can't depend on umbral-auth). If you swap in a custom user / credential model under a different table name, it is not auto-protected: its name isn't auth_user, so it's served like any other model. Add it yourself with .exclude(["your_user_table"]).

Danger

Auto-exposure means a business model (customer, payment, invoice) is readable the moment you add RestPlugin::default(): NoAuthentication plus the default ReadOnly permission means anyone can list and retrieve it, though writes return 403 until you opt in. The block-list only protects the framework's own internal tables, not yours. To require auth even for reads, set a stricter permission class (e.g. IsAuthenticated or IsStaff) per resource, and reach for include_only when you want exposure to be an explicit allowlist rather than opt-out.

The API root

GET /api/ (the base path you mounted at) returns a browsable index of everything the API exposes:

Code
json
{
"resources": {
"post": { "path": "/api/post/", "detail": "/api/post/{id}" }
},
"endpoints": [
{ "group": "oauth", "name": "google.login", "method": "GET",
"path": "/oauth/google/login",
"url": "https://api.example.com/oauth/google/login",
"label": "Sign in with Google" }
]
}

resources lists every model that passes the exposure rules above, each with its collection + detail path. endpoints aggregates what other plugins advertise via the framework's Plugin::api_endpoints() seam (OAuth's login/connect links, for instance), each with an absolute url joined from the incoming request's origin. REST lists them without depending on those plugins' crates. (When REST is mounted at the bare root, the index is skipped so it doesn't shadow your app's home route.)

See also

  • Permissions: gate who can perform each action once a table is exposed.
  • Authentication: resolve the caller's identity.
  • The plugin contract and the full exposure rules live in arch.md and docs/specs/.
restsecurityexposureapi