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]