This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-website.git
The following commit(s) were added to refs/heads/master by this push:
new 2127000cb docs: add nested organizations and list filtering guides
2127000cb is described below
commit 2127000cb02e14ff000308f32774c1a5712c9fee
Author: Yang Luo <[email protected]>
AuthorDate: Sun Nov 1 02:37:03 2026 +0800
docs: add nested organizations and list filtering guides
---
docs/DataPermissions.mdx | 2 +
docs/ListFiltering.mdx | 132 +++++++++++++++++++++++++++++++++++++++++++
docs/NestedOrganizations.mdx | 130 ++++++++++++++++++++++++++++++++++++++++++
docs/RBACWithDomains.mdx | 4 ++
sidebars.js | 2 +
5 files changed, 270 insertions(+)
diff --git a/docs/DataPermissions.mdx b/docs/DataPermissions.mdx
index 3a7b262a6..01c88f21b 100644
--- a/docs/DataPermissions.mdx
+++ b/docs/DataPermissions.mdx
@@ -49,3 +49,5 @@ List<Boolean> boolArray = e.batchEnforce(requests);
</TabItem>
</Tabs>
```
+
+For large lists, SQL pagination and other ways to turn Casbin decisions into a
query filter, see [List Filtering](/docs/list-filtering).
diff --git a/docs/ListFiltering.mdx b/docs/ListFiltering.mdx
new file mode 100644
index 000000000..c0f49b35a
--- /dev/null
+++ b/docs/ListFiltering.mdx
@@ -0,0 +1,132 @@
+---
+id: list-filtering
+title: List Filtering
+description: "Filter list and search results with Casbin - BatchEnforce,
per-unit implicit permissions, a materialized permission table, and Postgres
RLS, plus the query-planner gap."
+keywords: [list filtering, data filtering, BatchEnforce, row-level security,
PlanResources]
+authors: [hsluoyz]
+---
+
+`Enforce()` answers one question: may this subject do this action on this
object? A list endpoint asks a different question: **which rows may this user
see?** Casbin has no query planner (the equivalent of Cerbos `PlanResources` or
a Zanzibar `LookupResources`), so the filter has to be built by the
application. There are four practical patterns.
+
+## 1. Check the page with `BatchEnforce()`
+
+The simplest option: fetch the page, build one request per row, and keep the
allowed rows.
+
+```go
+rows, _ := queryPage(...) // each row has UnitPath and Path
+
+requests := make([][]interface{}, 0, len(rows))
+for _, row := range rows {
+ requests = append(requests, []interface{}{userID, row.UnitPath, row.Path,
"read"})
+}
+allowed, err := e.BatchEnforce(requests)
+if err != nil {
+ return nil, err
+}
+result := make([]Row, 0, len(rows))
+for i, row := range rows {
+ if allowed[i] {
+ result = append(result, row)
+ }
+}
+```
+
+This supports every matcher, including regex and custom functions. The cost is
one `Enforce()` per row, so it is a good fit for pages of tens to a few hundred
rows; for a 10,000-row export, use one of the next patterns.
+
+## 2. Group by unit and query implicit permissions once per unit
+
+If the list is already scoped to one unit, or the page spans only a few units,
ask Casbin what the user can do in that unit:
+
+```go
+perms, err := e.GetImplicitPermissionsForUser(userID, unitPath)
+```
+
+`perms` contains the `p` rules that apply to the user's roles in `unitPath`,
including rules inherited from ancestor units. The domain field in each
returned rule is **replaced with `unitPath`**, not the unit where the rule was
originally granted. Without a domain argument, a domain-scoped enforcer returns
nothing, so always pass the unit.
+
+Then match each row's object against the returned object patterns (for example
with `keyMatch2`) and its action. This is still a scan over the user's rules
per unit, but it avoids one `Enforce()` per row and gives you a list you can
cache for the request.
+
+## 3. Materialize a permission table
+
+For large lists, latency-sensitive endpoints, or SQL-side pagination, keep a
denormalized table that the database can index:
+
+```sql
+CREATE TABLE user_scope_grant (
+ user_id text NOT NULL,
+ scope_path text NOT NULL, -- the unit where the grant was made, e.g.
/acme/eng
+ action text NOT NULL
+);
+CREATE INDEX user_scope_grant_user ON user_scope_grant (user_id, action);
+```
+
+Build it from the policy and role links:
+
+- `e.GetPolicy()` gives `(sub, dom, obj, act)`.
+- `e.GetGroupingPolicy()` gives `(user, role, dom)`.
+- Expand roles with `e.GetImplicitRolesForUser(user, dom)` (or compute the
transitive closure of `g` yourself), and write one row per user/role grant.
+
+Then the list query becomes a join, and the subtree condition is a prefix
match:
+
+```sql
+SELECT d.*
+FROM documents d
+WHERE EXISTS (
+ SELECT 1
+ FROM user_scope_grant a
+ WHERE a.user_id = $1
+ AND a.action = 'read'
+ AND (d.unit_path = a.scope_path OR d.unit_path LIKE a.scope_path || '/%')
+);
+```
+
+(If unit paths can contain `%` or `_`, escape them before the `LIKE`.)
+
+This example assumes the policy grants the whole unit subtree, as in [Nested
Organizations](/docs/nested-organizations). If your policy also restricts the
object pattern inside the unit, store a normalized prefix in the table or apply
the pattern in the application after the SQL filter.
+
+Rebuild or update the table when the policy changes: in the same transaction
as the policy write, or through a watcher that triggers a rebuild. A full
rebuild is cheap for small policies; for large ones, update only the affected
user/role rows.
+
+## 4. Postgres row-level security
+
+If you already use Postgres RLS, keep Casbin for single-object checks and use
RLS for lists. At the start of the request, put the user's effective scope
paths into a session setting:
+
+```sql
+SET LOCAL app.user_scopes = '/acme,/acme/eng';
+```
+
+Then:
+
+```sql
+CREATE POLICY document_read ON documents
+FOR SELECT
+USING (
+ EXISTS (
+ SELECT 1
+ FROM unnest(string_to_array(current_setting('app.user_scopes'), ',')) AS
scope(path)
+ WHERE documents.unit_path = scope.path
+ OR documents.unit_path LIKE scope.path || '/%'
+ )
+);
+```
+
+The scope list still comes from Casbin (or from the materialized table above);
RLS only applies it to every query.
+
+## What Casbin does not do
+
+There is no built-in query planner: Casbin cannot turn an arbitrary matcher
into a SQL `WHERE` clause. Regexes, custom functions, and
`eval(p.obj_rule)`-style rules cannot be pushed into SQL without a
normalization layer.
+
+If you need a generic planner over arbitrary policies, use a system that
provides one (Cerbos `PlanResources`, SpiceDB/OpenFGA lookup APIs), or accept
the materialized-table approach and keep the policy language simple enough to
translate.
+
+## Choosing
+
+| Situation | Pattern |
+|-----------|---------|
+| Page of tens or hundreds of rows, any matcher | `BatchEnforce()` |
+| List scoped to one or a few units | implicit permissions per unit |
+| Large lists, SQL pagination, latency-sensitive | materialized permission
table |
+| Postgres and existing RLS | RLS + session scopes |
+| Arbitrary policies and a generic query planner | external authorization
service |
+
+## See also
+
+- [Nested Organizations](/docs/nested-organizations)
+- [Data Permissions](/docs/data-permissions)
+- [Policy Subset Loading](/docs/policy-subset-loading)
diff --git a/docs/NestedOrganizations.mdx b/docs/NestedOrganizations.mdx
new file mode 100644
index 000000000..a0d08fbcf
--- /dev/null
+++ b/docs/NestedOrganizations.mdx
@@ -0,0 +1,130 @@
+---
+id: nested-organizations
+title: Nested Organizations
+description: "Model nested organizations in Casbin - grant a role at a unit
and inherit it through the whole subtree with domain paths and a descendant
matching function."
+keywords: [nested organizations, organization hierarchy, departments, domain
hierarchy, subtree]
+authors: [hsluoyz]
+---
+
+Most multi-tenant systems are not flat: a company contains divisions, a
division contains departments, and a department contains teams. A role granted
at one unit should apply to that unit **and every unit below it**, but never to
its parent or its siblings.
+
+This page shows one model for that in Casbin, plus a variant that needs no
custom matching function. For the other half of the problem, filtering list
queries, see [List Filtering](/docs/list-filtering).
+
+## Model: the unit path is the domain
+
+Use the unit's path as the [domain](/docs/rbac-with-domains). Every grant
records the unit where it was made; the matcher accepts the grant when the
request's unit is that unit or a descendant.
+
+```ini
+[request_definition]
+r = sub, dom, obj, act
+
+[policy_definition]
+p = sub, dom, obj, act
+
+[role_definition]
+g = _, _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = g(r.sub, p.sub, r.dom) && isDescendant(r.dom, p.dom) && keyMatch2(r.obj,
p.obj) && r.act == p.act
+```
+
+`isDescendant(child, parent)` is your function:
+
+```go
+func isDescendant(domain, ancestor string) bool {
+ return domain == ancestor || strings.HasPrefix(domain, ancestor+"/")
+}
+```
+
+Register it twice in Go: once for the matcher, once for the role manager's
domain matching.
+
+```go
+e.AddFunction("isDescendant", func(args ...interface{}) (interface{}, error) {
+ return isDescendant(args[0].(string), args[1].(string)), nil
+})
+e.AddNamedDomainMatchingFunc("g", "isDescendant", isDescendant)
+```
+
+Do this before the first `Enforce()`.
+
+## Policy
+
+```csv
+p, admin, /acme, /acme/*, read
+p, editor, /acme/eng, /acme/eng/*, write
+
+g, alice, admin, /acme
+g, bob, editor, /acme/eng
+```
+
+`alice` is an admin at `/acme`; she can read anything under `/acme`, including
`/acme/eng/backend`. `bob` is an editor at `/acme/eng`; he can write under
`/acme/eng`, but not at `/acme` itself and not under a sibling unit.
+
+```go
+e.Enforce("alice", "/acme/eng/backend", "/acme/eng/backend/doc1", "read") //
true
+e.Enforce("alice", "/other", "/other/doc1", "read")
// false
+e.Enforce("bob", "/acme", "/acme/doc1", "write")
// false
+e.Enforce("bob", "/acme/eng/backend", "/acme/eng/backend/doc1", "write")
// true
+```
+
+Why both `g(...)` and `isDescendant(...)`:
+
+- `g(r.sub, p.sub, r.dom)` answers "does the user have this role in this
unit?". The domain matching function makes the role manager include role links
granted at ancestor units, so one `g` rule at `/acme` covers the whole subtree.
+- `isDescendant(r.dom, p.dom)` answers "is this policy's unit the request's
unit or one of its ancestors?". Without it, a policy at a child unit could
match a request at the parent.
+- `keyMatch2(r.obj, p.obj)` scopes the object to the subtree; `/acme/*`
matches `/acme/eng/backend/doc1`. The unit node itself (`/acme`) does not match
`/acme/*`; if the unit is also a resource, add an exact rule for it.
+
+## Variant: pattern domains with `keyMatch2`
+
+If you do not want a custom function, store the pattern in the role and policy
domain and use the built-in `keyMatch2` as the domain matching function:
+
+```ini
+[matchers]
+m = g(r.sub, p.sub, r.dom) && keyMatch2(r.dom, p.dom) && keyMatch2(r.obj,
p.obj) && r.act == p.act
+```
+
+```csv
+p, admin, /acme/*, /acme/*, read
+g, alice, admin, /acme/*
+```
+
+```go
+e.AddNamedDomainMatchingFunc("g", "keyMatch2", util.KeyMatch2)
+```
+
+This has the same subtree behavior, except that `/acme` itself does not match
`/acme/*`; grant the root separately if it is a resource. Choose this variant
if you prefer built-in functions; choose the `isDescendant` variant if you want
`g, alice, admin, /acme` to read naturally.
+
+## Other languages
+
+| Language | Register the domain matching function |
+|----------|----------------------------------------|
+| Go | `e.AddNamedDomainMatchingFunc("g", "isDescendant", isDescendant)` |
+| Python | `e.add_named_domain_matching_func("g", is_descendant)` |
+| Node.js | `await e.addNamedDomainMatchingFunc("g", isDescendant)` |
+| Java | `e.addNamedDomainMatchingFunc("g", "isDescendant",
this::isDescendant)` |
+
+The matcher function is registered with the usual custom-function API of each
port (`AddFunction`, `add_function`, `addFunction`, `addFunction`).
+
+## Delegated administration
+
+Casbin decides access; it does not manage roles. If each unit has an admin who
may grant roles only inside their subtree, enforce that with a second policy:
+
+```csv
+p, unit-admin, /acme/eng, /acme/eng/*, manage
+g, carol, unit-admin, /acme/eng
+```
+
+Before writing a role binding in unit `target`, check `unit-admin` on `target`
with the same `isDescendant` model. That keeps the "cannot grant above or
beside your own unit" rule in policy instead of in application code.
+
+## Scale notes
+
+- The role manager keeps one role-manager entry per domain it has seen. A tree
with tens of thousands of units means tens of thousands of entries; measure
before you model a very large tree this way.
+- Domain matching is applied when a domain's role manager is first built, then
cached; the cost is not paid on every `Enforce()`.
+- Run a synchronized enforcer in a multi-threaded server and keep replicas in
sync with a watcher; see [Production Deployment](/docs/production-deployment).
+
+## See also
+
+- [RBAC with Domains](/docs/rbac-with-domains)
+- [List Filtering](/docs/list-filtering)
+- [Data Permissions](/docs/data-permissions)
diff --git a/docs/RBACWithDomains.mdx b/docs/RBACWithDomains.mdx
index f9a3e2ca9..0457618f0 100644
--- a/docs/RBACWithDomains.mdx
+++ b/docs/RBACWithDomains.mdx
@@ -78,3 +78,7 @@ users := e.GetAllUsersByDomain("domain1") // without
SetFieldIndex, this will ra
```
:::
+
+## Nested units
+
+If the domains form a tree (a company contains divisions and departments) and
a role granted at a unit should apply to its whole subtree, see [Nested
Organizations](/docs/nested-organizations).
diff --git a/sidebars.js b/sidebars.js
index dbef32920..601e86c8e 100644
--- a/sidebars.js
+++ b/sidebars.js
@@ -103,6 +103,8 @@ module.exports = {
link: {type: "generated-index"},
items: [
"data-permissions",
+ "nested-organizations",
+ "list-filtering",
"menu-permissions",
],
},
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]