diff --git a/.changeset/silly-doors-exist.md b/.changeset/silly-doors-exist.md new file mode 100644 index 0000000000..7ec20ca0c5 --- /dev/null +++ b/.changeset/silly-doors-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-common': patch +--- + +Remove Catalog Location resource type diff --git a/.changeset/soft-socks-pay.md b/.changeset/soft-socks-pay.md new file mode 100644 index 0000000000..2ec330c449 --- /dev/null +++ b/.changeset/soft-socks-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Integrate permissions into catalog-backend location endpoints diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 12ab1880b6..8af74868f2 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -884,17 +884,32 @@ export interface LocationService { createLocation( spec: LocationSpec, dryRun: boolean, + options?: { + authorizationToken?: string; + }, ): Promise<{ location: Location_2; entities: Entity[]; exists?: boolean; }>; // (undocumented) - deleteLocation(id: string): Promise; + deleteLocation( + id: string, + options?: { + authorizationToken?: string; + }, + ): Promise; // (undocumented) - getLocation(id: string): Promise; + getLocation( + id: string, + options?: { + authorizationToken?: string; + }, + ): Promise; // (undocumented) - listLocations(): Promise; + listLocations(options?: { + authorizationToken?: string; + }): Promise; } // Warning: (ae-missing-release-tag) "LocationStore" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts new file mode 100644 index 0000000000..b27ca92924 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { AuthorizedLocationService } from './AuthorizedLocationService'; + +describe('AuthorizedLocationService', () => { + const fakeLocationService = { + createLocation: jest.fn(), + listLocations: jest.fn(), + getLocation: jest.fn(), + deleteLocation: jest.fn(), + }; + const fakePermissionApi = { + authorize: jest.fn(), + }; + + const mockAllow = () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { result: AuthorizeResult.ALLOW }, + ]); + }; + const mockDeny = () => { + fakePermissionApi.authorize.mockResolvedValueOnce([ + { result: AuthorizeResult.DENY }, + ]); + }; + + const createService = () => + new AuthorizedLocationService(fakeLocationService, fakePermissionApi); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('createLocation', () => { + it('calls underlying service to create location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + const spec = { type: 'type', target: 'target' }; + await service.createLocation(spec, false, { + authorizationToken: 'Bearer authtoken', + }); + + expect(fakeLocationService.createLocation).toHaveBeenCalledWith( + spec, + false, + ); + }); + + it('throws Error on DENY', async () => { + mockDeny(); + const service = createService(); + + const spec = { type: 'type', target: 'target' }; + await expect(() => + service.createLocation(spec, false, { + authorizationToken: 'Bearer authtoken', + }), + ).rejects.toThrowError(NotAllowedError); + }); + }); + + describe('listLocations', () => { + it('calls underlying service to list locations on ALLOW', async () => { + mockAllow(); + const service = createService(); + + await service.listLocations({ authorizationToken: 'Bearer authtoken' }); + + expect(fakeLocationService.listLocations).toHaveBeenCalled(); + }); + + it('returns empty array on DENY', async () => { + mockDeny(); + const service = createService(); + + const locations = await service.listLocations({ + authorizationToken: 'Bearer authtoken', + }); + + expect(locations).toEqual([]); + }); + }); + + describe('getLocation', () => { + it('calls underlying service to get location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + await service.getLocation('id', { + authorizationToken: 'Bearer authtoken', + }); + + expect(fakeLocationService.getLocation).toHaveBeenCalledWith('id'); + }); + + it('throws error on DENY', async () => { + mockDeny(); + const service = createService(); + + await expect(() => + service.getLocation('id', { authorizationToken: 'Bearer authtoken' }), + ).rejects.toThrowError(NotFoundError); + }); + }); + + describe('deleteLocation', () => { + it('calls underlying service to delete location on ALLOW', async () => { + mockAllow(); + const service = createService(); + + await service.deleteLocation('id', { + authorizationToken: 'Bearer authtoken', + }); + + expect(fakeLocationService.deleteLocation).toHaveBeenCalledWith('id'); + }); + + it('throws error on DENY', async () => { + mockDeny(); + const service = createService(); + + await expect(() => + service.deleteLocation('id', { + authorizationToken: 'Bearer authtoken', + }), + ).rejects.toThrowError(NotAllowedError); + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationService.ts b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts new file mode 100644 index 0000000000..1ae2833cd0 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedLocationService.ts @@ -0,0 +1,113 @@ +/* + * 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 { LocationSpec, Location, Entity } from '@backstage/catalog-model'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + catalogLocationCreatePermission, + catalogLocationDeletePermission, + catalogLocationReadPermission, +} from '@backstage/plugin-catalog-common'; +import { + AuthorizeResult, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; +import { LocationService } from './types'; + +export class AuthorizedLocationService implements LocationService { + constructor( + private readonly locationService: LocationService, + private readonly permissionApi: PermissionAuthorizer, + ) {} + + async createLocation( + spec: LocationSpec, + dryRun: boolean, + options?: { + authorizationToken?: string; + }, + ): Promise<{ + location: Location; + entities: Entity[]; + exists?: boolean | undefined; + }> { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationCreatePermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + throw new NotAllowedError(); + } + + return this.locationService.createLocation(spec, dryRun); + } + + async listLocations(options?: { + authorizationToken?: string; + }): Promise { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationReadPermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + return []; + } + + return this.locationService.listLocations(); + } + + async getLocation( + id: string, + options?: { authorizationToken?: string }, + ): Promise { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationReadPermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + + return this.locationService.getLocation(id); + } + + async deleteLocation( + id: string, + options?: { authorizationToken?: string }, + ): Promise { + const authorizationResponse = ( + await this.permissionApi.authorize( + [{ permission: catalogLocationDeletePermission }], + { token: options?.authorizationToken }, + ) + )[0]; + + if (authorizationResponse.result === AuthorizeResult.DENY) { + throw new NotAllowedError(); + } + + return this.locationService.deleteLocation(id); + } +} diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 489c7827c1..46c92c43da 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -95,6 +95,7 @@ import { import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog'; import { basicEntityFilter } from './request/basicEntityFilter'; import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common'; +import { AuthorizedLocationService } from './AuthorizedLocationService'; export type CatalogEnvironment = { logger: Logger; @@ -455,9 +456,9 @@ export class CatalogBuilder { const locationAnalyzer = this.locationAnalyzer ?? new RepoLocationAnalyzer(logger, integrations); - const locationService = new DefaultLocationService( - locationStore, - orchestrator, + const locationService = new AuthorizedLocationService( + new DefaultLocationService(locationStore, orchestrator), + permissions, ); const refreshService = new AuthorizedRefreshService( new DefaultRefreshService({ database: processingDatabase }), diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index 5e2bf2a599..02d234b6a8 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -20,10 +20,19 @@ export interface LocationService { createLocation( spec: LocationSpec, dryRun: boolean, + options?: { + authorizationToken?: string; + }, ): Promise<{ location: Location; entities: Entity[]; exists?: boolean }>; - listLocations(): Promise; - getLocation(id: string): Promise; - deleteLocation(id: string): Promise; + listLocations(options?: { authorizationToken?: string }): Promise; + getLocation( + id: string, + options?: { authorizationToken?: string }, + ): Promise; + deleteLocation( + id: string, + options?: { authorizationToken?: string }, + ): Promise; } /** diff --git a/plugins/catalog-common/api-report.md b/plugins/catalog-common/api-report.md index 8e03fc16b5..e2ee2d3225 100644 --- a/plugins/catalog-common/api-report.md +++ b/plugins/catalog-common/api-report.md @@ -25,7 +25,4 @@ export const catalogLocationReadPermission: Permission; // @public (undocumented) export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; - -// @public (undocumented) -export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; ``` diff --git a/plugins/catalog-common/src/index.ts b/plugins/catalog-common/src/index.ts index a38a5ed93b..05d5ed2ba5 100644 --- a/plugins/catalog-common/src/index.ts +++ b/plugins/catalog-common/src/index.ts @@ -23,7 +23,6 @@ export { RESOURCE_TYPE_CATALOG_ENTITY, - RESOURCE_TYPE_CATALOG_LOCATION, catalogEntityReadPermission, catalogEntityDeletePermission, catalogEntityRefreshPermission, diff --git a/plugins/catalog-common/src/permissions.ts b/plugins/catalog-common/src/permissions.ts index eb7b01c31e..b4c6c4377c 100644 --- a/plugins/catalog-common/src/permissions.ts +++ b/plugins/catalog-common/src/permissions.ts @@ -22,12 +22,6 @@ import { Permission } from '@backstage/plugin-permission-common'; */ export const RESOURCE_TYPE_CATALOG_ENTITY = 'catalog-entity'; -/** - * {@link https://backstage.io/docs/features/software-catalog/descriptor-format#kind-location} - * @public - */ -export const RESOURCE_TYPE_CATALOG_LOCATION = 'catalog-location'; - /** * This permission is used to authorize actions that involve reading one or more * entities from the catalog. @@ -83,7 +77,6 @@ export const catalogLocationReadPermission: Permission = { attributes: { action: 'read', }, - resourceType: RESOURCE_TYPE_CATALOG_LOCATION, }; /** @@ -96,7 +89,6 @@ export const catalogLocationCreatePermission: Permission = { attributes: { action: 'create', }, - resourceType: RESOURCE_TYPE_CATALOG_LOCATION, }; /** @@ -109,5 +101,4 @@ export const catalogLocationDeletePermission: Permission = { attributes: { action: 'delete', }, - resourceType: RESOURCE_TYPE_CATALOG_LOCATION, };