Skip to content

Commit

Permalink
feat(core): add local resource and role inheritance
Browse files Browse the repository at this point in the history
  • Loading branch information
draconisNoctis committed Dec 11, 2024
1 parent 835a374 commit 6888fde
Show file tree
Hide file tree
Showing 6 changed files with 90 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/large-weeks-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@a38/core': patch
---

Add local resource and role inheritance
23 changes: 16 additions & 7 deletions pkg/core/src/child-node.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export type SerializedChildNodes = [id: string, parents: string[]][];

export abstract class ChildNode<T, S extends SerializedChildNodes> {
export abstract class ChildNode<T extends { parents?: T[] }, S extends SerializedChildNodes> {
protected parents = new Map<string, string[]>();

protected abstract assertEntryId(entryOrId: T | string): string;
Expand All @@ -11,10 +11,18 @@ export abstract class ChildNode<T, S extends SerializedChildNodes> {
return this.parents.has(id);
}

getParents(entryOrId: T | string): string[] {
getParents(entryOrId: T | string): (T | string)[] {
const id = this.assertEntryId(entryOrId);

const parents = this.parents.get(id) ?? this.setParents(id, []);
const parents: (T | string)[] = [];

if (typeof entryOrId !== 'string' && entryOrId.parents) {
parents.push(...entryOrId.parents);
}

if (this.parents.has(id)) {
parents.push(...this.parents.get(id)!);
}

return parents;
}
Expand All @@ -27,7 +35,8 @@ export abstract class ChildNode<T, S extends SerializedChildNodes> {
}

addParents(entryOrId: T | string, parents: string[]) {
const currentParents = this.getParents(entryOrId);
const currentParents = this.parents.get(this.assertEntryId(entryOrId)) ?? [];
this.parents.set(this.assertEntryId(entryOrId), currentParents);

for (const parent of parents) {
if (!currentParents.includes(parent)) {
Expand All @@ -37,14 +46,14 @@ export abstract class ChildNode<T, S extends SerializedChildNodes> {
}

getParentsRecursive(entryOrId: T | string): string[] {
const stack = [this.assertEntryId(entryOrId)];
const stack = [entryOrId];
const result: string[] = [];

while (true) {
const e = stack.pop();
if (undefined === e) break;
if (result.includes(e)) continue;
result.push(e);
if (result.includes(this.assertEntryId(e))) continue;
result.push(this.assertEntryId(e));

stack.push(...this.getParents(e).toReversed());
}
Expand Down
58 changes: 54 additions & 4 deletions pkg/core/src/hrbac.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('HRBAC', () => {
expect(await hrbac.isAllowed('guest', documentA, 'update')).toBeFalse();
expect(await hrbac.isDenied('guest', documentA, 'update')).toBeTrue();
});

it('admin', async () => {
expect(await hrbac.isAllowed(admin, 'settings')).toBeTrue();
expect(await hrbac.isDenied(admin, 'settings')).toBeFalse();
Expand Down Expand Up @@ -131,12 +132,61 @@ describe('HRBAC', () => {
});
});

it('should support resource inheritance', async () => {
it('should support global resource inheritance', async () => {
hrbac = new HRBAC(new RoleManager(), new ResourceManager(), new PermissionManager());
hrbac.getResourceManager().addParents('parent-resource', ['grand-parent-resource']);
hrbac.getResourceManager().addParents('child-resource', ['parent-resource']);
hrbac.getPermissionManager().deny();
hrbac.getPermissionManager().allow('role1', 'parent-resource');
hrbac.getPermissionManager().allow('role2', 'grand-parent-resource');

expect(await hrbac.isAllowed('role1', 'child-resource')).toBeTrue();
expect(await hrbac.isAllowed('role2', 'child-resource')).toBeTrue();
});

it('should support local resource inheritance', async () => {
hrbac = new HRBAC(new RoleManager(), new ResourceManager(), new PermissionManager());
hrbac.getPermissionManager().deny();
hrbac.getPermissionManager().allow('role1', 'parent-resource');
hrbac.getPermissionManager().allow('role2', 'grand-parent-resource');

expect(
await hrbac.isAllowed(
'role1',
new Resource('child-resource', [new Resource('parent-resource', [new Resource('grand-parent-resource')])])
)
).toBeTrue();
expect(
await hrbac.isAllowed(
'role2',
new Resource('child-resource', [new Resource('parent-resource', [new Resource('grand-parent-resource')])])
)
).toBeTrue();
});

it('should support global role inheritance', async () => {
hrbac = new HRBAC(new RoleManager(), new ResourceManager(), new PermissionManager());
hrbac.getResourceManager().addParents('child', ['parent']);
hrbac.getRoleManager().addParents('parent-role', ['grand-parent-role']);
hrbac.getRoleManager().addParents('child-role', ['parent-role']);
hrbac.getPermissionManager().deny();
hrbac.getPermissionManager().allow('role', 'parent');
hrbac.getPermissionManager().allow('parent-role', 'resource1');
hrbac.getPermissionManager().allow('grand-parent-role', 'resource2');

expect(await hrbac.isAllowed('role', 'child')).toBeTrue();
expect(await hrbac.isAllowed('child-role', 'resource1')).toBeTrue();
expect(await hrbac.isAllowed('child-role', 'resource2')).toBeTrue();
});

it('should support local role inheritance', async () => {
hrbac = new HRBAC(new RoleManager(), new ResourceManager(), new PermissionManager());
hrbac.getPermissionManager().deny();
hrbac.getPermissionManager().allow('parent-role', 'resource1');
hrbac.getPermissionManager().allow('grand-parent-role', 'resource2');

expect(
await hrbac.isAllowed(new Role('child-role', [new Role('parent-role', [new Role('grand-parent-role')])]), 'resource1')
).toBeTrue();
expect(
await hrbac.isAllowed(new Role('child-role', [new Role('parent-role', [new Role('grand-parent-role')])]), 'resource2')
).toBeTrue();
});
});
16 changes: 7 additions & 9 deletions pkg/core/src/permission-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ export class ResourceRuleMap implements Iterable<[string | null, Rule[]]> {
}
}

// export type SerializedRoleResourceRuleMap = [roleId: string | null, rrmap: SerializedResourceRuleMap][];

export type SerializedPermissions = (readonly [roleId: string | null, resourceId: string | null, rule: SerializedRule])[];

export class RoleResourceRuleMap implements Iterable<[string | null, ResourceRuleMap]> {
Expand Down Expand Up @@ -148,21 +146,21 @@ export class RoleResourceRuleMap implements Iterable<[string | null, ResourceRul
if ((role !== null && typeof role !== 'string') || (resource !== null && typeof resource !== 'string')) {
throw new Error(`Invalid serialize [RoleResourceRuleMap] entry: ${JSON.stringify(entry)}`);
}
this.get(role).add(resource, Rule.fromJSON(rule)); //importJSON(rrMap);
this.get(role).add(resource, Rule.fromJSON(rule));
}
return this;
}
}

export class PermissionManager {
private rrrm = new RoleResourceRuleMap();
private roleResourceRuleMap = new RoleResourceRuleMap();

*getRules(roles: string[], resources: string[]): Generator<Rule, void, undefined> {
const roleSet = new Set(roles);
const resourceSet = new Set(resources);
for (const [role, rrMap] of this.rrrm) {
for (const [role, resourceRuleMap] of this.roleResourceRuleMap) {
if (!(role === null || roleSet.has(role))) continue;
for (const [resource, rules] of rrMap) {
for (const [resource, rules] of resourceRuleMap) {
if (!(resource === null || resourceSet.has(resource))) continue;
yield* rules;
}
Expand All @@ -179,7 +177,7 @@ export class PermissionManager {
const roleId = null == role ? null : assertRoleId(role);
const resourceId = null == resource ? null : assertResourceId(resource);

this.rrrm.get(roleId).add(resourceId, new Rule(type, privileges && new Set(privileges), assertion));
this.roleResourceRuleMap.get(roleId).add(resourceId, new Rule(type, privileges && new Set(privileges), assertion));
}

allow<ROLE extends Role = Role, RESOURCE extends Resource = Resource>(
Expand All @@ -201,11 +199,11 @@ export class PermissionManager {
}

toJSON(): SerializedPermissions {
return this.rrrm.toJSON();
return this.roleResourceRuleMap.toJSON();
}

importJSON(json: SerializedPermissions | unknown): this {
this.rrrm.importJSON(json);
this.roleResourceRuleMap.importJSON(json);
return this;
}
}
5 changes: 4 additions & 1 deletion pkg/core/src/resource-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { ChildNode } from './child-node.js';

export type SerializedResources = [resource: string, parents: string[]][];
export class Resource {
constructor(public readonly resourceId: string) {}
constructor(
public readonly resourceId: string,
public readonly parents?: Resource[]
) {}
}

export function assertResourceId(resourceOrId: Resource | string): string {
Expand Down
5 changes: 4 additions & 1 deletion pkg/core/src/role-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { ChildNode } from './child-node.js';

export type SerializedRoles = [role: string, parents: string[]][];
export class Role {
constructor(public readonly roleId: string) {}
constructor(
public readonly roleId: string,
public readonly parents?: Role[]
) {}
}

export function assertRoleId(roleOrId: Role | string): string {
Expand Down

0 comments on commit 6888fde

Please sign in to comment.