Chuyển tới nội dung chính

7. Permissions

Permissions have two layers, and a multi-tenant service needs both:

  1. Casbin (activity authorization): may this principal perform this operation?
  2. Row policy: which rows may this operation see or change? This is the automatic _tenant filter plus each resource's scope_policy.

Casbin​

Enable it​

service/riverbase.toml
[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:

RouteActivityExample
Collection or object command{cmdkey}.executecreate-todo.execute
List, item, meta, or report{resource}.querytodo.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
Audit names are not route activities

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.

domain/src/query.rs
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.
optionalLegacy: 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​

CheckQuestionTypical failure
AuthenticationValid token or session?AUT-001, AUT-002
CasbinActivity allowed?AUT-003, CAS-011
Row policyRows 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 = true with .execute and .query activity names
  • with_casbin_known_roles lists every role used in g, rows
  • A denied user receives 403 on protected routes
  • Tenant-owned resources use policy: required with a scope_policy, or rely on _tenant
  • A CommandPolicy on engines that workers call directly

Next: 8. Run and verify.