diff --git a/.changeset/plenty-eyes-brush.md b/.changeset/plenty-eyes-brush.md new file mode 100644 index 0000000000..354c216c3c --- /dev/null +++ b/.changeset/plenty-eyes-brush.md @@ -0,0 +1,60 @@ +--- +'@backstage/create-app': patch +--- + +Add permissions to create-app's PluginEnvironment + +`CatalogEnvironment` now has a `permissions` field, which means that a permission client must now be provided as part of `PluginEnvironment`. To apply these changes to an existing app, add the following to the `makeCreateEnv` function in `packages/backend/src/index.ts`: + +```diff + // packages/backend/src/index.ts + ++ import { ServerPermissionClient } from '@backstage/plugin-permission-node'; + + function makeCreateEnv(config: Config) { + ... ++ const permissions = ServerPerimssionClient.fromConfig(config, { ++ discovery, ++ tokenManager, ++ }); + + root.info(`Created UrlReader ${reader}`); + + return (plugin: string): PluginEnvironment => { + ... + return { + logger, + cache, + database, + config, + reader, + discovery, + tokenManager, + scheduler, ++ permissions, + }; + } + } +``` + +And add a permissions field to the `PluginEnvironment` type in `packages/backend/src/types.ts`: + +```diff + // packages/backend/src/types.ts + ++ import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; + + export type PluginEnvironment = { + ... ++ permissions: PermissionAuthorizer; + }; +``` + +[`@backstage/plugin-permission-common`](https://www.npmjs.com/package/@backstage/plugin-permission-common) and [`@backstage/plugin-permission-node`](https://www.npmjs.com/package/@backstage/plugin-permission-node) will need to be installed as dependencies: + +```diff + // packages/backend/package.json + ++ "@backstage/plugin-permission-common": "...", ++ "@backstage/plugin-permission-node": "...", +``` diff --git a/.changeset/three-sheep-sparkle.md b/.changeset/three-sheep-sparkle.md new file mode 100644 index 0000000000..db6d8148ce --- /dev/null +++ b/.changeset/three-sheep-sparkle.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +In order to integrate the permissions system with the refresh endpoint in catalog-backend, a new AuthorizedRefreshService was created as a thin wrapper around the existing refresh service which performs authorization and handles the case when authorization is denied. In order to instantiate AuthorizedRefreshService, a permission client is required, which was added as a new field to `CatalogEnvironment`. + +The new `permissions` field in `CatalogEnvironment` should already receive the permission client from the `PluginEnvrionment`, so there should be no changes required to the catalog backend setup. See [the create-app changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md) for more details. diff --git a/packages/create-app/package.json b/packages/create-app/package.json index e95dbb3a9d..639e0370d7 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -67,6 +67,8 @@ "@backstage/plugin-explore": "*", "@backstage/plugin-github-actions": "*", "@backstage/plugin-lighthouse": "*", + "@backstage/plugin-permission-common": "*", + "@backstage/plugin-permission-node": "*", "@backstage/plugin-proxy-backend": "*", "@backstage/plugin-rollbar-backend": "*", "@backstage/plugin-scaffolder": "*", diff --git a/packages/create-app/src/lib/versions.ts b/packages/create-app/src/lib/versions.ts index 3765422923..f89ff7f391 100644 --- a/packages/create-app/src/lib/versions.ts +++ b/packages/create-app/src/lib/versions.ts @@ -57,6 +57,8 @@ import { version as pluginExplore } from '../../../../plugins/explore/package.js import { version as pluginGithubActions } from '../../../../plugins/github-actions/package.json'; import { version as pluginLighthouse } from '../../../../plugins/lighthouse/package.json'; import { version as pluginOrg } from '../../../../plugins/org/package.json'; +import { version as pluginPermissionCommon } from '../../../../plugins/permission-common/package.json'; +import { version as pluginPermissionNode } from '../../../../plugins/permission-node/package.json'; import { version as pluginProxyBackend } from '../../../../plugins/proxy-backend/package.json'; import { version as pluginRollbarBackend } from '../../../../plugins/rollbar-backend/package.json'; import { version as pluginScaffolder } from '../../../../plugins/scaffolder/package.json'; @@ -94,6 +96,8 @@ export const packageVersions = { '@backstage/plugin-github-actions': pluginGithubActions, '@backstage/plugin-lighthouse': pluginLighthouse, '@backstage/plugin-org': pluginOrg, + '@backstage/plugin-permission-common': pluginPermissionCommon, + '@backstage/plugin-permission-node': pluginPermissionNode, '@backstage/plugin-proxy-backend': pluginProxyBackend, '@backstage/plugin-rollbar-backend': pluginRollbarBackend, '@backstage/plugin-scaffolder': pluginScaffolder, diff --git a/packages/create-app/templates/default-app/packages/backend/package.json.hbs b/packages/create-app/templates/default-app/packages/backend/package.json.hbs index a2ee89c546..96dc724a1e 100644 --- a/packages/create-app/templates/default-app/packages/backend/package.json.hbs +++ b/packages/create-app/templates/default-app/packages/backend/package.json.hbs @@ -23,6 +23,8 @@ "@backstage/plugin-app-backend": "^{{version '@backstage/plugin-app-backend'}}", "@backstage/plugin-auth-backend": "^{{version '@backstage/plugin-auth-backend'}}", "@backstage/plugin-catalog-backend": "^{{version '@backstage/plugin-catalog-backend'}}", + "@backstage/plugin-permission-common": "^{{version '@backstage/plugin-permission-common'}}", + "@backstage/plugin-permission-node": "^{{version '@backstage/plugin-permission-node'}}", "@backstage/plugin-proxy-backend": "^{{version '@backstage/plugin-proxy-backend'}}", "@backstage/plugin-scaffolder-backend": "^{{version '@backstage/plugin-scaffolder-backend'}}", "@backstage/plugin-search-backend": "^{{version '@backstage/plugin-search-backend'}}", diff --git a/packages/create-app/templates/default-app/packages/backend/src/index.ts b/packages/create-app/templates/default-app/packages/backend/src/index.ts index 08d21e61f7..70bc66bcdd 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/index.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/index.ts @@ -29,6 +29,7 @@ import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import search from './plugins/search'; import { PluginEnvironment } from './types'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -38,6 +39,10 @@ function makeCreateEnv(config: Config) { const databaseManager = DatabaseManager.fromConfig(config); const tokenManager = ServerTokenManager.noop(); const taskScheduler = TaskScheduler.fromConfig(config); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); root.info(`Created UrlReader ${reader}`); @@ -55,6 +60,7 @@ function makeCreateEnv(config: Config) { discovery, tokenManager, scheduler, + permissions, }; }; } diff --git a/packages/create-app/templates/default-app/packages/backend/src/types.ts b/packages/create-app/templates/default-app/packages/backend/src/types.ts index c3d0158dc6..0862b0e874 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/types.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/types.ts @@ -8,6 +8,7 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -18,4 +19,5 @@ export type PluginEnvironment = { discovery: PluginEndpointDiscovery; tokenManager: TokenManager; scheduler: PluginTaskScheduler; + permissions: PermissionAuthorizer; }; diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 8536dafe76..6d7834001a 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -25,6 +25,7 @@ import { Location as Location_2 } from '@backstage/catalog-model'; import { LocationSpec } from '@backstage/catalog-model'; import { Logger as Logger_2 } from 'winston'; import { Organizations } from 'aws-sdk'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; import { PluginEndpointDiscovery } from '@backstage/backend-common'; @@ -302,6 +303,7 @@ export type CatalogEnvironment = { database: PluginDatabaseManager; config: Config; reader: UrlReader; + permissions: PermissionAuthorizer; }; // @public @@ -1493,6 +1495,7 @@ export type RefreshIntervalFunction = () => number; // @public export type RefreshOptions = { entityRef: string; + authorizationToken?: string; }; // @public diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 8472c33369..504e86a736 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -32,11 +32,12 @@ "dependencies": { "@backstage/backend-common": "^0.10.1", "@backstage/catalog-client": "^0.5.3", - "@backstage/plugin-catalog-common": "^0.1.0", "@backstage/catalog-model": "^0.9.8", "@backstage/config": "^0.1.11", "@backstage/errors": "^0.1.5", "@backstage/integration": "^0.7.0", + "@backstage/plugin-catalog-common": "^0.1.0", + "@backstage/plugin-permission-common": "^0.3.0", "@backstage/plugin-permission-node": "^0.2.3", "@backstage/search-common": "^0.2.1", "@backstage/types": "^0.1.1", diff --git a/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts index 926aa67635..728d35ea3d 100644 --- a/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts +++ b/plugins/catalog-backend/src/legacy/service/CatalogBuilder.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { + getVoidLogger, + PluginEndpointDiscovery, + ServerTokenManager, + UrlReader, +} from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { ConfigReader } from '@backstage/config'; import { Knex } from 'knex'; @@ -24,6 +29,7 @@ import { CatalogProcessorParser } from '../../ingestion'; import * as result from '../../ingestion/processors/results'; import { CatalogBuilder } from './CatalogBuilder'; import { CatalogEnvironment } from '../../service'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; const dummyEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -47,11 +53,25 @@ describe('CatalogBuilder', () => { readTree: jest.fn(), search: jest.fn(), }; + const config = new ConfigReader({}); + const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; + const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + return mockBaseUrl; + }, + }; const env: CatalogEnvironment = { logger: getVoidLogger(), database: { getClient: async () => db }, - config: new ConfigReader({}), + config, reader, + permissions: ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager: ServerTokenManager.noop(), + }), }; beforeEach(async () => { diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts new file mode 100644 index 0000000000..82bedec573 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { AuthorizedRefreshService } from './AuthorizedRefreshService'; + +describe('AuthorizedRefreshService', () => { + const refreshService = { + refresh: jest.fn(), + }; + const permissionApi = { + authorize: jest.fn(), + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('throws AuthorizationError on deny', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.DENY, + }, + ]); + const authorizedService = new AuthorizedRefreshService( + refreshService, + permissionApi as unknown as ServerPermissionClient, + ); + + await expect(() => + authorizedService.refresh({ + entityRef: 'some entity ref', + authorizationToken: 'some auth token', + }), + ).rejects.toThrowError(NotAllowedError); + }); + + it('calls refresh on allow', async () => { + permissionApi.authorize.mockResolvedValueOnce([ + { + result: AuthorizeResult.ALLOW, + }, + ]); + const authorizedService = new AuthorizedRefreshService( + refreshService, + permissionApi as unknown as ServerPermissionClient, + ); + + const options = { + entityRef: 'some entity ref', + authorizationToken: 'some auth token', + }; + await authorizedService.refresh(options); + + expect(refreshService.refresh).toHaveBeenCalledWith(options); + }); +}); diff --git a/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts new file mode 100644 index 0000000000..800bdcf1b9 --- /dev/null +++ b/plugins/catalog-backend/src/service/AuthorizedRefreshService.ts @@ -0,0 +1,47 @@ +/* + * 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 { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common'; +import { + AuthorizeResult, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; +import { RefreshOptions, RefreshService } from './types'; + +export class AuthorizedRefreshService implements RefreshService { + constructor( + private readonly service: RefreshService, + private readonly permissionApi: PermissionAuthorizer, + ) {} + + async refresh(options: RefreshOptions) { + const authorizeResponse = ( + await this.permissionApi.authorize( + [ + { + permission: catalogEntityRefreshPermission, + resourceRef: options.entityRef, + }, + ], + { token: options.authorizationToken }, + ) + )[0]; + if (authorizeResponse.result !== AuthorizeResult.ALLOW) { + throw new NotAllowedError(); + } + await this.service.refresh(options); + } +} diff --git a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts index d0c01c3e71..1dcb632e64 100644 --- a/plugins/catalog-backend/src/service/NextCatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/NextCatalogBuilder.ts @@ -77,6 +77,7 @@ import { } from '../processing/refresh'; import { createNextRouter } from './NextRouter'; import { DefaultRefreshService } from './DefaultRefreshService'; +import { AuthorizedRefreshService } from './AuthorizedRefreshService'; import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules'; import { Config } from '@backstage/config'; import { Logger } from 'winston'; @@ -84,12 +85,14 @@ import { LocationService } from './types'; import { connectEntityProviders } from '../processing/connectEntityProviders'; import { CatalogPermissionRule } from '../permissions/types'; import { permissionRules as catalogPermissionRules } from '../permissions/rules'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; export type CatalogEnvironment = { logger: Logger; database: PluginDatabaseManager; config: Config; reader: UrlReader; + permissions: PermissionAuthorizer; }; /** @@ -344,7 +347,7 @@ export class NextCatalogBuilder { locationService: LocationService; router: Router; }> { - const { config, database, logger } = this.env; + const { config, database, logger, permissions } = this.env; const policy = this.buildEntityPolicy(); const processors = this.buildProcessors(); @@ -398,9 +401,10 @@ export class NextCatalogBuilder { locationStore, orchestrator, ); - const refreshService = new DefaultRefreshService({ - database: processingDatabase, - }); + const refreshService = new AuthorizedRefreshService( + new DefaultRefreshService({ database: processingDatabase }), + permissions, + ); const router = await createNextRouter({ entitiesCatalog, locationAnalyzer, diff --git a/plugins/catalog-backend/src/service/NextRouter.test.ts b/plugins/catalog-backend/src/service/NextRouter.test.ts index c31de07718..e835c4620a 100644 --- a/plugins/catalog-backend/src/service/NextRouter.test.ts +++ b/plugins/catalog-backend/src/service/NextRouter.test.ts @@ -66,10 +66,12 @@ describe('createNextRouter readonly disabled', () => { const response = await request(app) .post('/refresh') .set('Content-Type', 'application/json') + .set('authorization', 'Bearer someauthtoken') .send({ entityRef: 'Component/default:foo' }); expect(response.status).toBe(200); expect(refreshService.refresh).toHaveBeenCalledWith({ entityRef: 'Component/default:foo', + authorizationToken: 'someauthtoken', }); }); }); diff --git a/plugins/catalog-backend/src/service/NextRouter.ts b/plugins/catalog-backend/src/service/NextRouter.ts index 059f937dd7..7aae564a45 100644 --- a/plugins/catalog-backend/src/service/NextRouter.ts +++ b/plugins/catalog-backend/src/service/NextRouter.ts @@ -40,7 +40,7 @@ import { parseEntityTransformParams, } from '../service/request'; import { disallowReadonlyMode, validateRequestBody } from '../service/util'; -import { RefreshService, RefreshOptions, LocationService } from './types'; +import { RefreshOptions, LocationService, RefreshService } from './types'; export interface NextRouterOptions { entitiesCatalog?: EntitiesCatalog; @@ -77,6 +77,10 @@ export async function createNextRouter( if (refreshService) { router.post('/refresh', async (req, res) => { const refreshOptions: RefreshOptions = req.body; + refreshOptions.authorizationToken = getBearerToken( + req.header('authorization'), + ); + await refreshService.refresh(refreshOptions); res.status(200).send(); }); @@ -214,3 +218,13 @@ async function getEntityResource( return entities[0]; } + +function getBearerToken( + authorizationHeader: string | undefined, +): string | undefined { + if (typeof authorizationHeader !== 'string') { + return undefined; + } + const matches = authorizationHeader.match(/Bearer\s+(\S+)/i); + return matches?.[1]; +} diff --git a/plugins/catalog-backend/src/service/standaloneServer.ts b/plugins/catalog-backend/src/service/standaloneServer.ts index 7aae3cd47c..8824c3b0d6 100644 --- a/plugins/catalog-backend/src/service/standaloneServer.ts +++ b/plugins/catalog-backend/src/service/standaloneServer.ts @@ -17,6 +17,8 @@ import { createServiceBuilder, loadBackendConfig, + ServerTokenManager, + SingleHostDiscovery, UrlReaders, useHotMemoize, } from '@backstage/backend-common'; @@ -25,6 +27,7 @@ import { Logger } from 'winston'; import { DatabaseManager } from '../legacy/database'; import { CatalogBuilder } from '../legacy/service/CatalogBuilder'; import { createRouter } from '../legacy/service'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export interface ServerOptions { port: number; @@ -42,6 +45,12 @@ export async function startStandaloneServer( const db = useHotMemoize(module, () => DatabaseManager.createInMemoryDatabaseConnection(), ); + const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); logger.debug('Creating application...'); const builder = new CatalogBuilder({ @@ -49,6 +58,7 @@ export async function startStandaloneServer( database: { getClient: () => db }, config, reader, + permissions, }); const { entitiesCatalog, locationsCatalog, higherOrderOperation } = await builder.build(); diff --git a/plugins/catalog-backend/src/service/types.ts b/plugins/catalog-backend/src/service/types.ts index 05d3d00f4d..5e2bf2a599 100644 --- a/plugins/catalog-backend/src/service/types.ts +++ b/plugins/catalog-backend/src/service/types.ts @@ -34,6 +34,7 @@ export interface LocationService { export type RefreshOptions = { /** The reference to a single entity that should be refreshed */ entityRef: string; + authorizationToken?: string; }; /**