7. Permissions
Permissions have two layers, and a multi-tenant service needs both:
- Casbin (activity authorization): may this principal perform this operation?
- Row policy: which rows may this operation see or change? This is the automatic
_tenantfilter plus each resource'sscope_policy.
Casbin
Enable it
[riverbase.casbin]
enabled = true
policy = """
p, todo-admin, riverbase.todo, create-todo.execute, todo, *, allow
p, todo-admin, riverbase.todo, update-todo.execute, todo, *, allow
p, todo-admin, riverbase.todo, clear-done.execute, todo, *, allow
p, todo-admin, riverbase.todo, todo.query, todo, *, allow
p, todo-user, riverbase.todo, todo.query, todo, *, allow
g, alice, todo-admin
"""
Or provide the policy from code:
app.with_casbin_policy(TODO_POLICY_CSV);
app.with_casbin_known_roles(["todo-admin", "todo-user"]);
An empty policy denies everything. The model is the framework default unless you set
[riverbase.casbin] model. Rows read: subject or role, domain (namespace), activity, resource,
object, effect.
Activity names
The web layer maps every route to an activity. Your policy rows must use these names:
| Route | Activity | Example |
|---|---|---|
| Collection or object command | {cmdkey}.execute | create-todo.execute |
| List, item, meta, or report | {resource}.query | todo.query |
POST /api/riverbase.todo/create-todo:post/todo → riverbase.todo / create-todo.execute / todo
GET /api/riverbase.todo/todo.list → riverbase.todo / todo.query / todo
Activities you emit with emit_activity (such as todo.create) are audit records, not route
activities. HTTP enforcement uses .execute and .query. Write policy rows with the HTTP names.
A denial returns 403 with code AUT-003. That means "authenticated but not allowed". Missing or
invalid credentials are AUT-001 and AUT-002. A custom route with no mapped activity returns
403 CAS-011.
Set omit_inaccessible_openapi = true to hide operations the caller cannot run from their copy of
openapi.json.
Off the HTTP path
Workers, tests, and internal invokers do not pass through HTTP middleware. Implement
CommandPolicy and add it to the engine's arguments (CommandEngineArgs::with_policy), so those
paths are authorized too:
use async_trait::async_trait;
use riverbase_core::base::{EngineContext, RiverbaseResult};
use riverbase_core::command::{CommandPolicy, CommandTarget};
use serde_json::Value;
struct AdminsOnly;
#[async_trait]
impl CommandPolicy for AdminsOnly {
fn name(&self) -> &'static str { "admins-only" }
async fn authorize(
&self,
ctx: &EngineContext,
_command: &str,
_payload: &Value,
_target: &CommandTarget,
) -> RiverbaseResult<()> {
if ctx.roles.iter().any(|r| r == "todo-admin") {
Ok(())
} else {
Err(riverbase_core::base::ForbiddenError::with(
"TDO-040", "Only todo administrators may run this command.", "missing role todo-admin",
))
}
}
}
Row policy
The tenant filter
Every framework row carries a _tenant UUID, stamped at creation from the caller's authorization
context (usually the organization of the active profile). With no extra code:
- queries AND
_tenant = <caller's tenant>into the server filter; - aggregates do not load rows from another tenant;
- audit records persist
_tenant.
A catalog that is deliberately shared across tenants opts out with
tenant_scope: exempt("reason", "sign-off").
scope_policy
Casbin decides that a user may call todo.query. scope_policy narrows which rows that call
returns. It runs on the server, uses physical column names from the binding, and is ANDed in front
of the client's filter, so a client cannot widen it.
use riverbase_core::datastore::dsl::Expr;
riverbase_core::query_resource! {
ProjectQueryResource name "project" {
meta {
title: "Project",
default_order: [created.desc],
scope: none,
}
fields {
field id { preset: Uuid, label: "Project ID", identifier }
field name { preset: String, label: "Name" }
field created { preset: Datetime, label: "Created" }
}
binding source "projects" { id => "_id", created => "_created" }
errors {
list: ("PRJ-002", "Failed to list projects."),
item: ("PRJ-003", "Failed to fetch project by id."),
}
policy: required,
scope_policy(ctx, _url_scope) {
// Only projects the caller's team owns. No team claim, no rows.
Ok(ctx.claim_str("team_id").map(|team| Expr::eq("team_id", team)))
}
}
}
policy: | Meaning |
|---|---|
required (the default) | scope_policy must return a constraint. None, an error, or missing context denies. |
public("reason", "sign-off") | Unrestricted on purpose. Both strings are mandatory. |
optional | Legacy: None means unrestricted. Prefer public. |
This is the fail-closed contract: when the framework cannot prove what a caller may see, it returns nothing.
Mental model
| Check | Question | Typical failure |
|---|---|---|
| Authentication | Valid token or session? | AUT-001, AUT-002 |
| Casbin | Activity allowed? | AUT-003, CAS-011 |
| Row policy | Rows within reach? | Empty result or a QRY-* policy error |
End to end
Allow alice (via todo-admin) to create and list todos; deny everyone else:
[riverbase.casbin]
enabled = true
policy = """
p, todo-admin, riverbase.todo, create-todo.execute, todo, *, allow
p, todo-admin, riverbase.todo, todo.query, todo, *, allow
g, alice, todo-admin
"""
With MockAuth as alice, create and list succeed. As bob, Casbin rejects the request before the
handler runs.
Checklist
-
[riverbase.casbin] enabled = truewith.executeand.queryactivity names -
with_casbin_known_roleslists every role used ing,rows - A denied user receives
403on protected routes - Tenant-owned resources use
policy: requiredwith ascope_policy, or rely on_tenant - A
CommandPolicyon engines that workers call directly
Next: 8. Run and verify.