Merge pull request #7943 from backstage/natasha/b2b-auth

Support backend to backend authentication
This commit is contained in:
MT Lewis
2021-11-26 15:31:52 +00:00
committed by GitHub
29 changed files with 661 additions and 29 deletions
+51
View File
@@ -0,0 +1,51 @@
---
'@backstage/create-app': patch
---
Incorporate usage of the tokenManager into the backend created using `create-app`.
In existing backends, update the `PluginEnvironment` to include a `tokenManager`:
```diff
// packages/backend/src/types.ts
...
import {
...
+ TokenManager,
} from '@backstage/backend-common';
export type PluginEnvironment = {
...
+ tokenManager: TokenManager;
};
```
Then, create a `ServerTokenManager`. This can either be a `noop` that requires no secret and validates all requests by default, or one that uses a secret from your `app-config.yaml` to generate and validate tokens.
```diff
// packages/backend/src/index.ts
...
import {
...
+ ServerTokenManager,
} from '@backstage/backend-common';
...
function makeCreateEnv(config: Config) {
...
// CHOOSE ONE
// TokenManager not requiring a secret
+ const tokenManager = ServerTokenManager.noop();
// OR TokenManager requiring a secret
+ const tokenManager = ServerTokenManager.fromConfig(config);
...
return (plugin: string): PluginEnvironment => {
...
- return { logger, cache, database, config, reader, discovery };
+ return { logger, cache, database, config, reader, discovery, tokenManager };
};
}
```
+28
View File
@@ -0,0 +1,28 @@
---
'@backstage/plugin-techdocs-backend': minor
---
**BREAKING** `DefaultTechDocsCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`:
```diff
// packages/backend/src/plugins/search.ts
...
export default async function createPlugin({
...
+ tokenManager,
}: PluginEnvironment) {
...
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultTechDocsCollator.fromConfig(config, {
discovery,
logger,
+ tokenManager,
}),
});
...
}
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Create a `TokenManager` interface and `ServerTokenManager` implementation to generate and validate server tokens for authenticated backend-to-backend API requests.
+27
View File
@@ -0,0 +1,27 @@
---
'@backstage/plugin-catalog-backend': minor
---
**BREAKING** `DefaultCatalogCollator` has a new required option `tokenManager`. See the create-app changelog for how to create a `tokenManager` and add it to the `PluginEnvironment`. It can then be passed to the collator in `createPlugin`:
```diff
// packages/backend/src/plugins/search.ts
...
export default async function createPlugin({
...
+ tokenManager,
}: PluginEnvironment) {
...
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultCatalogCollator.fromConfig(config, {
discovery,
+ tokenManager,
}),
});
...
}
```
+5
View File
@@ -23,6 +23,11 @@ app:
title: '#backstage'
backend:
# Used for enabling authentication, secret is shared by all backend plugins
# See backend-to-backend-auth.md in the docs for information on the format
# auth:
# keys:
# - secret: ${BACKEND_SECRET}
baseUrl: http://localhost:7007
listen:
port: 7007
+13 -3
View File
@@ -154,13 +154,17 @@ import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
export default async function createPlugin({
logger,
discovery,
tokenManager,
}: PluginEnvironment) {
const searchEngine = new LunrSearchEngine({ logger });
const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
collator: new DefaultCatalogCollator({
discovery,
tokenManager,
}),
});
const { scheduler } = await indexBuilder.build();
@@ -285,7 +289,10 @@ const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
collator: new DefaultCatalogCollator({
discovery,
tokenManager,
}),
});
indexBuilder.addCollator({
@@ -303,6 +310,9 @@ its `defaultRefreshIntervalSeconds` value, like this:
```typescript {3}
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new DefaultCatalogCollator({ discovery }),
collator: new DefaultCatalogCollator({
discovery,
tokenManager,
}),
});
```
+2 -1
View File
@@ -2,7 +2,7 @@
id: how-to-guides
title: Search "HOW TO" guides
sidebar_label: "HOW TO" guides
description: Search "HOW TO" guides
description: Search "HOW TO" guides
---
## How to implement your own Search API
@@ -74,6 +74,7 @@ indexBuilder.addCollator({
collator: DefaultTechDocsCollator.fromConfig(config, {
discovery,
logger,
tokenManager,
}),
});
```
+68
View File
@@ -0,0 +1,68 @@
---
id: backend-to-backend-auth
title: Backend-to-Backend Authentication
description:
Guide for authenticating API requests between Backstage plugin backends
---
This tutorial describes the steps needed to handle _backend-to-backend
authentication_, which allows plugin backends to determine whether a given
request originates from a legitimate Backstage backend by verifying a token
signed with a shared secret. This system has limited use for now, but will be
needed to support the upcoming framework for permissions and authorization (see
[the PRFC on the topic](https://github.com/backstage/backstage/pull/7761) for
more details).
Backends have no concept of a Backstage identity, so instead they use a token
generated using a shared key stored in config. You can generate a unique key for
your app in a terminal, and set the `BACKEND_SECRET` environment variable to the
resulting value.
```bash
node -p 'require("crypto").randomBytes(24).toString("base64")'
```
Requests originating from a backend plugin can be authenticated by decorating
them with a backend token. Backend tokens can be generated using a
`TokenManager`, which can be passed to plugin backends via the
`PluginEnvironment`. The `TokenManager` provided in new Backstage instances
generated by `create-app` is a stub, which returns empty tokens and accepts any
input string as valid. To enable backend-to-backend authentication, you'll need
to instantiate a new one using the secret from your config instead:
```diff
// packages/backend/src/index.ts
function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
root.info(`Created UrlReader ${reader}`);
const cacheManager = CacheManager.fromConfig(config);
const databaseManager = DatabaseManager.fromConfig(config);
- const tokenManager = ServerTokenManager.noop();
+ const tokenManager = ServerTokenManager.fromConfig(config);
```
With this `tokenManager`, you can then generate a server token for requests:
```typescript
const { token } = await this.tokenManager.getToken();
const response = await fetch(pluginBackendApiUrl, {
method: 'GET',
headers: {
...headers,
Authorization: `Bearer ${token}`,
},
});
```
You can use the same `tokenManager` to authenticate tokens supplied on incoming
requests:
```typescript
await tokenManager.authenticate(token); // throws if token is invalid
```
+24
View File
@@ -526,6 +526,20 @@ export type SearchResponseFile = {
content(): Promise<Buffer>;
};
// @public
export class ServerTokenManager implements TokenManager {
// (undocumented)
authenticate(token: string): Promise<void>;
// (undocumented)
static fromConfig(config: Config): ServerTokenManager;
// (undocumented)
getToken(): Promise<{
token: string;
}>;
// (undocumented)
static noop(): TokenManager;
}
// @public (undocumented)
export type ServiceBuilder = {
loadConfig(config: Config): ServiceBuilder;
@@ -583,6 +597,16 @@ export interface StatusCheckHandlerOptions {
statusCheck?: StatusCheck;
}
// @public
export interface TokenManager {
// (undocumented)
authenticate: (token: string) => Promise<void>;
// (undocumented)
getToken: () => Promise<{
token: string;
}>;
}
// @public
export type UrlReader = {
read(url: string): Promise<Buffer>;
+14
View File
@@ -20,6 +20,20 @@ export interface Config {
};
backend: {
/** Backend configuration for when request authentication is enabled */
auth?: {
/** Keys shared by all backends for signing and validating backend tokens. */
keys: {
/**
* Secret for generating tokens. Should be a base64 string, recommended
* length is 24 bytes.
*
* @visibility secret
*/
secret: string;
}[];
};
baseUrl: string; // defined in core, but repeated here without doc
/** Address that the backend should listen to. */
+1
View File
@@ -54,6 +54,7 @@
"git-url-parse": "^11.6.0",
"helmet": "^4.0.0",
"isomorphic-git": "^1.8.0",
"jose": "^1.27.1",
"keyv": "^4.0.3",
"keyv-memcache": "^1.2.5",
"knex": "^0.95.1",
+1
View File
@@ -31,4 +31,5 @@ export * from './paths';
export * from './reading';
export * from './scm';
export * from './service';
export * from './tokens';
export * from './util';
@@ -0,0 +1,189 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { TokenManager } from './types';
import { ServerTokenManager } from './ServerTokenManager';
const emptyConfig = new ConfigReader({});
const configWithSecret = new ConfigReader({
backend: { auth: { keys: [{ secret: 'a-secret-key' }] } },
});
describe('ServerTokenManager', () => {
it('should throw if secret in config does not exist', () => {
expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError();
});
describe('getToken', () => {
it('should return a token if secret in config exists', async () => {
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
expect((await tokenManager.getToken()).token).toBeDefined();
});
it('should return a token string if using a noop TokenManager', async () => {
const tokenManager = ServerTokenManager.noop();
expect((await tokenManager.getToken()).token).toBeDefined();
});
});
describe('authenticate', () => {
it('should not throw if token is valid', async () => {
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
const { token } = await tokenManager.getToken();
await expect(tokenManager.authenticate(token)).resolves.not.toThrow();
});
it('should throw if token is invalid', async () => {
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
await expect(
tokenManager.authenticate('random-string'),
).rejects.toThrowError(/invalid server token/i);
});
it('should validate server tokens created by a different instance using the same secret', async () => {
const tokenManager1 = ServerTokenManager.fromConfig(configWithSecret);
const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret);
const { token } = await tokenManager1.getToken();
await expect(tokenManager2.authenticate(token)).resolves.not.toThrow();
});
it('should validate server tokens created using any of the secrets', async () => {
const tokenManager1 = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
}),
);
const tokenManager2 = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
}),
);
const tokenManager3 = ServerTokenManager.fromConfig(
new ConfigReader({
backend: {
auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] },
},
}),
);
const { token: token1 } = await tokenManager1.getToken();
await expect(tokenManager3.authenticate(token1)).resolves.not.toThrow();
const { token: token2 } = await tokenManager2.getToken();
await expect(tokenManager3.authenticate(token2)).resolves.not.toThrow();
});
it('should throw for server tokens created using a different secret', async () => {
const tokenManager1 = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
}),
);
const tokenManager2 = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'd4e5f6' }] } },
}),
);
const { token } = await tokenManager1.getToken();
await expect(tokenManager2.authenticate(token)).rejects.toThrowError(
/invalid server token/i,
);
});
it('should throw for server tokens created using a noop TokenManager', async () => {
const noopTokenManager = ServerTokenManager.noop();
const tokenManager = ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [{ secret: 'a1b2c3' }] } },
}),
);
const { token } = await noopTokenManager.getToken();
await expect(tokenManager.authenticate(token)).rejects.toThrowError(
/invalid server token/i,
);
});
});
describe('ServerTokenManager.fromConfig', () => {
it('should throw if backend auth configuration is missing', () => {
expect(() =>
ServerTokenManager.fromConfig(new ConfigReader({})),
).toThrow();
});
it('should throw if no keys are included in the configuration', () => {
expect(() =>
ServerTokenManager.fromConfig(
new ConfigReader({
backend: { auth: { keys: [] } },
}),
),
).toThrow();
});
it('should throw if any key is missing a secret property', () => {
expect(() =>
ServerTokenManager.fromConfig(
new ConfigReader({
backend: {
auth: {
keys: [{ secret: '123' }, {}, { secret: '789' }],
},
},
}),
),
).toThrow();
});
});
describe('ServerTokenManager.noop', () => {
let noopTokenManager: TokenManager;
beforeEach(() => {
noopTokenManager = ServerTokenManager.noop();
});
it('should accept tokens it generates', async () => {
const { token } = await noopTokenManager.getToken();
await expect(noopTokenManager.authenticate(token)).resolves.not.toThrow();
});
it('should accept tokens generated by other noop token managers', async () => {
const noopTokenManager2 = ServerTokenManager.noop();
await expect(
noopTokenManager.authenticate(
(
await noopTokenManager2.getToken()
).token,
),
).resolves.not.toThrow();
});
it('should accept signed tokens', async () => {
const tokenManager = ServerTokenManager.fromConfig(configWithSecret);
await expect(
noopTokenManager.authenticate((await tokenManager.getToken()).token),
).resolves.not.toThrow();
});
});
});
@@ -0,0 +1,80 @@
/*
* 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 { JWKS, JWK, JWT } from 'jose';
import { Config } from '@backstage/config';
import { AuthenticationError } from '@backstage/errors';
import { TokenManager } from './types';
class NoopTokenManager implements TokenManager {
async getToken() {
return { token: '' };
}
async authenticate() {}
}
/**
* Creates and validates tokens for use during backend-to-backend
* authentication.
*
* @public
*/
export class ServerTokenManager implements TokenManager {
private readonly verificationKeys: JWKS.KeyStore;
private readonly signingKey: JWK.Key;
static noop(): TokenManager {
return new NoopTokenManager();
}
static fromConfig(config: Config) {
return new ServerTokenManager(
config
.getConfigArray('backend.auth.keys')
.map(key => key.getString('secret')),
);
}
private constructor(secrets?: string[]) {
if (!secrets?.length) {
throw new Error(
'No secrets provided when constructing ServerTokenManager',
);
}
this.verificationKeys = new JWKS.KeyStore(
secrets.map(k => JWK.asKey({ kty: 'oct', k })),
);
this.signingKey = this.verificationKeys.all()[0];
}
async getToken(): Promise<{ token: string }> {
const jwt = JWT.sign({ sub: 'backstage-server' }, this.signingKey, {
algorithm: 'HS256',
});
return { token: jwt };
}
async authenticate(token: string): Promise<void> {
try {
JWT.verify(token, this.verificationKeys);
} catch (e) {
throw new AuthenticationError('Invalid server token');
}
}
}
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { ServerTokenManager } from './ServerTokenManager';
export type { TokenManager } from './types';
@@ -0,0 +1,25 @@
/*
* 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.
*/
/**
* Interface for creating and validating tokens.
*
* @public
*/
export interface TokenManager {
getToken: () => Promise<{ token: string }>;
authenticate: (token: string) => Promise<void>;
}
+3 -1
View File
@@ -33,6 +33,7 @@ import {
SingleHostDiscovery,
UrlReaders,
useHotMemoize,
ServerTokenManager,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
@@ -60,6 +61,7 @@ function makeCreateEnv(config: Config) {
const root = getRootLogger();
const reader = UrlReaders.default({ logger: root, config });
const discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
root.info(`Created UrlReader ${reader}`);
@@ -70,7 +72,7 @@ function makeCreateEnv(config: Config) {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
const cache = cacheManager.forPlugin(plugin);
return { logger, cache, database, config, reader, discovery };
return { logger, cache, database, config, reader, discovery, tokenManager };
};
}
+6 -1
View File
@@ -59,6 +59,7 @@ export default async function createPlugin({
discovery,
config,
database,
tokenManager,
}: PluginEnvironment) {
// Initialize a connection to a search engine.
const searchEngine = await createSearchEngine({ config, logger, database });
@@ -68,7 +69,10 @@ export default async function createPlugin({
// particular collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultCatalogCollator.fromConfig(config, { discovery }),
collator: DefaultCatalogCollator.fromConfig(config, {
discovery,
tokenManager,
}),
});
indexBuilder.addCollator({
@@ -76,6 +80,7 @@ export default async function createPlugin({
collator: DefaultTechDocsCollator.fromConfig(config, {
discovery,
logger,
tokenManager,
}),
});
+2
View File
@@ -20,6 +20,7 @@ import {
PluginCacheManager,
PluginDatabaseManager,
PluginEndpointDiscovery,
TokenManager,
UrlReader,
} from '@backstage/backend-common';
@@ -30,4 +31,5 @@ export type PluginEnvironment = {
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
};
@@ -6,6 +6,11 @@ organization:
name: My Company
backend:
# Used for enabling authentication, secret is shared by all backend plugins
# See backend-to-backend-auth.md in the docs for information on the format
# auth:
# keys:
# - secret: ${BACKEND_SECRET}
baseUrl: http://localhost:7007
listen:
port: 7007
@@ -17,6 +17,7 @@ import {
DatabaseManager,
SingleHostDiscovery,
UrlReaders,
ServerTokenManager,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import app from './plugins/app';
@@ -37,12 +38,13 @@ function makeCreateEnv(config: Config) {
const cacheManager = CacheManager.fromConfig(config);
const databaseManager = DatabaseManager.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
return (plugin: string): PluginEnvironment => {
const logger = root.child({ type: 'plugin', plugin });
const database = databaseManager.forPlugin(plugin);
const cache = cacheManager.forPlugin(plugin);
return { logger, database, cache, config, reader, discovery };
return { logger, database, cache, config, reader, discovery, tokenManager };
};
}
@@ -12,6 +12,7 @@ export default async function createPlugin({
logger,
discovery,
config,
tokenManager,
}: PluginEnvironment) {
// Initialize a connection to a search engine.
const searchEngine = new LunrSearchEngine({ logger });
@@ -21,13 +22,20 @@ export default async function createPlugin({
// collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultCatalogCollator.fromConfig(config, { discovery }),
collator: DefaultCatalogCollator.fromConfig(config, {
discovery,
tokenManager,
}),
});
// collator gathers entities from techdocs.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultTechDocsCollator.fromConfig(config, { discovery, logger }),
collator: DefaultTechDocsCollator.fromConfig(config, {
discovery,
logger,
tokenManager,
}),
});
// The scheduler controls when documents are gathered from collators and sent
@@ -4,6 +4,7 @@ import {
PluginCacheManager,
PluginDatabaseManager,
PluginEndpointDiscovery,
TokenManager,
UrlReader,
} from '@backstage/backend-common';
@@ -14,4 +15,5 @@ export type PluginEnvironment = {
config: Config;
reader: UrlReader;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
};
+6
View File
@@ -31,6 +31,7 @@ import { ResourceEntityV1alpha1 } from '@backstage/catalog-model';
import { Router } from 'express';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ScmIntegrations } from '@backstage/integration';
import { TokenManager } from '@backstage/backend-common';
import { UrlReader } from '@backstage/backend-common';
import { Validators } from '@backstage/catalog-model';
@@ -756,8 +757,10 @@ export class DefaultCatalogCollator implements DocumentCollator {
locationTemplate,
filter,
catalogClient,
tokenManager,
}: {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
locationTemplate?: string;
filter?: CatalogEntitiesRequest['filter'];
catalogClient?: CatalogApi;
@@ -780,12 +783,15 @@ export class DefaultCatalogCollator implements DocumentCollator {
_config: Config,
options: {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
filter?: CatalogEntitiesRequest['filter'];
},
): DefaultCatalogCollator;
// (undocumented)
protected locationTemplate: string;
// (undocumented)
protected tokenManager: TokenManager;
// (undocumented)
readonly type: string;
}
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { DefaultCatalogCollator } from './DefaultCatalogCollator';
import { setupServer } from 'msw/node';
@@ -55,6 +58,7 @@ const expectedEntities: Entity[] = [
describe('DefaultCatalogCollator', () => {
let mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery>;
let mockTokenManager: jest.Mocked<TokenManager>;
let collator: DefaultCatalogCollator;
beforeAll(() => {
@@ -62,7 +66,14 @@ describe('DefaultCatalogCollator', () => {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007'),
getExternalBaseUrl: jest.fn(),
};
collator = new DefaultCatalogCollator({ discovery: mockDiscoveryApi });
mockTokenManager = {
getToken: jest.fn().mockResolvedValue({ token: '' }),
authenticate: jest.fn(),
};
collator = new DefaultCatalogCollator({
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
});
server.listen();
});
beforeEach(() => {
@@ -118,6 +129,7 @@ describe('DefaultCatalogCollator', () => {
// Provide an alternate location template.
collator = new DefaultCatalogCollator({
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
locationTemplate: '/software/:name',
});
@@ -131,6 +143,7 @@ describe('DefaultCatalogCollator', () => {
// Provide an alternate location template.
collator = DefaultCatalogCollator.fromConfig(new ConfigReader({}), {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
filter: {
kind: ['Foo', 'Bar'],
},
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { Entity, UserEntity } from '@backstage/catalog-model';
import { IndexableDocument, DocumentCollator } from '@backstage/search-common';
import { Config } from '@backstage/config';
@@ -38,11 +41,13 @@ export class DefaultCatalogCollator implements DocumentCollator {
protected filter?: CatalogEntitiesRequest['filter'];
protected readonly catalogClient: CatalogApi;
public readonly type: string = 'software-catalog';
protected tokenManager: TokenManager;
static fromConfig(
_config: Config,
options: {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
filter?: CatalogEntitiesRequest['filter'];
},
) {
@@ -56,8 +61,10 @@ export class DefaultCatalogCollator implements DocumentCollator {
locationTemplate,
filter,
catalogClient,
tokenManager,
}: {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
locationTemplate?: string;
filter?: CatalogEntitiesRequest['filter'];
catalogClient?: CatalogApi;
@@ -68,6 +75,7 @@ export class DefaultCatalogCollator implements DocumentCollator {
this.filter = filter;
this.catalogClient =
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.tokenManager = tokenManager;
}
protected applyArgsToFormat(
@@ -100,9 +108,13 @@ export class DefaultCatalogCollator implements DocumentCollator {
}
async execute() {
const response = await this.catalogClient.getEntities({
filter: this.filter,
});
const { token } = await this.tokenManager.getToken();
const response = await this.catalogClient.getEntities(
{
filter: this.filter,
},
{ token },
);
return response.items.map((entity: Entity): CatalogEntityDocument => {
return {
title: entity.metadata.title ?? entity.metadata.name,
+3
View File
@@ -14,6 +14,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PreparerBuilder } from '@backstage/techdocs-common';
import { PublisherBase } from '@backstage/techdocs-common';
import { TechDocsDocument } from '@backstage/techdocs-common';
import { TokenManager } from '@backstage/backend-common';
// Warning: (ae-forgotten-export) The symbol "RouterOptions" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -31,6 +32,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
locationTemplate,
logger,
catalogClient,
tokenManager,
parallelismLimit,
legacyPathCasing,
}: TechDocsCollatorOptions);
@@ -60,6 +62,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
export type TechDocsCollatorOptions = {
discovery: PluginEndpointDiscovery;
logger: Logger_2;
tokenManager: TokenManager;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
@@ -17,6 +17,7 @@
import {
PluginEndpointDiscovery,
getVoidLogger,
TokenManager,
} from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { DefaultTechDocsCollator } from './DefaultTechDocsCollator';
@@ -87,6 +88,7 @@ const expectedEntities: Entity[] = [
describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
let mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery>;
let mockTokenManager: jest.Mocked<TokenManager>;
let collator: DefaultTechDocsCollator;
const worker = setupServer();
@@ -96,6 +98,10 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
getExternalBaseUrl: jest.fn(),
};
mockTokenManager = {
getToken: jest.fn().mockResolvedValue({ token: '' }),
authenticate: jest.fn(),
};
const mockConfig = new ConfigReader({
techdocs: {
legacyUseCaseSensitiveTripletPaths: true,
@@ -103,6 +109,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
});
collator = DefaultTechDocsCollator.fromConfig(mockConfig, {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
logger,
legacyPathCasing: true,
});
@@ -147,6 +154,7 @@ describe('DefaultTechDocsCollator with legacyPathCasing configuration', () => {
describe('DefaultTechDocsCollator', () => {
let mockDiscoveryApi: jest.Mocked<PluginEndpointDiscovery>;
let mockTokenManager: jest.Mocked<TokenManager>;
let collator: DefaultTechDocsCollator;
const worker = setupServer();
@@ -156,8 +164,13 @@ describe('DefaultTechDocsCollator', () => {
getBaseUrl: jest.fn().mockResolvedValue('http://test-backend'),
getExternalBaseUrl: jest.fn(),
};
mockTokenManager = {
getToken: jest.fn().mockResolvedValue({ token: '' }),
authenticate: jest.fn(),
};
collator = DefaultTechDocsCollator.fromConfig(new ConfigReader({}), {
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
logger,
});
@@ -195,6 +208,7 @@ describe('DefaultTechDocsCollator', () => {
// Provide an alternate location template.
collator = new DefaultTechDocsCollator({
discovery: mockDiscoveryApi,
tokenManager: mockTokenManager,
locationTemplate: '/software/:name',
logger,
});
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { DocumentCollator } from '@backstage/search-common';
import fetch from 'node-fetch';
@@ -34,6 +37,7 @@ interface MkSearchIndexDoc {
export type TechDocsCollatorOptions = {
discovery: PluginEndpointDiscovery;
logger: Logger;
tokenManager: TokenManager;
locationTemplate?: string;
catalogClient?: CatalogApi;
parallelismLimit?: number;
@@ -51,6 +55,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
protected locationTemplate: string;
private readonly logger: Logger;
private readonly catalogClient: CatalogApi;
private readonly tokenManager: TokenManager;
private readonly parallelismLimit: number;
private readonly legacyPathCasing: boolean;
public readonly type: string = 'techdocs';
@@ -63,6 +68,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
locationTemplate,
logger,
catalogClient,
tokenManager,
parallelismLimit = 10,
legacyPathCasing = false,
}: TechDocsCollatorOptions) {
@@ -74,6 +80,7 @@ export class DefaultTechDocsCollator implements DocumentCollator {
catalogClient || new CatalogClient({ discoveryApi: discovery });
this.parallelismLimit = parallelismLimit;
this.legacyPathCasing = legacyPathCasing;
this.tokenManager = tokenManager;
}
static fromConfig(config: Config, options: TechDocsCollatorOptions) {
@@ -87,19 +94,23 @@ export class DefaultTechDocsCollator implements DocumentCollator {
async execute() {
const limit = pLimit(this.parallelismLimit);
const techDocsBaseUrl = await this.discovery.getBaseUrl('techdocs');
const entities = await this.catalogClient.getEntities({
fields: [
'kind',
'namespace',
'metadata.annotations',
'metadata.name',
'metadata.title',
'metadata.namespace',
'spec.type',
'spec.lifecycle',
'relations',
],
});
const { token } = await this.tokenManager.getToken();
const entities = await this.catalogClient.getEntities(
{
fields: [
'kind',
'namespace',
'metadata.annotations',
'metadata.name',
'metadata.title',
'metadata.namespace',
'spec.type',
'spec.lifecycle',
'relations',
],
},
{ token },
);
const docPromises = entities.items
.filter(it => it.metadata?.annotations?.['backstage.io/techdocs-ref'])
.map((entity: Entity) =>