Merge pull request #8853 from backstage/catalog-backend-permission-integration/entity-ancestry

Integrate permissions into `entityAncestry` endpoint in catalog-backend
This commit is contained in:
Fredrik Adelöw
2022-01-18 14:01:09 +01:00
committed by GitHub
5 changed files with 185 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Integrate permissions into entity ancestry endpoint in catalog-backend
+6 -1
View File
@@ -554,7 +554,12 @@ export type EntitiesCatalog = {
authorizationToken?: string;
},
): Promise<void>;
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
entityAncestry(
entityRef: string,
options?: {
authorizationToken?: string;
},
): Promise<EntityAncestryResponse>;
};
// Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+4 -1
View File
@@ -111,5 +111,8 @@ export type EntitiesCatalog = {
*
* @param entityRef - An entity reference to the root of the tree
*/
entityAncestry(entityRef: string): Promise<EntityAncestryResponse>;
entityAncestry(
entityRef: string,
options?: { authorizationToken?: string },
): Promise<EntityAncestryResponse>;
};
@@ -14,9 +14,14 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { createConditionTransformer } from '@backstage/plugin-permission-node';
import {
createConditionTransformer,
PermissionRule,
} from '@backstage/plugin-permission-node';
import { EntitiesSearchFilter } from '../catalog/types';
import { isEntityKind } from '../permissions/rules/isEntityKind';
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
@@ -30,6 +35,15 @@ describe('AuthorizedEntitiesCatalog', () => {
authorize: jest.fn(),
};
const createCatalog = (
...rules: PermissionRule<Entity, EntitiesSearchFilter, unknown[]>[]
) =>
new AuthorizedEntitiesCatalog(
fakeCatalog,
fakePermissionApi,
createConditionTransformer(rules),
);
afterEach(() => {
jest.clearAllMocks();
});
@@ -39,12 +53,7 @@ describe('AuthorizedEntitiesCatalog', () => {
fakePermissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const catalog = new AuthorizedEntitiesCatalog(
fakeCatalog,
fakePermissionApi,
createConditionTransformer([]),
);
const catalog = createCatalog();
expect(
await catalog.entities({
@@ -63,11 +72,7 @@ describe('AuthorizedEntitiesCatalog', () => {
conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] },
},
]);
const catalog = new AuthorizedEntitiesCatalog(
fakeCatalog,
fakePermissionApi,
createConditionTransformer([isEntityKind]),
);
const catalog = createCatalog(isEntityKind);
await catalog.entities({ authorizationToken: 'abcd' });
@@ -81,11 +86,7 @@ describe('AuthorizedEntitiesCatalog', () => {
fakePermissionApi.authorize.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
]);
const catalog = new AuthorizedEntitiesCatalog(
fakeCatalog,
fakePermissionApi,
createConditionTransformer([]),
);
const catalog = createCatalog();
await catalog.entities({ authorizationToken: 'abcd' });
@@ -176,4 +177,81 @@ describe('AuthorizedEntitiesCatalog', () => {
expect(fakeCatalog.removeEntityByUid).toHaveBeenCalledWith('uid');
});
});
describe('entityAncestry', () => {
it('throws error if denied access to root entity', async () => {
fakePermissionApi.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.DENY },
]);
const catalog = createCatalog();
await expect(() =>
catalog.entityAncestry('backstage:default/component', {
authorizationToken: 'Bearer abcd',
}),
).rejects.toThrowError(NotAllowedError);
});
it('filters out unauthorized entities and their parents', async () => {
fakePermissionApi.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
]);
fakePermissionApi.authorize.mockResolvedValueOnce([
{ result: AuthorizeResult.ALLOW },
{ result: AuthorizeResult.DENY },
{ result: AuthorizeResult.ALLOW },
{ result: AuthorizeResult.ALLOW },
{ result: AuthorizeResult.ALLOW },
]);
fakeCatalog.entityAncestry.mockResolvedValueOnce({
rootEntityRef: 'component:default/a',
items: [
{
entity: { kind: 'component', namespace: 'default', name: 'a' },
parentEntityRefs: ['component:default/b1', 'component:default/b2'],
},
{
entity: { kind: 'component', namespace: 'default', name: 'b1' },
parentEntityRefs: ['component:default/c'],
},
{
entity: { kind: 'component', namespace: 'default', name: 'b2' },
parentEntityRefs: [],
},
{
entity: { kind: 'component', namespace: 'default', name: 'c' },
parentEntityRefs: [],
},
{
entity: { kind: 'component', namespace: 'default', name: 'd' },
parentEntityRefs: [],
},
],
});
const catalog = createCatalog();
const ancestryResult = await catalog.entityAncestry(
'backstage:default/a',
{ authorizationToken: 'Bearer abcd' },
);
expect(ancestryResult).toEqual({
rootEntityRef: 'component:default/a',
items: [
{
entity: { kind: 'component', namespace: 'default', name: 'a' },
parentEntityRefs: ['component:default/b1', 'component:default/b2'],
},
{
entity: { kind: 'component', namespace: 'default', name: 'b2' },
parentEntityRefs: [],
},
{
entity: { kind: 'component', namespace: 'default', name: 'd' },
parentEntityRefs: [],
},
],
});
});
});
});
@@ -19,6 +19,7 @@ import {
catalogEntityDeletePermission,
catalogEntityReadPermission,
} from '@backstage/plugin-catalog-common';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
AuthorizeResult,
PermissionAuthorizer,
@@ -99,8 +100,79 @@ export class AuthorizedEntitiesCatalog implements EntitiesCatalog {
return this.entitiesCatalog.removeEntityByUid(uid);
}
entityAncestry(entityRef: string): Promise<EntityAncestryResponse> {
// TODO: Implement permissioning
return this.entitiesCatalog.entityAncestry(entityRef);
async entityAncestry(
entityRef: string,
options?: { authorizationToken?: string },
): Promise<EntityAncestryResponse> {
const rootEntityAuthorizeResponse = (
await this.permissionApi.authorize(
[{ permission: catalogEntityReadPermission, resourceRef: entityRef }],
{ token: options?.authorizationToken },
)
)[0];
if (rootEntityAuthorizeResponse.result === AuthorizeResult.DENY) {
throw new NotAllowedError();
}
const ancestryResult = await this.entitiesCatalog.entityAncestry(entityRef);
const authorizeResponse = await this.permissionApi.authorize(
ancestryResult.items.map(item => ({
permission: catalogEntityReadPermission,
resourceRef: stringifyEntityRef(item.entity),
})),
{ token: options?.authorizationToken },
);
const unauthorizedAncestryItems = ancestryResult.items.filter(
(_, index) => authorizeResponse[index].result === AuthorizeResult.DENY,
);
if (unauthorizedAncestryItems.length === 0) {
return ancestryResult;
}
const rootUnauthorizedEntityRefs = unauthorizedAncestryItems.map(
ancestryItem => stringifyEntityRef(ancestryItem.entity),
);
const allUnauthorizedEntityRefs = new Set(
rootUnauthorizedEntityRefs.flatMap(rootEntityRef =>
this.findParents(
rootEntityRef,
ancestryResult.items,
new Set(rootUnauthorizedEntityRefs),
),
),
);
return {
rootEntityRef: ancestryResult.rootEntityRef,
items: ancestryResult.items.filter(
ancestryItem =>
!allUnauthorizedEntityRefs.has(
stringifyEntityRef(ancestryItem.entity),
),
),
};
}
private findParents(
entityRef: string,
allAncestryItems: { entity: Entity; parentEntityRefs: string[] }[],
seenEntityRefs: Set<string>,
): string[] {
const entity = allAncestryItems.find(
ancestryItem => stringifyEntityRef(ancestryItem.entity) === entityRef,
);
if (!entity) return [];
const newSeenEntityRefs = new Set(seenEntityRefs);
entity.parentEntityRefs.forEach(parentRef =>
newSeenEntityRefs.add(parentRef),
);
return [
entityRef,
...entity.parentEntityRefs.flatMap(parentRef =>
seenEntityRefs.has(parentRef)
? []
: this.findParents(parentRef, allAncestryItems, newSeenEntityRefs),
),
];
}
}