From 0a6c68582a152aba13e6ecdef21e5111eefbec09 Mon Sep 17 00:00:00 2001 From: Joon Park Date: Thu, 13 Jan 2022 10:59:13 +0000 Subject: [PATCH] Add authorization to entities get endpoints in catalog-backend. (#8711) * Add authorization to entities get endpoints in catalog-backend. The delete endpoint and the ancestry endpoint, as well as the rest of the endpoints in catalog-backend will be covered in later PRs. Signed-off-by: Joon Park * Refactor permissioning into AuthorizedNextEntitiesCatalog Signed-off-by: Joon Park * Create integration router in builder Signed-off-by: Joon Park * Fix test authorization strings Signed-off-by: Joon Park --- .changeset/small-hotels-shake.md | 5 + plugins/catalog-backend/api-report.md | 3 +- plugins/catalog-backend/src/catalog/types.ts | 1 + .../service/AuthorizedEntitiesCatalog.test.ts | 96 +++++++++++++++++++ .../src/service/AuthorizedEntitiesCatalog.ts | 77 +++++++++++++++ .../src/service/NextCatalogBuilder.ts | 36 ++++++- .../src/service/NextRouter.test.ts | 74 +------------- .../catalog-backend/src/service/NextRouter.ts | 45 ++------- 8 files changed, 226 insertions(+), 111 deletions(-) create mode 100644 .changeset/small-hotels-shake.md create mode 100644 plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts create mode 100644 plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts diff --git a/.changeset/small-hotels-shake.md b/.changeset/small-hotels-shake.md new file mode 100644 index 0000000000..8cb052cf96 --- /dev/null +++ b/.changeset/small-hotels-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Add authorization to catalog-backend entities GET endpoints diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 702597c7c8..c503d303ba 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -849,6 +849,7 @@ export type EntitiesRequest = { filter?: EntityFilter; fields?: (entity: Entity) => Entity; pagination?: EntityPagination; + authorizationToken?: string; }; // Warning: (ae-missing-release-tag) "EntitiesResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1358,7 +1359,7 @@ export interface NextRouterOptions { // (undocumented) logger: Logger_2; // (undocumented) - permissionRules?: PermissionRule[]; + permissionIntegrationRouter?: express.Router; // (undocumented) refreshService?: RefreshService; } diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index f030d63bce..f71d7fb7d9 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -70,6 +70,7 @@ export type EntitiesRequest = { filter?: EntityFilter; fields?: (entity: Entity) => Entity; pagination?: EntityPagination; + authorizationToken?: string; }; export type EntitiesResponse = { diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts new file mode 100644 index 0000000000..768e3a690b --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { createConditionTransformer } from '@backstage/plugin-permission-node'; +import { isEntityKind } from '../permissions/rules/isEntityKind'; +import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; + +describe('AuthorizedEntitiesCatalog', () => { + const fakeCatalog = { + entities: jest.fn(), + removeEntityByUid: jest.fn(), + entityAncestry: jest.fn(), + }; + const fakePermissionApi = { + authorize: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('entities', () => { + it('returns empty response on DENY', async () => { + fakePermissionApi.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const catalog = new AuthorizedEntitiesCatalog( + fakeCatalog, + fakePermissionApi, + createConditionTransformer([]), + ); + + expect( + await catalog.entities({ + authorizationToken: 'abcd', + }), + ).toEqual({ + entities: [], + pageInfo: { hasNextPage: false }, + }); + }); + + it('calls underlying catalog method with correct filter on CONDITIONAL', async () => { + fakePermissionApi.authorize.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + conditions: { rule: 'IS_ENTITY_KIND', params: [['b']] }, + }, + ]); + const catalog = new AuthorizedEntitiesCatalog( + fakeCatalog, + fakePermissionApi, + createConditionTransformer([isEntityKind]), + ); + + await catalog.entities({ authorizationToken: 'abcd' }); + + expect(fakeCatalog.entities).toHaveBeenCalledWith({ + authorizationToken: 'abcd', + filter: { key: 'kind', values: ['b'] }, + }); + }); + + it('calls underlying catalog method on ALLOW', async () => { + fakePermissionApi.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + const catalog = new AuthorizedEntitiesCatalog( + fakeCatalog, + fakePermissionApi, + createConditionTransformer([]), + ); + + await catalog.entities({ authorizationToken: 'abcd' }); + + expect(fakeCatalog.entities).toHaveBeenCalledWith({ + authorizationToken: 'abcd', + }); + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts new file mode 100644 index 0000000000..5b65070c43 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedEntitiesCatalog.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common'; +import { + AuthorizeResult, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; +import { + EntitiesCatalog, + EntitiesRequest, + EntitiesResponse, + EntityAncestryResponse, + EntityFilter, +} from '../catalog/types'; + +export class AuthorizedEntitiesCatalog implements EntitiesCatalog { + constructor( + private readonly entitiesCatalog: EntitiesCatalog, + private readonly permissionApi: PermissionAuthorizer, + private readonly transformConditions: ConditionTransformer, + ) {} + + async entities(request?: EntitiesRequest): Promise { + const authorizeResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogEntityReadPermission }], + { token: request?.authorizationToken }, + ) + )[0]; + + if (authorizeResponse.result === AuthorizeResult.DENY) { + return { + entities: [], + pageInfo: { hasNextPage: false }, + }; + } + + if (authorizeResponse.result === AuthorizeResult.CONDITIONAL) { + const permissionFilter: EntityFilter = this.transformConditions( + authorizeResponse.conditions, + ); + return this.entitiesCatalog.entities({ + ...request, + filter: request?.filter + ? { allOf: [permissionFilter, request.filter] } + : permissionFilter, + }); + } + + return this.entitiesCatalog.entities(request); + } + + removeEntityByUid(uid: string): Promise { + // TODO: Implement permissioning + return this.entitiesCatalog.removeEntityByUid(uid); + } + + entityAncestry(entityRef: string): Promise { + // TODO: Implement permissioning + return this.entitiesCatalog.entityAncestry(entityRef); + } +} diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index eecfb5fc11..391c89d83d 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -23,6 +23,7 @@ import { FieldFormatEntityPolicy, makeValidator, NoForeignRootFieldsEntityPolicy, + parseEntityRef, SchemaValidEntityPolicy, Validators, } from '@backstage/catalog-model'; @@ -90,7 +91,14 @@ import { LocationService } from './types'; import { connectEntityProviders } from '../processing/connectEntityProviders'; import { permissionRules as catalogPermissionRules } from '../permissions/rules'; import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; -import { PermissionRule } from '@backstage/plugin-permission-node'; +import { + PermissionRule, + createConditionTransformer, + createPermissionIntegrationRouter, +} from '@backstage/plugin-permission-node'; +import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; +import { basicEntityFilter } from './request/basicEntityFilter'; +import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; export type CatalogEnvironment = { logger: Logger; @@ -399,7 +407,29 @@ export class NextCatalogBuilder { parser, policy, }); - const entitiesCatalog = new NextEntitiesCatalog(dbClient); + const unauthorizedEntitiesCatalog = new NextEntitiesCatalog(dbClient); + const entitiesCatalog = new AuthorizedEntitiesCatalog( + unauthorizedEntitiesCatalog, + permissions, + createConditionTransformer(this.permissionRules), + ); + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, + getResource: async (resourceRef: string) => { + const parsed = parseEntityRef(resourceRef); + + const { entities } = await unauthorizedEntitiesCatalog.entities({ + filter: basicEntityFilter({ + kind: parsed.kind, + 'metadata.namespace': parsed.namespace, + 'metadata.name': parsed.name, + }), + }); + + return entities[0]; + }, + rules: this.permissionRules, + }); const stitcher = new Stitcher(dbClient, logger); const locationStore = new DefaultLocationStore(dbClient); @@ -435,7 +465,7 @@ export class NextCatalogBuilder { refreshService, logger, config, - permissionRules: this.permissionRules, + permissionIntegrationRouter, }); await connectEntityProviders(processingDatabase, entityProviders); diff --git a/plugins/catalog-backend/src/service/NextRouter.test.ts b/plugins/catalog-backend/src/service/NextRouter.test.ts index e835c4620a..84580d0ca4 100644 --- a/plugins/catalog-backend/src/service/NextRouter.test.ts +++ b/plugins/catalog-backend/src/service/NextRouter.test.ts @@ -24,7 +24,6 @@ import { EntitiesCatalog } from '../catalog'; import { LocationService, RefreshService } from './types'; import { basicEntityFilter } from './request'; import { createNextRouter } from './NextRouter'; -import { AuthorizeResult } from '@backstage/plugin-permission-common'; describe('createNextRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; @@ -52,7 +51,7 @@ describe('createNextRouter readonly disabled', () => { logger: getVoidLogger(), refreshService, config: new ConfigReader(undefined), - permissionRules: [], + permissionIntegrationRouter: express.Router(), }); app = express().use(router); }); @@ -340,7 +339,7 @@ describe('createNextRouter readonly enabled', () => { readonly: true, }, }), - permissionRules: [], + permissionIntegrationRouter: express.Router(), }); app = express().use(router); }); @@ -434,72 +433,3 @@ describe('createNextRouter readonly enabled', () => { }); }); }); - -describe('NextRouter permissioning', () => { - let entitiesCatalog: jest.Mocked; - let locationService: jest.Mocked; - let app: express.Express; - let refreshService: RefreshService; - - const fakeRule = { - name: 'FAKE_RULE', - description: 'fake rule', - apply: () => true, - toQuery: () => ({ key: '', values: [] }), - }; - - beforeAll(async () => { - entitiesCatalog = { - entities: jest.fn(), - removeEntityByUid: jest.fn(), - batchAddOrUpdateEntities: jest.fn(), - entityAncestry: jest.fn(), - }; - locationService = { - getLocation: jest.fn(), - createLocation: jest.fn(), - listLocations: jest.fn(), - deleteLocation: jest.fn(), - }; - refreshService = { refresh: jest.fn() }; - const router = await createNextRouter({ - entitiesCatalog, - locationService, - logger: getVoidLogger(), - refreshService, - config: new ConfigReader(undefined), - permissionRules: [fakeRule], - }); - app = express().use(router); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('accepts and evaluates conditions at the apply-conditions endpoint', async () => { - const spideySense: Entity = { - apiVersion: 'a', - kind: 'b', - metadata: { - name: 'spidey-sense', - }, - }; - entitiesCatalog.entities.mockResolvedValueOnce({ - entities: [spideySense], - pageInfo: { hasNextPage: false }, - }); - - const requestBody = { - resourceType: 'catalog-entity', - resourceRef: 'component:default/spidey-sense', - conditions: { rule: 'FAKE_RULE', params: ['user:default/spiderman'] }, - }; - const response = await request(app) - .post('/.well-known/backstage/permissions/apply-conditions') - .send(requestBody); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ result: AuthorizeResult.ALLOW }); - }); -}); diff --git a/plugins/catalog-backend/src/service/NextRouter.ts b/plugins/catalog-backend/src/service/NextRouter.ts index 5338f58f91..78550e1791 100644 --- a/plugins/catalog-backend/src/service/NextRouter.ts +++ b/plugins/catalog-backend/src/service/NextRouter.ts @@ -17,23 +17,16 @@ import { errorHandler } from '@backstage/backend-common'; import { analyzeLocationSchema, - Entity, locationSpecSchema, - parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { NotFoundError } from '@backstage/errors'; -import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; -import { - createPermissionIntegrationRouter, - PermissionRule, -} from '@backstage/plugin-permission-node'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import yn from 'yn'; -import { EntitiesCatalog, EntitiesSearchFilter } from '../catalog'; +import { EntitiesCatalog } from '../catalog'; import { LocationAnalyzer } from '../ingestion/types'; import { basicEntityFilter, @@ -51,7 +44,7 @@ export interface NextRouterOptions { refreshService?: RefreshService; logger: Logger; config: Config; - permissionRules?: PermissionRule[]; + permissionIntegrationRouter?: express.Router; } export async function createNextRouter( @@ -64,7 +57,7 @@ export async function createNextRouter( refreshService, config, logger, - permissionRules, + permissionIntegrationRouter, } = options; const router = Router(); @@ -88,21 +81,18 @@ export async function createNextRouter( }); } + if (permissionIntegrationRouter) { + router.use(permissionIntegrationRouter); + } + if (entitiesCatalog) { router - .use( - createPermissionIntegrationRouter({ - resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - getResource: resourceRef => - getEntityResource(resourceRef, entitiesCatalog), - rules: permissionRules ?? [], - }), - ) .get('/entities', async (req, res) => { const { entities, pageInfo } = await entitiesCatalog.entities({ filter: parseEntityFilterParams(req.query), fields: parseEntityTransformParams(req.query), pagination: parseEntityPaginationParams(req.query), + authorizationToken: getBearerToken(req.header('authorization')), }); // Add a Link header to the next page @@ -120,6 +110,7 @@ export async function createNextRouter( const { uid } = req.params; const { entities } = await entitiesCatalog.entities({ filter: basicEntityFilter({ 'metadata.uid': uid }), + authorizationToken: getBearerToken(req.header('authorization')), }); if (!entities.length) { throw new NotFoundError(`No entity with uid ${uid}`); @@ -139,6 +130,7 @@ export async function createNextRouter( 'metadata.namespace': namespace, 'metadata.name': name, }), + authorizationToken: getBearerToken(req.header('authorization')), }); if (!entities.length) { throw new NotFoundError( @@ -204,23 +196,6 @@ export async function createNextRouter( return router; } -async function getEntityResource( - resourceRef: string, - entitiesCatalog: EntitiesCatalog, -): Promise { - const parsed = parseEntityRef(resourceRef); - - const { entities } = await entitiesCatalog.entities({ - filter: basicEntityFilter({ - kind: parsed.kind, - 'metadata.namespace': parsed.namespace, - 'metadata.name': parsed.name, - }), - }); - - return entities[0]; -} - function getBearerToken( authorizationHeader: string | undefined, ): string | undefined {