Skip to content

Commit

Permalink
fix(rbac): fix work resource permission specified by name (#940)
Browse files Browse the repository at this point in the history
* fix(rbac): fix work resource permission specified by name

Signed-off-by: Oleksandr Andriienko <[email protected]>

* fix(rbac): add more tests

Signed-off-by: Oleksandr Andriienko <[email protected]>

* fix(rbac): handle code review feedback

Signed-off-by: Oleksandr Andriienko <[email protected]>

* fix(rbac): fix rbac-backend doc

Signed-off-by: Oleksandr Andriienko <[email protected]>

---------

Signed-off-by: Oleksandr Andriienko <[email protected]>
  • Loading branch information
AndrienkoAleksandr authored Jan 23, 2024
1 parent 53be08c commit 3601eb8
Show file tree
Hide file tree
Showing 5 changed files with 369 additions and 49 deletions.
12 changes: 9 additions & 3 deletions plugins/rbac-backend/docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ Note: The requirements section primarily pertains to the frontend and may not be

When defining a permission for the RBAC Backend plugin to consume, follow these guidelines:

- If the permission has a Resource Type, it must be used. Otherwise, use the name of the permission.
Permission policies defined using the name of the permission will have higher priority over permission policies that are defined using the resource type.
Example:

- Example: `p, role:default/test, catalog.entity.read, read, allow` will not be properly picked up.
- Instead, use: `p, role:default/test, catalog-entity, read, allow`
```
p, role:default/myrole, catalog-entity, read, allow
p, role:default/myrole, catalog.entity.read, read, deny
g, user:default/myuser, role:default/myrole
```

Where 'myuser' will have a deny for reading catalog entities, because the permission name takes priority over the permission resource type.

- If the permission does not have a policy associated with it, use the keyword `use` in its place.
- Example: `p, role:default/test, kubernetes.proxy, use, allow`
Expand Down
198 changes: 188 additions & 10 deletions plugins/rbac-backend/src/service/permission-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,16 +197,24 @@ describe('RBACPermissionPolicy Tests', () => {
beforeEach(async () => {
const adapter = new StringAdapter(
`
# basic type permission policies
# ========== basic type permission policies ========== #
# case 1
p, user:default/known_user, test.resource.deny, use, deny
# case 2 is about user without listed permissions
# case 3
p, user:default/duplicated, test.resource, use, allow
p, user:default/duplicated, test.resource, use, deny
# case 4
p, user:default/known_user, test.resource, use, allow
# resource type permission policies
p, user:default/known_user, test-resource-deny, update, deny
# ========== resource type permission policies ========== #
# case 1
p, user:default/known_user, test-resource-deny, update, deny
# case 2 is about user without listed permissions
# case 3
p, user:default/duplicated, test-resource, update, allow
p, user:default/duplicated, test-resource, update, deny
# case 4
p, user:default/known_user, test-resource, update, allow
`,
);
Expand All @@ -229,13 +237,13 @@ describe('RBACPermissionPolicy Tests', () => {

catalogApi.getEntities.mockReturnValue({ items: [] });
});
// +-------+------+------------------------+
// | allow | deny | result |
// +-------+------+------------------------+
// | N | Y | deny | 1
// | N | N | deny (user not listed) | 2
// | Y | Y | deny (user:default/duplicated) | 3
// | Y | N | allow | 4
// +-------+------+------------------------------------+
// | allow | deny | result | |
// +-------+------+--------------------------------+---|
// | N | Y | deny | 1 |
// | N | N | deny (user not listed) | 2 |
// | Y | Y | deny (user:default/duplicated) | 3 |
// | Y | N | allow | 4 |

// Tests for Resource basic type permission

Expand Down Expand Up @@ -351,6 +359,176 @@ describe('RBACPermissionPolicy Tests', () => {
});
});

// Notice: There is corner case, when "resourced" permission policy can be defined not by resource type, but by name.
describe('Policy checks for resourced permissions defined by name', () => {
async function createRBACPolicy(
policyContent: string,
): Promise<RBACPermissionPolicy> {
const adapter = new StringAdapter(policyContent);
const config = new ConfigReader({});
const theModel = newModelFromString(MODEL);
const logger = getVoidLogger();
const enf = await createEnforcer(
theModel,
adapter,
logger,
tokenManagerMock,
);

return await RBACPermissionPolicy.build(
logger,
config,
conditionalStorage,
enf,
);
}
it('should allow access to resourced permission assigned by name', async () => {
catalogApi.getEntities.mockReturnValue({ items: [] });

const policy = await createRBACPolicy(`
p, role:default/catalog_reader, catalog.entity.read, read, allow
g, user:default/tor, role:default/catalog_reader
`);

const decision = await policy.handle(
newPolicyQueryWithResourcePermission(
'catalog.entity.read',
'catalog-entity',
'read',
),
newIdentityResponse('user:default/tor'),
);
expect(decision.result).toBe(AuthorizeResult.ALLOW);
});

it('should allow access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => {
catalogApi.getEntities.mockReturnValue({ items: [] });

const policy = await createRBACPolicy(`
p, role:default/catalog_reader, catalog.entity.read, read, allow
p, role:default/catalog_reader, catalog-entity, read, deny
g, user:default/tor, role:default/catalog_reader
`);

const decision = await policy.handle(
newPolicyQueryWithResourcePermission(
'catalog.entity.read',
'catalog-entity',
'read',
),
newIdentityResponse('user:default/tor'),
);
expect(decision.result).toBe(AuthorizeResult.ALLOW);
});

it('should deny access to resourced permission assigned by name, because it has higher priority then permission for the same resource assigned by resource type', async () => {
catalogApi.getEntities.mockReturnValue({ items: [] });

const policy = await createRBACPolicy(`
p, role:default/catalog_reader, catalog.entity.read, read, deny
p, role:default/catalog_reader, catalog-entity, read, allow
g, user:default/tor, role:default/catalog_reader
`);

const decision = await policy.handle(
newPolicyQueryWithResourcePermission(
'catalog.entity.read',
'catalog-entity',
'read',
),
newIdentityResponse('user:default/tor'),
);
expect(decision.result).toBe(AuthorizeResult.DENY);
});

it('should allow access to resourced permission assigned by name, but user inherits policy from his group', async () => {
const groupEntityMock: Entity = {
apiVersion: 'v1',
kind: 'Group',
metadata: {
name: 'team-a',
namespace: 'default',
},
};
catalogApi.getEntities.mockImplementation(arg => {
const hasMember = arg.filter['relations.hasMember'];
if (hasMember && hasMember[0] === 'user:default/tor') {
return { items: [groupEntityMock] };
}
return { items: [] };
});

const policy = await createRBACPolicy(`
p, role:default/catalog_user, catalog.entity.read, read, allow
g, group:default/team-a, role:default/catalog_user
`);

const decision = await policy.handle(
newPolicyQueryWithResourcePermission(
'catalog.entity.read',
'catalog-entity',
'read',
),
newIdentityResponse('user:default/tor'),
);
expect(decision.result).toBe(AuthorizeResult.ALLOW);
});

it('should allow access to resourced permission assigned by name, but user inherits policy from few groups', async () => {
const groupEntityMock: Entity = {
apiVersion: 'v1',
kind: 'Group',
metadata: {
name: 'team-a',
namespace: 'default',
},
spec: {
parent: 'team-b',
},
};
const groupParentMock: Entity = {
apiVersion: 'v1',
kind: 'Group',
metadata: {
name: 'team-b',
namespace: 'default',
},
};
catalogApi.getEntities.mockImplementation(arg => {
const hasMember = arg.filter['relations.hasMember'];
if (hasMember && hasMember[0] === 'user:default/tor') {
return { items: [groupEntityMock] };
}
const hasParent = arg.filter['relations.parentOf'];
if (hasParent && hasParent[0] === 'group:default/team-a') {
return { items: [groupParentMock] };
}
return { items: [] };
});

const policy = await createRBACPolicy(`
p, role:default/catalog_user, catalog.entity.read, read, allow
g, group:default/team-a, group:default/team-b
g, group:default/team-b, role:default/catalog_user
`);

const decision = await policy.handle(
newPolicyQueryWithResourcePermission(
'catalog.entity.read',
'catalog-entity',
'read',
),
newIdentityResponse('user:default/tor'),
);
expect(decision.result).toBe(AuthorizeResult.ALLOW);
});
});

describe('Policy checks for users and groups', () => {
let policy: RBACPermissionPolicy;

Expand Down
73 changes: 46 additions & 27 deletions plugins/rbac-backend/src/service/permission-policy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/core-plugin-api';
import {
BackstageIdentityResponse,
BackstageUserIdentity,
} from '@backstage/plugin-auth-node';
import { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import {
AuthorizeResult,
isResourcePermission,
Expand Down Expand Up @@ -160,32 +157,53 @@ export class RBACPermissionPolicy implements PermissionPolicy {
// a more complicated model with multiple policy and request shapes.
const action = request.permission.attributes.action ?? 'use';

if (!identityResp?.identity) {
return { result: AuthorizeResult.DENY };
}

const userEntityRef = identityResp.identity.userEntityRef;
const permissionName = request.permission.name;

if (isResourcePermission(request.permission)) {
status = await this.isAuthorized(
identityResp?.identity,
request.permission.resourceType,
action,
);
const resourceType = request.permission.resourceType;
const hasNamedPermission =
await this.hasImplicitPermissionSpecifiedByName(
userEntityRef,
permissionName,
action,
);
// Let's set up higher priority for permission specified by name, than by resource type
const obj = hasNamedPermission ? permissionName : resourceType;

status = await this.enforcer.enforce(userEntityRef, obj, action);

if (status && identityResp) {
const conditionalDecision = await this.conditionStorage.findCondition(
request.permission.resourceType,
);
const conditionalDecision =
await this.conditionStorage.findCondition(resourceType);
if (conditionalDecision) {
this.logger.info(
`${identityResp?.identity.userEntityRef} executed condition for permission ${request.permission.name}, resource type ${resourceType} and action ${action}`,
);
return conditionalDecision;
}
}
} else {
status = await this.isAuthorized(
identityResp?.identity,
request.permission.name,
status = await this.enforcer.enforce(
userEntityRef,
permissionName,
action,
);
}

const result = status ? AuthorizeResult.ALLOW : AuthorizeResult.DENY;
this.logger.info(
`${identityResp?.identity.userEntityRef} is ${result} for permission ${request.permission.name} and action ${action}`,
`${userEntityRef} is ${result} for permission '${
request.permission.name
}'${
isResourcePermission(request.permission)
? `, resource type '${request.permission.resourceType}'`
: ''
} and action ${action}`,
);
return Promise.resolve({
result: result,
Expand All @@ -198,17 +216,18 @@ export class RBACPermissionPolicy implements PermissionPolicy {
}
}

private isAuthorized = async (
identity: BackstageUserIdentity | undefined,
resourceType: string,
private async hasImplicitPermissionSpecifiedByName(
userEntityRef: string,
permissionName: string,
action: string,
): Promise<boolean> => {
if (!identity) {
return false;
): Promise<boolean> {
const userPerms =
await this.enforcer.getImplicitPermissionsForUser(userEntityRef);
for (const perm of userPerms) {
if (permissionName === perm[1] && action === perm[2]) {
return true;
}
}

const entityRef = identity.userEntityRef;

return await this.enforcer.enforce(entityRef, resourceType, action);
};
return false;
}
}
Loading

0 comments on commit 3601eb8

Please sign in to comment.