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]

Reply via email to