Skip to content

Commit

Permalink
feat(rbac): add the optional maxDepth feature (janus-idp#1486)
Browse files Browse the repository at this point in the history
* feat(rbac): add the optional maxDepth feature
  • Loading branch information
PatAKnight authored Apr 23, 2024
1 parent 2a567f0 commit ea87f34
Show file tree
Hide file tree
Showing 10 changed files with 392 additions and 62 deletions.
13 changes: 13 additions & 0 deletions plugins/rbac-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,16 @@ The RBAC plugin offers the option to store policies in a database. It supports t
- postgres: Recommended for production environments.

Ensure that you have already configured the database backend for your Backstage instance, as the RBAC plugin utilizes the same database configuration.

### Optional maximum depth

The RBAC plugin also includes an option max depth feature for organizations with potentially complex group hierarchy, this configuration value will ensure that the RBAC plugin will stop at a certain depth when building user graphs.

```YAML
permission:
enabled: true
rbac:
maxDepth: 1
```

The maxDepth must be greater than 0 to ensure that the graphs are built correctly. Also the graph will be built with a hierarchy of 1 + maxDepth.
5 changes: 5 additions & 0 deletions plugins/rbac-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export interface Config {
* The RBAC plugin will handle access control for plugins included in this list.
*/
pluginsWithPermission?: string[];
/**
* An optional value that limits the depth when building the hierarchy group graph
* @visibility frontend
*/
maxDepth?: number;
};
};
}
35 changes: 35 additions & 0 deletions plugins/rbac-backend/src/file-permissions/csv.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TokenManager } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';

import {
Adapter,
Expand Down Expand Up @@ -115,11 +116,14 @@ async function createEnforcer(
const catalogDBClient = Knex.knex({ client: MockClient });
const enf = await newEnforcer(theModel, adapter);

const config = newConfigReader();

const rm = new BackstageRoleManager(
catalogApi,
log,
tokenManager,
catalogDBClient,
config,
);
enf.setRoleManager(rm);
enf.enableAutoBuildRoleLinks(false);
Expand Down Expand Up @@ -1005,3 +1009,34 @@ describe('CSV file', () => {
});
});
});

function newConfigReader(
users?: Array<{ name: string }>,
superUsers?: Array<{ name: string }>,
): ConfigReader {
const testUsers = [
{
name: 'user:default/guest',
},
{
name: 'group:default/guests',
},
];

return new ConfigReader({
permission: {
rbac: {
admin: {
users: users || testUsers,
superUsers: superUsers,
},
},
},
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
});
}
190 changes: 139 additions & 51 deletions plugins/rbac-backend/src/role-manager/ancestor-search-memo.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Entity } from '@backstage/catalog-model';
import { Entity, GroupEntity } from '@backstage/catalog-model';

import * as Knex from 'knex';
import { createTracker, MockClient, Tracker } from 'knex-mock-client';
Expand Down Expand Up @@ -41,28 +41,24 @@ describe('ancestor-search-memo', () => {
];

const testGroups = [
createGroupEntity(
'group:default/team-a',
'group:default/team-b',
[],
['adam'],
),
createGroupEntity('group:default/team-b', 'group:default/team-c', [], []),
createGroupEntity('group:default/team-c', '', [], []),
createGroupEntity(
'group:default/team-d',
'group:default/team-e',
[],
['george'],
),
createGroupEntity('group:default/team-e', 'group:default/team-f', [], []),
createGroupEntity('group:default/team-f', '', [], []),
createGroupEntity('team-a', 'team-b', [], ['adam']),
createGroupEntity('team-b', 'team-c', [], []),
createGroupEntity('team-c', '', [], []),
createGroupEntity('team-d', 'team-e', [], ['george']),
createGroupEntity('team-e', 'team-f', [], []),
createGroupEntity('team-f', '', [], []),
];

const testUserGroups = [createGroupEntity('team-a', 'team-b', [], ['adam'])];

const catalogApiMock: any = {
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: testGroups })),
getEntities: jest.fn().mockImplementation((arg: any) => {
const hasMember = arg.filter['relations.hasMember'];
if (hasMember && hasMember === 'user:default/adam') {
return { items: testUserGroups };
}
return { items: testGroups };
}),
};

const catalogDBClient = Knex.knex({ client: MockClient });
Expand All @@ -74,12 +70,16 @@ describe('ancestor-search-memo', () => {
authenticate: jest.fn().mockImplementation(),
};

const asm = new AncestorSearchMemo(
'user:default/adam',
tokenManagerMock,
catalogApiMock,
catalogDBClient,
);
let asm: AncestorSearchMemo;

beforeEach(() => {
asm = new AncestorSearchMemo(
'user:default/adam',
tokenManagerMock,
catalogApiMock,
catalogDBClient,
);
});

describe('getAllGroups and getAllRelations', () => {
let tracker: Tracker;
Expand All @@ -104,7 +104,6 @@ describe('ancestor-search-memo', () => {

it('should return all groups', async () => {
const allGroupsTest = await asm.getAllGroups();
// @ts-ignore
expect(allGroupsTest).toEqual(testGroups);
});

Expand Down Expand Up @@ -137,14 +136,8 @@ describe('ancestor-search-memo', () => {
});

it('should return all user groups', async () => {
tracker.on
.select(
/select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/,
)
.response(userRelations);
const relations = await asm.getUserRelations();

expect(relations).toEqual(userRelations);
const userGroups = await asm.getUserGroups();
expect(userGroups).toEqual(testUserGroups);
});

it('should fail to return anything when there is an error getting user relations', async () => {
Expand Down Expand Up @@ -187,6 +180,7 @@ describe('ancestor-search-memo', () => {
asm,
relation as Relation,
allRelationsTest as Relation[],
0,
),
);

Expand All @@ -196,6 +190,103 @@ describe('ancestor-search-memo', () => {
expect(asm.hasEntityRef('group:default/team-c')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-d')).toBeFalsy();
});

// maxDepth of one stops here
// |
// user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c
it('should build the graph but stop based on the maxDepth', async () => {
const asmMaxDepth = new AncestorSearchMemo(
'user:default/adam',
tokenManagerMock,
catalogApiMock,
catalogDBClient,
1,
);

tracker.on
.select(
/select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/,
)
.response(userRelations);
const userRelationsTest = await asmMaxDepth.getUserRelations();

tracker.reset();
tracker.on
.select(
/select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/,
)
.response(allRelations);
const allRelationsTest = await asmMaxDepth.getAllRelations();

userRelationsTest.forEach(relation =>
asmMaxDepth.traverseRelations(
asmMaxDepth,
relation as Relation,
allRelationsTest as Relation[],
0,
),
);

expect(asmMaxDepth.hasEntityRef('user:default/adam')).toBeTruthy();
expect(asmMaxDepth.hasEntityRef('group:default/team-a')).toBeTruthy();
expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeTruthy();
expect(asmMaxDepth.hasEntityRef('group:default/team-c')).toBeFalsy();
expect(asmMaxDepth.hasEntityRef('group:default/team-d')).toBeFalsy();
});
});

describe('traverseGroups', () => {
// user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c
it('should build a graph for a particular user', async () => {
const userGroupsTest = await asm.getUserGroups();

const allGroupsTest = await asm.getAllGroups();

userGroupsTest.forEach(group =>
asm.traverseGroups(
asm,
group as GroupEntity,
allGroupsTest as GroupEntity[],
0,
),
);

expect(asm.hasEntityRef('group:default/team-a')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-b')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-c')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-d')).toBeFalsy();
});

// maxDepth of one stops here
// |
// user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c
it('should build the graph but stop based on the maxDepth', async () => {
const asmMaxDepth = new AncestorSearchMemo(
'user:default/adam',
tokenManagerMock,
catalogApiMock,
catalogDBClient,
1,
);

const userGroupsTest = await asmMaxDepth.getUserGroups();

const allGroupsTest = await asmMaxDepth.getAllGroups();

userGroupsTest.forEach(group =>
asmMaxDepth.traverseGroups(
asmMaxDepth,
group as GroupEntity,
allGroupsTest as GroupEntity[],
0,
),
);

expect(asmMaxDepth.hasEntityRef('group:default/team-a')).toBeTruthy();
expect(asmMaxDepth.hasEntityRef('group:default/team-b')).toBeTruthy();
expect(asmMaxDepth.hasEntityRef('group:default/team-c')).toBeFalsy();
expect(asmMaxDepth.hasEntityRef('group:default/team-d')).toBeFalsy();
});
});

describe('buildUserGraph', () => {
Expand All @@ -211,6 +302,12 @@ describe('ancestor-search-memo', () => {
const asmDBSpy = jest
.spyOn(asmUserGraph, 'doesRelationTableExist')
.mockImplementation(() => Promise.resolve(true));
const userRelationsSpy = jest
.spyOn(asmUserGraph, 'getUserRelations')
.mockImplementation(() => Promise.resolve(userRelations));
const allRelationsSpy = jest
.spyOn(asmUserGraph, 'getAllRelations')
.mockImplementation(() => Promise.resolve(allRelations));

beforeAll(() => {
tracker = createTracker(catalogDBClient);
Expand All @@ -222,25 +319,16 @@ describe('ancestor-search-memo', () => {

// user:default/adam -> group:default/team-a -> group:default/team-b -> group:default/team-c
it('should build the user graph using relations table', async () => {
tracker.on
.select(
/select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/,
)
.response(userRelations);
tracker.reset();
tracker.on
.select(
/select "source_entity_ref", "target_entity_ref" from "relations" where "type" = ?/,
)
.response(allRelations);
await asmUserGraph.buildUserGraph(asmUserGraph);

expect(asmDBSpy).toHaveBeenCalled();
expect(asm.hasEntityRef('user:default/adam')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-a')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-b')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-c')).toBeTruthy();
expect(asm.hasEntityRef('group:default/team-d')).toBeFalsy();
expect(userRelationsSpy).toHaveBeenCalled();
expect(allRelationsSpy).toHaveBeenCalled();
expect(asmUserGraph.hasEntityRef('user:default/adam')).toBeTruthy();
expect(asmUserGraph.hasEntityRef('group:default/team-a')).toBeTruthy();
expect(asmUserGraph.hasEntityRef('group:default/team-b')).toBeTruthy();
expect(asmUserGraph.hasEntityRef('group:default/team-c')).toBeTruthy();
expect(asmUserGraph.hasEntityRef('group:default/team-d')).toBeFalsy();
});
});

Expand Down
Loading

0 comments on commit ea87f34

Please sign in to comment.