From 48248e2db5913b03c4283d9f68e45bad5820907e Mon Sep 17 00:00:00 2001 From: Joon Park Date: Mon, 10 Jan 2022 18:11:26 +0000 Subject: [PATCH] Integrate permissions into entityAncestry endpoint in catalog-backend Signed-off-by: Joon Park --- .changeset/many-jokes-mix.md | 5 + plugins/catalog-backend/api-report.md | 7 +- plugins/catalog-backend/src/catalog/types.ts | 5 +- .../service/AuthorizedEntitiesCatalog.test.ts | 112 +++++++++++++++--- .../src/service/AuthorizedEntitiesCatalog.ts | 78 +++++++++++- 5 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 .changeset/many-jokes-mix.md diff --git a/.changeset/many-jokes-mix.md b/.changeset/many-jokes-mix.md new file mode 100644 index 0000000000..65d8f18baf --- /dev/null +++ b/.changeset/many-jokes-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Integrate permissions into entity ancestry endpoint in catalog-backend diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index e76da7bd6c..12ab1880b6 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -554,7 +554,12 @@ export type EntitiesCatalog = { authorizationToken?: string; }, ): Promise; - entityAncestry(entityRef: string): Promise; + entityAncestry( + entityRef: string, + options?: { + authorizationToken?: string; + }, + ): Promise; }; // Warning: (ae-missing-release-tag) "EntitiesRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 669f0258d8..a79229b9a1 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -111,5 +111,8 @@ export type EntitiesCatalog = { * * @param entityRef - An entity reference to the root of the tree */ - entityAncestry(entityRef: string): Promise; + entityAncestry( + entityRef: string, + options?: { authorizationToken?: string }, + ): Promise; }; diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts index 34e8b6c569..156db8e8ce 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -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[] + ) => + 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: [], + }, + ], + }); + }); + }); }); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts index 412c6bd326..99fc3ebc8e 100644 --- a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -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 { - // TODO: Implement permissioning - return this.entitiesCatalog.entityAncestry(entityRef); + async entityAncestry( + entityRef: string, + options?: { authorizationToken?: string }, + ): Promise { + 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[] { + 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), + ), + ]; } }