Skip to content

Commit 80648e0

Browse files
committed
fix: match wildcard domains in getImplicitPermissionsForUser
1 parent 0a8629a commit 80648e0

7 files changed

Lines changed: 336 additions & 18 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[request_definition]
2+
r = sub, obj, act, dom
3+
4+
[policy_definition]
5+
p = sub, obj, act, dom, eft
6+
7+
[role_definition]
8+
g = _, _, _
9+
10+
[policy_effect]
11+
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
12+
13+
[matchers]
14+
m = g(r.sub, p.sub, r.dom) && (p.dom == "*" || r.dom == p.dom) && r.obj == p.obj && r.act == p.act
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
p, abstract_roles1, devis, read, *, allow
2+
p, abstract_roles1, devis, create, *, allow
3+
p, abstract_roles2, devis, read, *, allow
4+
p, abstract_roles2, organization, read, *, allow
5+
p, abstract_roles2, organization, write, *, allow
6+
p, roles1, devis, delete, tenant1, allow
7+
8+
g, roles1, abstract_roles1, tenant1
9+
g, roles1, abstract_roles1, tenant2
10+
g, roles2, abstract_roles2, tenant1
11+
g, roles2, abstract_roles2, tenant2
12+
g, super_user, abstract_roles2, *
13+
14+
g, michael, roles1, tenant1
15+
g, thomas, roles2, tenant2
16+
g, theo, super_user, *

src/enforcer.ts

Lines changed: 113 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -356,22 +356,55 @@ export class Enforcer extends ManagementEnforcer {
356356
* But getImplicitRolesForUser("alice") will get: ["role:admin", "role:user"].
357357
*/
358358
public async getImplicitRolesForUser(name: string, ...domain: string[]): Promise<string[]> {
359-
const res = new Set<string>();
359+
const res: string[] = [];
360+
361+
// Each role definition is a hierarchy of its own, so every role manager is walked
362+
// separately and the results are concatenated. Following a "g" link and then a "g2"
363+
// link off the role it led to would mix two unrelated hierarchies.
364+
for (const ptype of this.rmMap.keys()) {
365+
res.push(...(await this.getNamedImplicitRolesForUser(ptype, name, ...domain)));
366+
}
367+
368+
return res;
369+
}
370+
371+
/**
372+
* getNamedImplicitRolesForUser gets implicit roles that a user has, using only the
373+
* given role definition. Compared to getImplicitRolesForUser(), which walks every
374+
* role manager, this one is restricted to "g", "g2", ...
375+
*
376+
* @param ptype the role definition type, can be "g", "g2", "g3", ..
377+
* @param name the user.
378+
* @param domain the domain, optional.
379+
*/
380+
public async getNamedImplicitRolesForUser(ptype: string, name: string, ...domain: string[]): Promise<string[]> {
381+
const rm = this.rmMap.get(ptype);
382+
if (!rm) {
383+
throw new Error(`role manager ${ptype} is not initialized`);
384+
}
385+
386+
if (rm.getImplicitRoles) {
387+
return rm.getImplicitRoles(name, ...domain);
388+
}
389+
390+
// Fallback for role managers that only expose one hop. It cannot honour a hierarchy
391+
// level limit, since that limit belongs to the role manager.
392+
const res: string[] = [];
393+
const visited = new Set<string>([name]);
360394
const q = [name];
361395
let n: string | undefined;
362396
while ((n = q.shift()) !== undefined) {
363-
for (const rm of this.rmMap.values()) {
364-
const role = await rm.getRoles(n, ...domain);
365-
role.forEach((r) => {
366-
if (!res.has(r)) {
367-
res.add(r);
368-
q.push(r);
369-
}
370-
});
371-
}
397+
const roles = await rm.getRoles(n, ...domain);
398+
roles.forEach((r) => {
399+
if (!visited.has(r)) {
400+
visited.add(r);
401+
res.push(r);
402+
q.push(r);
403+
}
404+
});
372405
}
373406

374-
return Array.from(res);
407+
return res;
375408
}
376409

377410
/**
@@ -386,16 +419,78 @@ export class Enforcer extends ManagementEnforcer {
386419
* But getImplicitPermissionsForUser("alice") will get: [["admin", "data1", "read"], ["alice", "data2", "read"]].
387420
*/
388421
public async getImplicitPermissionsForUser(user: string, ...domain: string[]): Promise<string[][]> {
389-
const roles = await this.getImplicitRolesForUser(user, ...domain);
390-
roles.unshift(user);
391-
const res: string[][] = [];
422+
return this.getNamedImplicitPermissionsForUser('p', 'g', user, ...domain);
423+
}
392424

393-
for (const n of roles) {
394-
const p = await this.getPermissionsForUser(n, ...domain);
395-
res.push(...p);
425+
/**
426+
* getNamedImplicitPermissionsForUser gets implicit permissions for a user or role
427+
* by the named policy and the named role definition.
428+
*
429+
* When a domain is given, a policy rule is kept if its domain field matches that
430+
* domain according to the role manager, so a rule written for a wildcard domain
431+
* (e.g. "p, admin, data, read, *") is reported for every concrete domain once a
432+
* domain matching function has been registered with addNamedDomainMatchingFunc().
433+
* The returned rule then carries the requested domain instead of the pattern.
434+
*
435+
* @param ptype the policy type, can be "p", "p2", "p3", ..
436+
* @param gtype the role definition type, can be "g", "g2", "g3", ..
437+
* @param user the user.
438+
* @param domain the domain, optional.
439+
*/
440+
public async getNamedImplicitPermissionsForUser(ptype: string, gtype: string, user: string, ...domain: string[]): Promise<string[][]> {
441+
if (domain.length > 1) {
442+
throw new Error('error: domain should be 1 parameter');
396443
}
397444

398-
return res;
445+
const rm = this.rmMap.get(gtype);
446+
if (!rm) {
447+
throw new Error(`role manager ${gtype} is not initialized`);
448+
}
449+
450+
const roles = await this.getNamedImplicitRolesForUser(gtype, user, ...domain);
451+
const policyRoles = new Set<string>(roles);
452+
policyRoles.add(user);
453+
454+
// The subject and the domain are not necessarily the first two tokens, so both
455+
// have to be looked up in the model instead of being assumed to sit at a fixed index.
456+
const subIndex = this.getFieldIndex(ptype, FieldIndex.Subject);
457+
if (subIndex === -1) {
458+
throw new Error(`${FieldIndex.Subject} index is not set, please use enforcer.setFieldIndex() to set index`);
459+
}
460+
461+
const permission: string[][] = [];
462+
const policy = await this.getNamedPolicy(ptype);
463+
464+
if (domain.length === 0) {
465+
for (const rule of policy) {
466+
if (policyRoles.has(rule[subIndex])) {
467+
permission.push([...rule]);
468+
}
469+
}
470+
return permission;
471+
}
472+
473+
const domIndex = this.getFieldIndex(ptype, FieldIndex.Domain);
474+
if (domIndex === -1) {
475+
throw new Error(`${FieldIndex.Domain} index is not set, please use enforcer.setFieldIndex() to set index`);
476+
}
477+
478+
const d = domain[0];
479+
for (const rule of policy) {
480+
// match() falls back to an exact comparison unless a domain matching function
481+
// has been registered, so a "*" rule only spreads across domains on request.
482+
const matched = rm.match ? rm.match(d, rule[domIndex]) : d === rule[domIndex];
483+
if (!matched) {
484+
continue;
485+
}
486+
if (policyRoles.has(rule[subIndex])) {
487+
const newRule = [...rule];
488+
newRule[domIndex] = d;
489+
permission.push(newRule);
490+
}
491+
}
492+
493+
return permission;
399494
}
400495

401496
/**

src/rbac/defaultRoleManager.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ export class DefaultRoleManager implements RoleManager {
181181
this.domainMatchingFunc = fn;
182182
}
183183

184+
/**
185+
* match determines whether the domain str is covered by the domain pattern used
186+
* in a policy or grouping rule. Without a domain matching function registered,
187+
* only an exact match counts, which keeps the default behaviour unchanged.
188+
*
189+
* @param str the concrete domain, e.g. "tenant1"
190+
* @param pattern the domain written in the rule, e.g. "*"
191+
*/
192+
public match(str: string, pattern: string): boolean {
193+
if (str === pattern) {
194+
return true;
195+
}
196+
if (this.hasDomainPattern) {
197+
return this.domainMatchingFunc(str, pattern);
198+
}
199+
return false;
200+
}
201+
184202
/**
185203
* addDomainHierarchy sets a rolemanager to define role inheritance between domains
186204
* @param rm RoleManager to define domain hierarchy
@@ -319,6 +337,51 @@ export class DefaultRoleManager implements RoleManager {
319337
return allRoles.createRole(name, this.matchingFunc).getRoles();
320338
}
321339

340+
/**
341+
* getImplicitRoles gets the roles that a subject inherits, directly or through other
342+
* roles. Compared to getRoles(), which only walks one hop, this follows the whole
343+
* hierarchy, up to maxHierarchyLevel hops.
344+
* domain is a prefix to the roles.
345+
*/
346+
public async getImplicitRoles(name: string, ...domain: string[]): Promise<string[]> {
347+
if (domain.length === 0) {
348+
domain = [DEFAULT_DOMAIN];
349+
} else if (domain.length > 1) {
350+
throw new Error('error: domain should be 1 parameter');
351+
}
352+
353+
// The role graph only has to be resolved once for the whole traversal.
354+
const allRoles = this.generateTempRoles(domain[0]);
355+
356+
const res: string[] = [];
357+
// Seeded with the subject so that a cycle back to it does not report the subject as
358+
// one of its own roles, and so that the traversal terminates.
359+
const roleSet = new Set<string>([name]);
360+
let current = [name];
361+
362+
for (let level = 0; level < this.maxHierarchyLevel && current.length > 0; level++) {
363+
const next: string[] = [];
364+
for (const n of current) {
365+
if (!allRoles.hasRole(n, this.matchingFunc)) {
366+
continue;
367+
}
368+
allRoles
369+
.createRole(n, this.matchingFunc)
370+
.getRoles()
371+
.forEach((r) => {
372+
if (!roleSet.has(r)) {
373+
roleSet.add(r);
374+
res.push(r);
375+
next.push(r);
376+
}
377+
});
378+
}
379+
current = next;
380+
}
381+
382+
return res;
383+
}
384+
322385
/**
323386
* getUsers gets the users that inherits a subject.
324387
* domain is an unreferenced parameter here, may be used in other implementations.

src/rbac/roleManager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,21 @@ export interface RoleManager {
3131
// GetRoles gets the roles that a user inherits.
3232
// domain is a prefix to the roles (can be used for other purposes).
3333
getRoles(name: string, ...domain: string[]): Promise<string[]>;
34+
// GetImplicitRoles gets the roles that a user inherits, directly or through other roles,
35+
// respecting the hierarchy level limit of the implementation.
36+
// domain is a prefix to the roles (can be used for other purposes).
37+
// Optional: when not implemented, callers fall back to walking getRoles() themselves,
38+
// which cannot honour a hierarchy level limit.
39+
getImplicitRoles?(name: string, ...domain: string[]): Promise<string[]>;
3440
// GetUsers gets the users that inherits a role.
3541
// domain is a prefix to the users (can be used for other purposes).
3642
getUsers(name: string, ...domain: string[]): Promise<string[]>;
3743
// PrintRoles prints all the roles to log.
3844
printRoles(): Promise<void>;
45+
// Match determines whether the domain str is covered by the domain pattern
46+
// written in a policy or grouping rule, e.g. "tenant1" against "*".
47+
// Optional: when not implemented, callers fall back to an exact comparison.
48+
match?(str: string, pattern: string): boolean;
3949
// GetDomains gets domains that a user has
4050
getDomains(name: string): Promise<string[]>;
4151
// GetAllDomains gets all domains

test/rbac/defaultRoleManager.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,47 @@ test('TestAllMatchingFunc', async () => {
1212
// *:/book/:id
1313
expect(await rm.hasLink('/book/1', 'book_group', 'domain1')).toBe(true);
1414
});
15+
16+
test('TestGetImplicitRoles', async () => {
17+
const rm = new DefaultRoleManager(10);
18+
await rm.addLink('alice', 'admin');
19+
await rm.addLink('admin', 'data1_admin');
20+
await rm.addLink('admin', 'data2_admin');
21+
22+
expect(await rm.getRoles('alice')).toEqual(['admin']);
23+
expect(await rm.getImplicitRoles('alice')).toEqual(['admin', 'data1_admin', 'data2_admin']);
24+
expect(await rm.getImplicitRoles('bob')).toEqual([]);
25+
26+
// A cycle terminates and never reports the subject as one of its own roles.
27+
await rm.addLink('data2_admin', 'alice');
28+
expect(await rm.getImplicitRoles('alice')).toEqual(['admin', 'data1_admin', 'data2_admin']);
29+
});
30+
31+
test('TestGetImplicitRolesRespectsMaxHierarchyLevel', async () => {
32+
// role0 -> role1 -> ... -> role5
33+
const chain = async (rm: DefaultRoleManager): Promise<void> => {
34+
for (let i = 0; i < 5; i++) {
35+
await rm.addLink(`role${i}`, `role${i + 1}`);
36+
}
37+
};
38+
39+
const rm = new DefaultRoleManager(10);
40+
await chain(rm);
41+
expect(await rm.getImplicitRoles('role0')).toEqual(['role1', 'role2', 'role3', 'role4', 'role5']);
42+
43+
// Only maxHierarchyLevel hops are followed, so the tail of the chain is cut off.
44+
const shallow = new DefaultRoleManager(2);
45+
await chain(shallow);
46+
expect(await shallow.getImplicitRoles('role0')).toEqual(['role1', 'role2']);
47+
});
48+
49+
test('TestGetImplicitRolesWithDomain', async () => {
50+
const rm = new DefaultRoleManager(10);
51+
await rm.addLink('alice', 'admin', 'domain1');
52+
await rm.addLink('admin', 'data1_admin', 'domain1');
53+
await rm.addLink('alice', 'guest', 'domain2');
54+
55+
expect(await rm.getImplicitRoles('alice', 'domain1')).toEqual(['admin', 'data1_admin']);
56+
expect(await rm.getImplicitRoles('alice', 'domain2')).toEqual(['guest']);
57+
expect(await rm.getImplicitRoles('alice', 'domain3')).toEqual([]);
58+
});

0 commit comments

Comments
 (0)