diff --git a/.changeset/lucky-zoos-camp.md b/.changeset/lucky-zoos-camp.md new file mode 100644 index 0000000000..e99e8dc7f3 --- /dev/null +++ b/.changeset/lucky-zoos-camp.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-backend': minor +'@backstage/plugin-catalog-common': minor +'@backstage/plugin-catalog-node': minor +--- + +The `analyze-location` endpoint is now protected by the `catalog.location.analyze` permission. +The `validate-entity` endpoint is now protected by the `catalog.entity.validate` permission. diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.test.ts b/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.test.ts new file mode 100644 index 0000000000..1583049080 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2021 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 } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { mockCredentials } from '@backstage/backend-test-utils'; + +describe('AuthorizedLocationAnalyzer', () => { + const locationAnalyzerService = { + analyzeLocation: jest.fn(), + }; + const permissionApi = { + authorize: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('throws Authorization Error on deny', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + const authorizedService = new AuthorizedLocationAnalyzer( + locationAnalyzerService, + permissionApi as unknown as ServerPermissionClient, + ); + await expect(() => + authorizedService.analyzeLocation( + { + location: { + type: 'url', + target: 'https://example.com/path/to/your/catalog-info.yaml', + presence: 'required', + }, + catalogFilename: 'catalog-info.yaml', + }, + mockCredentials.none(), + ), + ).rejects.toThrow(NotAllowedError); + }); + + it('calls analyzeLocation on allow', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + const authorizedService = new AuthorizedLocationAnalyzer( + locationAnalyzerService, + permissionApi as unknown as ServerPermissionClient, + ); + const analyzeLocationRequest = { + location: { + type: 'url', + target: 'https://example.com/path/to/your/catalog-info.yaml', + }, + catalogFilename: 'catalog-info.yaml', + }; + await authorizedService.analyzeLocation( + analyzeLocationRequest, + mockCredentials.none(), + ); + expect(locationAnalyzerService.analyzeLocation).toHaveBeenCalledWith( + analyzeLocationRequest, + mockCredentials.none(), + ); + }); +}); diff --git a/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.ts b/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.ts new file mode 100644 index 0000000000..dc9af81dcd --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedLocationAnalyzer.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 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 } from '@backstage/errors'; +import { catalogLocationAnalyzePermission } from '@backstage/plugin-catalog-common/alpha'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; +import { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; +import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; + +export class AuthorizedLocationAnalyzer implements LocationAnalyzer { + constructor( + private readonly service: LocationAnalyzer, + private readonly permissionApi: PermissionsService, + ) {} + + async analyzeLocation( + request: AnalyzeLocationRequest, + credentials: BackstageCredentials, + ): Promise { + const authorizeDecision = ( + await this.permissionApi.authorize( + [ + { + permission: catalogLocationAnalyzePermission, + }, + ], + { credentials: credentials }, + ) + )[0]; + if (authorizeDecision.result !== AuthorizeResult.ALLOW) { + throw new NotAllowedError(); + } + return this.service.analyzeLocation(request, credentials); + } +} diff --git a/plugins/catalog-backend/src/service/AuthorizedValidationService.test.ts b/plugins/catalog-backend/src/service/AuthorizedValidationService.test.ts new file mode 100644 index 0000000000..1e4f74a97b --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedValidationService.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2021 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 } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { mockCredentials } from '@backstage/backend-test-utils'; +import { AuthorizedValidationService } from './AuthorizedValidationService'; + +describe('AuthorizedValidationService', () => { + const orchestratorService = { + process: jest.fn(), + }; + const permissionApi = { + authorize: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('throws Authorization Error on deny', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + const authorizedService = new AuthorizedValidationService( + orchestratorService, + permissionApi as unknown as ServerPermissionClient, + ); + const entityProcessingRequest = { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'your-entity-name', + namespace: 'default', + description: 'your-entity-description', + }, + spec: { + type: 'service', + owner: 'team-a', + }, + }, + }; + await expect(() => + authorizedService.process( + entityProcessingRequest, + mockCredentials.none(), + ), + ).rejects.toThrow(NotAllowedError); + }); + + it('calls process on allow', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + const authorizedService = new AuthorizedValidationService( + orchestratorService, + permissionApi as unknown as ServerPermissionClient, + ); + const entityProcessingRequest = { + entity: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'your-entity-name', + namespace: 'default', + description: 'your-entity-description', + }, + spec: { + type: 'service', + owner: 'team-a', + }, + }, + }; + await authorizedService.process( + entityProcessingRequest, + mockCredentials.none(), + ); + expect(orchestratorService.process).toHaveBeenCalledWith( + entityProcessingRequest, + ); + }); +}); diff --git a/plugins/catalog-backend/src/service/AuthorizedValidationService.ts b/plugins/catalog-backend/src/service/AuthorizedValidationService.ts new file mode 100644 index 0000000000..870368c9ee --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedValidationService.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 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 } from '@backstage/errors'; +import { catalogEntityValidatePermission } from '@backstage/plugin-catalog-common/alpha'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + CatalogProcessingOrchestrator, + EntityProcessingRequest, + EntityProcessingResult, +} from '../processing/types'; +import { + BackstageCredentials, + PermissionsService, +} from '@backstage/backend-plugin-api'; + +export class AuthorizedValidationService { + constructor( + private readonly service: CatalogProcessingOrchestrator, + private readonly permissionApi: PermissionsService, + ) {} + + async process( + request: EntityProcessingRequest, + credentials: BackstageCredentials, + ): Promise { + const authorizeDecision = ( + await this.permissionApi.authorize( + [ + { + permission: catalogEntityValidatePermission, + }, + ], + { credentials }, + ) + )[0]; + + if (authorizeDecision.result !== AuthorizeResult.ALLOW) { + throw new NotAllowedError(); + } + return this.service.process(request); + } +} diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 8f020c8c7c..dac0a64e59 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -56,6 +56,7 @@ import { import { ConfigLocationEntityProvider } from '../modules/core/ConfigLocationEntityProvider'; import { DefaultLocationStore } from '../modules/core/DefaultLocationStore'; import { RepoLocationAnalyzer } from '../ingestion/LocationAnalyzer'; +import { AuthorizedLocationAnalyzer } from './AuthorizedLocationAnalyzer'; import { jsonPlaceholderResolver, textPlaceholderResolver, @@ -515,15 +516,7 @@ export class CatalogBuilder { }); const integrations = ScmIntegrations.fromConfig(config); const rulesEnforcer = DefaultCatalogRulesEnforcer.fromConfig(config); - const orchestrator = new DefaultCatalogProcessingOrchestrator({ - processors, - integrations, - rulesEnforcer, - logger, - parser, - policy, - legacySingleProcessorValidation: this.legacySingleProcessorValidation, - }); + const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog({ database: dbClient, logger, @@ -540,6 +533,16 @@ export class CatalogBuilder { permissionsService = toPermissionEvaluator(permissions); } + const orchestrator = new DefaultCatalogProcessingOrchestrator({ + processors, + integrations, + rulesEnforcer, + logger, + parser, + policy, + legacySingleProcessorValidation: this.legacySingleProcessorValidation, + }); + const entitiesCatalog = new AuthorizedEntitiesCatalog( unauthorizedEntitiesCatalog, permissionsService, @@ -599,7 +602,10 @@ export class CatalogBuilder { const locationAnalyzer = this.locationAnalyzer ?? - new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers); + new AuthorizedLocationAnalyzer( + new RepoLocationAnalyzer(logger, integrations, this.locationAnalyzers), + permissionsService, + ); const locationService = new AuthorizedLocationService( new DefaultLocationService(locationStore, orchestrator, { allowedLocationTypes: this.allowedLocationType, @@ -622,6 +628,7 @@ export class CatalogBuilder { permissionIntegrationRouter, auth, httpAuth, + permissionsService, }); await connectEntityProviders(providerDatabase, entityProviders); diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 5687bc1f85..9cc0717807 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -42,6 +42,7 @@ import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; +import { PermissionsService } from '@backstage/backend-plugin-api'; describe('createRouter readonly disabled', () => { let entitiesCatalog: jest.Mocked; @@ -50,6 +51,7 @@ describe('createRouter readonly disabled', () => { let app: express.Express | Server; let refreshService: RefreshService; let locationAnalyzer: jest.Mocked; + let permissionsService: jest.Mocked; beforeAll(async () => { entitiesCatalog = { @@ -71,6 +73,11 @@ describe('createRouter readonly disabled', () => { locationAnalyzer = { analyzeLocation: jest.fn(), }; + permissionsService = { + authorize: jest.fn(), + authorizeConditional: jest.fn(), + }; + refreshService = { refresh: jest.fn() }; orchestrator = { process: jest.fn() }; const router = await createRouter({ @@ -84,6 +91,7 @@ describe('createRouter readonly disabled', () => { auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), locationAnalyzer, + permissionsService: permissionsService, }); app = wrapInOpenApiTestServer(express().use(router)); }); @@ -725,6 +733,12 @@ describe('createRouter readonly disabled', () => { metadata: { name: 'n' }, }; + permissionsService.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + orchestrator.process.mockResolvedValueOnce({ ok: true, state: {}, @@ -765,6 +779,12 @@ describe('createRouter readonly disabled', () => { metadata: { name: 'invalid*name' }, }; + permissionsService.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + orchestrator.process.mockResolvedValueOnce({ ok: false, errors: [new Error('Invalid entity name')], @@ -802,6 +822,12 @@ describe('createRouter readonly disabled', () => { metadata: { name: 'n' }, }; + permissionsService.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + const response = await request(app) .post('/validate-entity') .send({ entity, location: null }); @@ -848,6 +874,7 @@ describe('createRouter readonly enabled', () => { let entitiesCatalog: jest.Mocked; let app: express.Express; let locationService: jest.Mocked; + let permissionsService: jest.Mocked; beforeAll(async () => { entitiesCatalog = { @@ -877,6 +904,7 @@ describe('createRouter readonly enabled', () => { permissionIntegrationRouter: express.Router(), auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), + permissionsService: permissionsService, }); app = express().use(router); }); @@ -1046,6 +1074,7 @@ describe('NextRouter permissioning', () => { let locationService: jest.Mocked; let app: express.Express; let refreshService: RefreshService; + let permissionsService: jest.Mocked; const fakeRule = createPermissionRule({ name: 'FAKE_RULE', @@ -1092,6 +1121,7 @@ describe('NextRouter permissioning', () => { }), auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), + permissionsService: permissionsService, }); app = express().use(router); }); diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 83084efa97..4a89cfc6cb 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -53,8 +53,10 @@ import { HttpAuthService, LoggerService, SchedulerService, + PermissionsService, } from '@backstage/backend-plugin-api'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; +import { AuthorizedValidationService } from './AuthorizedValidationService'; /** * Options used by {@link createRouter}. @@ -74,6 +76,7 @@ export interface RouterOptions { permissionIntegrationRouter?: express.Router; auth: AuthService; httpAuth: HttpAuthService; + permissionsService: PermissionsService; } /** @@ -98,6 +101,7 @@ export async function createRouter( config, logger, permissionIntegrationRouter, + permissionsService, auth, httpAuth, } = options; @@ -301,9 +305,13 @@ export async function createRouter( location: locationInput, catalogFilename: z.string().optional(), }); + const credentials = await httpAuth.credentials(req); const parsedBody = schema.parse(body); try { - const output = await locationAnalyzer.analyzeLocation(parsedBody); + const output = await locationAnalyzer.analyzeLocation( + parsedBody, + credentials, + ); res.status(200).json(output); } catch (err) { if ( @@ -342,19 +350,27 @@ export async function createRouter( }); } - const processingResult = await orchestrator.process({ - entity: { - ...entity, - metadata: { - ...entity.metadata, - annotations: { - [ANNOTATION_LOCATION]: body.location, - [ANNOTATION_ORIGIN_LOCATION]: body.location, - ...entity.metadata.annotations, + const credentials = await httpAuth.credentials(req); + const authorizedValidationService = new AuthorizedValidationService( + orchestrator, + permissionsService, + ); + const processingResult = await authorizedValidationService.process( + { + entity: { + ...entity, + metadata: { + ...entity.metadata, + annotations: { + [ANNOTATION_LOCATION]: body.location, + [ANNOTATION_ORIGIN_LOCATION]: body.location, + ...entity.metadata.annotations, + }, }, }, }, - }); + credentials, + ); if (!processingResult.ok) res.status(400).json({ diff --git a/plugins/catalog-common/api-report-alpha.md b/plugins/catalog-common/api-report-alpha.md index 12e0806a9c..86e606dd84 100644 --- a/plugins/catalog-common/api-report-alpha.md +++ b/plugins/catalog-common/api-report-alpha.md @@ -23,6 +23,12 @@ export const catalogEntityReadPermission: ResourcePermission<'catalog-entity'>; // @alpha export const catalogEntityRefreshPermission: ResourcePermission<'catalog-entity'>; +// @alpha +export const catalogEntityValidatePermission: BasicPermission; + +// @alpha +export const catalogLocationAnalyzePermission: BasicPermission; + // @alpha export const catalogLocationCreatePermission: BasicPermission; diff --git a/plugins/catalog-common/src/alpha.ts b/plugins/catalog-common/src/alpha.ts index 2dfc028332..8a3ed17431 100644 --- a/plugins/catalog-common/src/alpha.ts +++ b/plugins/catalog-common/src/alpha.ts @@ -20,9 +20,11 @@ export { catalogEntityCreatePermission, catalogEntityDeletePermission, catalogEntityRefreshPermission, + catalogEntityValidatePermission, catalogLocationReadPermission, catalogLocationCreatePermission, catalogLocationDeletePermission, + catalogLocationAnalyzePermission, catalogPermissions, } from './permissions'; export type { CatalogEntityPermission } from './permissions'; diff --git a/plugins/catalog-common/src/permissions.ts b/plugins/catalog-common/src/permissions.ts index 3082c0da70..fa86268f61 100644 --- a/plugins/catalog-common/src/permissions.ts +++ b/plugins/catalog-common/src/permissions.ts @@ -91,6 +91,15 @@ export const catalogEntityRefreshPermission = createPermission({ resourceType: RESOURCE_TYPE_CATALOG_ENTITY, }); +/** + * This permission is used to authorize validating catalog entities. + * @alpha + */ +export const catalogEntityValidatePermission = createPermission({ + name: 'catalog.entity.validate', + attributes: {}, +}); + /** * This permission is used to designate actions that involve reading one or more * locations from the catalog. @@ -118,6 +127,15 @@ export const catalogLocationCreatePermission = createPermission({ }, }); +/** + * This permission is used to authorize analyzing catalog locations. + * @alpha + */ +export const catalogLocationAnalyzePermission = createPermission({ + name: 'catalog.location.analyze', + attributes: {}, +}); + /** * This permission is used to designate actions that involve deleting locations * from the catalog. @@ -139,7 +157,9 @@ export const catalogPermissions = [ catalogEntityCreatePermission, catalogEntityDeletePermission, catalogEntityRefreshPermission, + catalogEntityValidatePermission, catalogLocationReadPermission, catalogLocationCreatePermission, catalogLocationDeletePermission, + catalogLocationAnalyzePermission, ]; diff --git a/plugins/catalog-node/api-report.md b/plugins/catalog-node/api-report.md index 7c1c8b5911..43209aa862 100644 --- a/plugins/catalog-node/api-report.md +++ b/plugins/catalog-node/api-report.md @@ -8,6 +8,7 @@ import { AnalyzeLocationExistingEntity } from '@backstage/plugin-catalog-common'; import { AnalyzeLocationRequest } from '@backstage/plugin-catalog-common'; import { AnalyzeLocationResponse } from '@backstage/plugin-catalog-common'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Entity } from '@backstage/catalog-model'; import { JsonValue } from '@backstage/types'; @@ -172,6 +173,7 @@ export type EntityRelationSpec = { export type LocationAnalyzer = { analyzeLocation( location: AnalyzeLocationRequest, + credentials: BackstageCredentials, ): Promise; }; diff --git a/plugins/catalog-node/src/processing/types.ts b/plugins/catalog-node/src/processing/types.ts index e3d3c305fc..625effc4df 100644 --- a/plugins/catalog-node/src/processing/types.ts +++ b/plugins/catalog-node/src/processing/types.ts @@ -22,6 +22,7 @@ import { } from '@backstage/plugin-catalog-common'; import { JsonValue } from '@backstage/types'; import { CatalogProcessorEmit } from '../api'; +import { BackstageCredentials } from '@backstage/backend-plugin-api'; /** * Entities that are not yet processed. @@ -66,6 +67,7 @@ export type LocationAnalyzer = { */ analyzeLocation( location: AnalyzeLocationRequest, + credentials: BackstageCredentials, ): Promise; };