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-casbin.js.git


The following commit(s) were added to refs/heads/master by this push:
     new 5cdb4a4  feat: support users with multiple roles
5cdb4a4 is described below

commit 5cdb4a4cd126b17496420649192b8cbe014d7b12
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 6 21:06:15 2026 +0800

    feat: support users with multiple roles
---
 README.md                      |  28 +++++++++++
 src/Authorizer.ts              |  71 +++++++++++++++++++++++---
 src/Permission.ts              |   6 +++
 src/__test__/multirole.test.ts | 110 +++++++++++++++++++++++++++++++++++++++++
 4 files changed, 209 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md
index 24776d1..8fa0fc6 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,34 @@ authorizer.can("read", "data1").then(result => {
 });
 ```
 
+### Users with several roles
+
+A user may hold any number of roles at the same time, and a role may inherit 
from
+another role. Casbin.js evaluates the `g` rules with the same RBAC engine as 
the
+backend, so the user gets the permissions of every one of those roles. This 
needs
+the model and policies described above: the plain `{action: [objects]}` map 
holds no
+roles at all, only the flattened result.
+```javascript
+// g, alice, admin
+// g, alice, editor
+// g, admin, root
+const authorizer = new casbinjs.Authorizer("manual");
+authorizer.setPermission(responseFromApi);
+await authorizer.setUser("alice");
+
+// Granted by "admin", by "editor", and by the "root" role that "admin" 
inherits.
+await authorizer.can("read", "data1");
+
+// ["admin", "editor", "root"]
+await authorizer.getRoles();
+
+// [["admin", "data1", "read"], ["editor", "data2", "write"], ...]
+await authorizer.getImplicitPermissions();
+```
+
+For a model with domains, pass the domain to each of them: 
`authorizer.can("read", "data1", "domain1")`,
+`authorizer.getRoles("domain1")`.
+
 You can also use the `auto` mode. In details, specify a casbin backend service 
endpoint when initializing the Casbin.js authorizer, and set the subject when 
the frontend user identity changes. Casbin.js will automatically fetch the 
permission from the endpoint. (A pre-configurated casbin service API is 
required at the backend.)
 ```javascript
 const casbinjs = require('casbin.js');
diff --git a/src/Authorizer.ts b/src/Authorizer.ts
index 86b8a85..c03ac3e 100644
--- a/src/Authorizer.ts
+++ b/src/Authorizer.ts
@@ -102,6 +102,10 @@ export class Authorizer {
             this.enforcerPromise.catch(() => undefined);
             return;
         }
+        // A previously built enforcer takes precedence in "can", so it has to 
go
+        // when the permission is replaced by a plain action-to-objects map.
+        this.enforcer = undefined;
+        this.enforcerPromise = undefined;
         if (this.permission === undefined) {
             this.permission = new Permission();
         }
@@ -113,18 +117,25 @@ export class Authorizer {
         if (!('m' in obj)) {
             throw Error("No model when init enforcer.");
         }
+        // Drop the previous state first, so a failed or half-finished load 
never
+        // leaves the permissions of the former identity in place.
+        this.enforcer = undefined;
+        this.permission = undefined;
         const m = new casbin.Model(obj['m']);
-        this.enforcer = await casbin.newEnforcer(m);
+        const enforcer = await casbin.newEnforcer(m);
         // "CasbinJsGetPermissionForUser" puts the policies under "p" and the 
grouping
-        // policies under "g", each rule prefixed with its own ptype.
+        // policies under "g", each rule prefixed with its own ptype. The 
grouping
+        // policies carry the role assignments, including the several roles a 
single
+        // user may have, and any role that inherits from another role.
         for (const section of ['p', 'g']) {
             if (section in obj) {
-                await this.loadRules(obj[section]);
+                await this.loadRules(enforcer, obj[section]);
             }
         }
+        this.enforcer = enforcer;
     }
 
-    private async loadRules(rules: unknown): Promise<void> {
+    private async loadRules(enforcer: casbin.Enforcer, rules: unknown): 
Promise<void> {
         if (!Array.isArray(rules)) {
             return;
         }
@@ -138,9 +149,9 @@ export class Authorizer {
                 continue;
             }
             if (pType.startsWith('g')) {
-                await (this.enforcer as 
casbin.Enforcer).addNamedGroupingPolicy(pType, ...arr);
+                await enforcer.addNamedGroupingPolicy(pType, ...arr);
             } else {
-                await (this.enforcer as casbin.Enforcer).addNamedPolicy(pType, 
...arr);
+                await enforcer.addNamedPolicy(pType, ...arr);
             }
         }
     }
@@ -181,11 +192,59 @@ export class Authorizer {
         }
     }
 
+    /**
+     * Get every role of the current user, the roles inherited from another 
role
+     * included. Only available when the permission was loaded from the data of
+     * "CasbinJsGetPermissionForUser", since a plain action-to-objects map 
holds
+     * no roles.
+     * @param domain The domain, for a model with domains
+     */
+    public async getRoles(domain?: string): Promise<string[]> {
+        const enforcer = await this.getEnforcer();
+        const user = this.requireUser();
+        return domain === undefined
+            ? await enforcer.getImplicitRolesForUser(user)
+            : await enforcer.getImplicitRolesForUser(user, domain);
+    }
+
+    /**
+     * Get every permission of the current user, the ones coming from the 
user's
+     * roles included. Same requirement as "getRoles".
+     * @param domain The domain, for a model with domains
+     */
+    public async getImplicitPermissions(domain?: string): Promise<string[][]> {
+        const enforcer = await this.getEnforcer();
+        const user = this.requireUser();
+        return domain === undefined
+            ? await enforcer.getImplicitPermissionsForUser(user)
+            : await enforcer.getImplicitPermissionsForUser(user, domain);
+    }
+
+    private async getEnforcer(): Promise<casbin.Enforcer> {
+        if (this.enforcerPromise !== undefined) {
+            await this.enforcerPromise;
+        }
+        if (this.enforcer === undefined) {
+            throw Error("Enforcer not initialized. Roles are only available 
when the permission " +
+                "is the model and policies of 
\"CasbinJsGetPermissionForUser\".");
+        }
+        return this.enforcer;
+    }
+
+    private requireUser(): string {
+        if (this.user === undefined) {
+            throw Error("User is not defined. Call \"setUser\" before 
evaluating the permission.");
+        }
+        return this.user;
+    }
+
     public async can(action: string, object: string, domain?: string): 
Promise<boolean> {
         if (this.enforcerPromise !== undefined) {
             await this.enforcerPromise;
         }
         if (this.enforcer !== undefined) {
+            // Without a subject every request would silently be denied.
+            this.requireUser();
             if (domain == undefined) {
                 return await this.enforcer.enforce(this.user, object, action);
             } else {
diff --git a/src/Permission.ts b/src/Permission.ts
index 4e3b947..f31a9dc 100644
--- a/src/Permission.ts
+++ b/src/Permission.ts
@@ -17,6 +17,12 @@ export default class Permission {
             p = permission as StringKV;
         }
 
+        // A load replaces the permission, it does not merge into the previous 
one.
+        // Otherwise the permissions of a former user or role would leak into 
the
+        // current one when the identity changes.
+        this.actObjData.clear();
+        this.objActData.clear();
+
         // Generate data: {key:Actions, value: Array of objects}
         for (const act in p) {
             this.actObjData.set(act, p[act] as Array<string>);
diff --git a/src/__test__/multirole.test.ts b/src/__test__/multirole.test.ts
new file mode 100644
index 0000000..4271f5d
--- /dev/null
+++ b/src/__test__/multirole.test.ts
@@ -0,0 +1,110 @@
+import { Authorizer } from "../Authorizer";
+import { rbacModelStr, rbacWithDomainsModelStr } from './models';
+
+// A user with several roles at once, plus a role that inherits from another 
role.
+const multiRoleData = {
+    m: rbacModelStr,
+    p: [
+        ["p", "admin", "data1", "read"],
+        ["p", "editor", "data2", "write"],
+        ["p", "root", "data3", "read"],
+    ],
+    g: [
+        ["g", "alice", "admin"],
+        ["g", "alice", "editor"],
+        ["g", "admin", "root"],
+        ["g", "bob", "editor"],
+    ],
+};
+
+test('A user holding several roles gets the permissions of all of them', async 
() => {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission(multiRoleData);
+    await authorizer.setUser("alice");
+
+    // From "admin", from "editor" and from "root" which "admin" inherits.
+    expect(await authorizer.can("read", "data1")).toBe(true);
+    expect(await authorizer.can("write", "data2")).toBe(true);
+    expect(await authorizer.can("read", "data3")).toBe(true);
+    expect(await authorizer.can("write", "data1")).toBe(false);
+
+    // Only "editor", so neither the "admin" nor the inherited "root" 
permissions.
+    await authorizer.setUser("bob");
+    expect(await authorizer.can("write", "data2")).toBe(true);
+    expect(await authorizer.can("read", "data1")).toBe(false);
+    expect(await authorizer.can("read", "data3")).toBe(false);
+});
+
+test('The roles of a user are listed, the inherited ones included', async () 
=> {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission(multiRoleData);
+    await authorizer.setUser("alice");
+
+    expect((await authorizer.getRoles()).sort()).toEqual(["admin", "editor", 
"root"]);
+    expect(await 
authorizer.getImplicitPermissions()).toEqual(expect.arrayContaining([
+        ["admin", "data1", "read"],
+        ["editor", "data2", "write"],
+        ["root", "data3", "read"],
+    ]));
+});
+
+test('A user holding several roles in several domains', async () => {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission({
+        m: rbacWithDomainsModelStr,
+        p: [
+            ["p", "admin", "domain1", "data1", "read"],
+            ["p", "editor", "domain1", "data2", "write"],
+            ["p", "admin", "domain2", "data3", "read"],
+        ],
+        g: [
+            ["g", "alice", "admin", "domain1"],
+            ["g", "alice", "editor", "domain1"],
+            ["g", "alice", "admin", "domain2"],
+        ],
+    });
+    await authorizer.setUser("alice");
+
+    expect(await authorizer.can("read", "data1", "domain1")).toBe(true);
+    expect(await authorizer.can("write", "data2", "domain1")).toBe(true);
+    expect(await authorizer.can("read", "data3", "domain2")).toBe(true);
+    // The roles of a domain do not leak into another one.
+    expect(await authorizer.can("read", "data1", "domain2")).toBe(false);
+    expect((await authorizer.getRoles("domain1")).sort()).toEqual(["admin", 
"editor"]);
+    expect(await authorizer.getRoles("domain2")).toEqual(["admin"]);
+});
+
+test('Setting the permission again replaces the former one', async () => {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission({ "read": ["data1", "data2"], "write": ["data1"] 
});
+    expect(await authorizer.can("write", "data1")).toBe(true);
+
+    authorizer.setPermission({ "read": ["data1"] });
+    expect(await authorizer.can("read", "data1")).toBe(true);
+    expect(await authorizer.can("read", "data2")).toBe(false);
+    expect(await authorizer.can("write", "data1")).toBe(false);
+});
+
+test('Switching from the model and policies back to an action map', async () 
=> {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission(multiRoleData);
+    await authorizer.setUser("alice");
+    expect(await authorizer.can("read", "data1")).toBe(true);
+
+    authorizer.setPermission({ "read": ["data2"] });
+    expect(await authorizer.can("read", "data1")).toBe(false);
+    expect(await authorizer.can("read", "data2")).toBe(true);
+});
+
+test('Evaluating without a user is an error instead of a silent denial', async 
() => {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission(multiRoleData);
+    await expect(authorizer.can("read", "data1")).rejects.toThrow(/User is not 
defined/);
+});
+
+test('Roles are unavailable when the permission is a plain action map', async 
() => {
+    const authorizer = new Authorizer("manual");
+    authorizer.setPermission({ "read": ["data1"] });
+    await authorizer.setUser("alice");
+    await expect(authorizer.getRoles()).rejects.toThrow(/Enforcer not 
initialized/);
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to