diff --git a/.changeset/dry-pianos-brush.md b/.changeset/dry-pianos-brush.md new file mode 100644 index 0000000000..c749962a83 --- /dev/null +++ b/.changeset/dry-pianos-brush.md @@ -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 }; + }; +} +``` diff --git a/.changeset/green-toes-search.md b/.changeset/green-toes-search.md new file mode 100644 index 0000000000..2bdba7e31a --- /dev/null +++ b/.changeset/green-toes-search.md @@ -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, + }), + }); + + ... +} +``` diff --git a/.changeset/lemon-moons-stare.md b/.changeset/lemon-moons-stare.md new file mode 100644 index 0000000000..4b7818d403 --- /dev/null +++ b/.changeset/lemon-moons-stare.md @@ -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. diff --git a/.changeset/young-bikes-argue.md b/.changeset/young-bikes-argue.md new file mode 100644 index 0000000000..a9f09cd12b --- /dev/null +++ b/.changeset/young-bikes-argue.md @@ -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, + }), + }); + + ... +} +``` diff --git a/app-config.yaml b/app-config.yaml index 50ddb7ce89..913a72dbab 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -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 diff --git a/docs/features/search/getting-started.md b/docs/features/search/getting-started.md index 27219e529a..7c3717e22e 100644 --- a/docs/features/search/getting-started.md +++ b/docs/features/search/getting-started.md @@ -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, + }), }); ``` diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index 621d46b51e..4ba44b8ef4 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -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, }), }); ``` diff --git a/docs/tutorials/backend-to-backend-auth.md b/docs/tutorials/backend-to-backend-auth.md new file mode 100644 index 0000000000..3cb72a4827 --- /dev/null +++ b/docs/tutorials/backend-to-backend-auth.md @@ -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 +``` diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e3119e78a8..9c66c0573e 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -526,6 +526,20 @@ export type SearchResponseFile = { content(): Promise; }; +// @public +export class ServerTokenManager implements TokenManager { + // (undocumented) + authenticate(token: string): Promise; + // (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; + // (undocumented) + getToken: () => Promise<{ + token: string; + }>; +} + // @public export type UrlReader = { read(url: string): Promise; diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 3690069431..bdc7740c13 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -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. */ diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 95af7c823d..7fb2de0fe0 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index c214961a2e..e430ddc1a4 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -31,4 +31,5 @@ export * from './paths'; export * from './reading'; export * from './scm'; export * from './service'; +export * from './tokens'; export * from './util'; diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts new file mode 100644 index 0000000000..fe724a0a4b --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/backend-common/src/tokens/ServerTokenManager.ts b/packages/backend-common/src/tokens/ServerTokenManager.ts new file mode 100644 index 0000000000..35a07509b6 --- /dev/null +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -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 { + try { + JWT.verify(token, this.verificationKeys); + } catch (e) { + throw new AuthenticationError('Invalid server token'); + } + } +} diff --git a/packages/backend-common/src/tokens/index.ts b/packages/backend-common/src/tokens/index.ts new file mode 100644 index 0000000000..43ff12e597 --- /dev/null +++ b/packages/backend-common/src/tokens/index.ts @@ -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'; diff --git a/packages/backend-common/src/tokens/types.ts b/packages/backend-common/src/tokens/types.ts new file mode 100644 index 0000000000..1fea018db9 --- /dev/null +++ b/packages/backend-common/src/tokens/types.ts @@ -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; +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f978e84da9..4426ed7767 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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 }; }; } diff --git a/packages/backend/src/plugins/search.ts b/packages/backend/src/plugins/search.ts index 4cf9c10021..9a8db0f0f9 100644 --- a/packages/backend/src/plugins/search.ts +++ b/packages/backend/src/plugins/search.ts @@ -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, }), }); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 8290e569ef..4be9c036b3 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -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; }; diff --git a/packages/create-app/templates/default-app/app-config.yaml.hbs b/packages/create-app/templates/default-app/app-config.yaml.hbs index ab4858edd6..be144d91f7 100644 --- a/packages/create-app/templates/default-app/app-config.yaml.hbs +++ b/packages/create-app/templates/default-app/app-config.yaml.hbs @@ -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 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 f2b14b23f9..3f12122a3f 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 @@ -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 }; }; } diff --git a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts index 63e196235c..f23b0c7bcf 100644 --- a/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts +++ b/packages/create-app/templates/default-app/packages/backend/src/plugins/search.ts @@ -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 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 6c78a2a90c..b1e2e0a1df 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 @@ -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; }; diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 7363a803c8..43eb8d61b7 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -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; } diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts index 8f1af6f615..1360ca2647 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.test.ts @@ -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; + let mockTokenManager: jest.Mocked; 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'], }, diff --git a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts index 3e19b38ea6..4c6342c6c6 100644 --- a/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts +++ b/plugins/catalog-backend/src/search/DefaultCatalogCollator.ts @@ -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, diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md index a6094c2bdd..f9be757e6a 100644 --- a/plugins/techdocs-backend/api-report.md +++ b/plugins/techdocs-backend/api-report.md @@ -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; diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts index 21be228636..5f0bed55dd 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.test.ts @@ -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; + let mockTokenManager: jest.Mocked; 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; + let mockTokenManager: jest.Mocked; 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, }); diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index df968bb8d3..283d0264e4 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -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) =>