Integrate permissions with catalog-backend refresh endpoint (#8693)

Integration permission framework with refresh in catalog-backend

... through the use of a new AuthorizedRefreshService.

Signed-off-by: Joon Park <joonp@spotify.com>
This commit is contained in:
Joon Park
2022-01-07 15:49:26 +00:00
committed by GitHub
parent 3cf9ff0ba4
commit cd529c4094
17 changed files with 265 additions and 8 deletions
+60
View File
@@ -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": "...",
```
+7
View File
@@ -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.
+2
View File
@@ -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": "*",
+4
View File
@@ -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,
@@ -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'}}",
@@ -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,
};
};
}
@@ -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;
};
+3
View File
@@ -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
+2 -1
View File
@@ -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",
@@ -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 () => {
@@ -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);
});
});
@@ -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);
}
}
@@ -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,
@@ -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',
});
});
});
@@ -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];
}
@@ -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();
@@ -34,6 +34,7 @@ export interface LocationService {
export type RefreshOptions = {
/** The reference to a single entity that should be refreshed */
entityRef: string;
authorizationToken?: string;
};
/**