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:
@@ -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": "...",
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user