diff --git a/.changeset/bright-ears-visit.md b/.changeset/bright-ears-visit.md new file mode 100644 index 0000000000..350cc5b094 --- /dev/null +++ b/.changeset/bright-ears-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/app-defaults': patch +--- + +Added an instance of PermissionApi to the apis included by default in createApp. diff --git a/.changeset/famous-icons-pump.md b/.changeset/famous-icons-pump.md new file mode 100644 index 0000000000..c2a5f07292 --- /dev/null +++ b/.changeset/famous-icons-pump.md @@ -0,0 +1,17 @@ +--- +'@backstage/plugin-permission-react': minor +--- + +Breaking Changes: + +- Remove "api" suffixes from constructor parameters in IdentityPermissionApi.create + +```diff + const { config, discovery, identity } = options; +- const permissionApi = IdentityPermissionApi.create({ +- configApi: config, +- discoveryApi: discovery, +- identityApi: identity +- }); ++ const permissionApi = IdentityPermissionApi.create({ config, discovery, identity }); +``` diff --git a/.changeset/lazy-files-check.md b/.changeset/lazy-files-check.md new file mode 100644 index 0000000000..54c53be434 --- /dev/null +++ b/.changeset/lazy-files-check.md @@ -0,0 +1,14 @@ +--- +'@backstage/backend-common': minor +--- + +Auto-generate secrets for backend-to-backend auth in local development environments. + +When NODE_ENV is 'development', the ServerTokenManager will now generate a secret for backend-to-backend auth to make it simpler to work locally on Backstage instances that use backend-to-backend auth. For production deployments, a secret must still be manually configured as described in [the backend-to-backend auth tutorial](https://backstage.io/docs/tutorials/backend-to-backend-auth). + +After the change, the static `fromConfig` method on the `ServerTokenManager` requires a logger. + +```diff +- const tokenManager = ServerTokenManager.fromConfig(config); ++ const tokenManager = ServerTokenManager.fromConfig(config, { logger: root }); +``` diff --git a/.changeset/real-geckos-work.md b/.changeset/real-geckos-work.md new file mode 100644 index 0000000000..549445e079 --- /dev/null +++ b/.changeset/real-geckos-work.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-permission-common': minor +--- + +- Add `PermissionAuthorizer` interface matching `PermissionClient` to allow alternative implementations like the `ServerPermissionClient` in @backstage/plugin-permission-node. + +Breaking Changes: + +- Remove "api" suffixes from constructor parameters in PermissionClient + +```diff + const { config, discovery } = options; +- const permissionClient = new PermissionClient({ discoveryApi: discovery, configApi: config }); ++ const permissionClient = new PermissionClient({ discovery, config }); +``` diff --git a/.changeset/wet-bikes-pull.md b/.changeset/wet-bikes-pull.md new file mode 100644 index 0000000000..1c1c98b253 --- /dev/null +++ b/.changeset/wet-bikes-pull.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-permission-node': patch +--- + +Add `ServerPermissionClient`, which implements `PermissionAuthorizer` from @backstage/plugin-permission-common. This implementation skips authorization entirely when the supplied token is a valid backend-to-backend token, thereby allowing backend-to-backend systems to communicate without authorization. + +The `ServerPermissionClient` should always be used over the standard `PermissionClient` in plugin backends. diff --git a/docs/tutorials/backend-to-backend-auth.md b/docs/tutorials/backend-to-backend-auth.md index 3cb72a4827..cd73a95c89 100644 --- a/docs/tutorials/backend-to-backend-auth.md +++ b/docs/tutorials/backend-to-backend-auth.md @@ -22,6 +22,10 @@ resulting value. node -p 'require("crypto").randomBytes(24).toString("base64")' ``` +**NOTE**: For ease of development, we auto-generate a key for you if you haven't +configured a secret in dev mode. You _must set your own secret_ in order for +backend-to-backend authentication to work in production. + 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 @@ -43,7 +47,7 @@ function makeCreateEnv(config: Config) { const cacheManager = CacheManager.fromConfig(config); const databaseManager = DatabaseManager.fromConfig(config); - const tokenManager = ServerTokenManager.noop(); -+ const tokenManager = ServerTokenManager.fromConfig(config); ++ const tokenManager = ServerTokenManager.fromConfig(config, { logger: root }); ``` With this `tokenManager`, you can then generate a server token for requests: diff --git a/microsite/sidebars.json b/microsite/sidebars.json index d41aab4f7a..4750e1d49d 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -271,7 +271,8 @@ "tutorials/migrating-away-from-core", "tutorials/configuring-plugin-databases", "tutorials/switching-sqlite-postgres", - "tutorials/using-backstage-proxy-within-plugin" + "tutorials/using-backstage-proxy-within-plugin", + "tutorials/backend-to-backend-auth" ], "Architecture Decision Records (ADRs)": [ "architecture-decisions/adrs-overview", diff --git a/packages/app-defaults/package.json b/packages/app-defaults/package.json index fc1bca2104..80ff8dc29b 100644 --- a/packages/app-defaults/package.json +++ b/packages/app-defaults/package.json @@ -32,6 +32,7 @@ "@backstage/core-components": "^0.8.0", "@backstage/core-app-api": "^0.2.0", "@backstage/core-plugin-api": "^0.3.0", + "@backstage/plugin-permission-react": "^0.1.1", "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 29aa62bdab..f26c298fb1 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -62,6 +62,10 @@ import { bitbucketAuthApiRef, atlassianAuthApiRef, } from '@backstage/core-plugin-api'; +import { + permissionApiRef, + IdentityPermissionApi, +} from '@backstage/plugin-permission-react'; export const apis = [ createApiFactory({ @@ -296,4 +300,14 @@ export const apis = [ }); }, }), + createApiFactory({ + api: permissionApiRef, + deps: { + discovery: discoveryApiRef, + identity: identityApiRef, + config: configApiRef, + }, + factory: ({ config, discovery, identity }) => + IdentityPermissionApi.create({ config, discovery, identity }), + }), ]; diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index cbdfba3c38..061931832d 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -493,7 +493,12 @@ export class ServerTokenManager implements TokenManager { // (undocumented) authenticate(token: string): Promise; // (undocumented) - static fromConfig(config: Config): ServerTokenManager; + static fromConfig( + config: Config, + options: { + logger: Logger_2; + }, + ): ServerTokenManager; // (undocumented) getToken(): Promise<{ token: string; diff --git a/packages/backend-common/src/tokens/ServerTokenManager.test.ts b/packages/backend-common/src/tokens/ServerTokenManager.test.ts index fe724a0a4b..1799291544 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.test.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.test.ts @@ -13,23 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { getVoidLogger } from '../logging/voidLogger'; import { ConfigReader } from '@backstage/config'; -import { TokenManager } from './types'; import { ServerTokenManager } from './ServerTokenManager'; +import { Logger } from 'winston'; +import { JWK } from 'jose'; +import { TokenManager } from './types'; const emptyConfig = new ConfigReader({}); const configWithSecret = new ConfigReader({ backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, }); +const env = process.env; +let logger: Logger; describe('ServerTokenManager', () => { - it('should throw if secret in config does not exist', () => { - expect(() => ServerTokenManager.fromConfig(emptyConfig)).toThrowError(); + beforeEach(() => { + process.env = { ...env }; + logger = getVoidLogger(); + }); + + afterEach(() => { + process.env = env; }); describe('getToken', () => { - it('should return a token if secret in config exists', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + it('should return a token', async () => { + const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); expect((await tokenManager.getToken()).token).toBeDefined(); }); @@ -41,21 +53,29 @@ describe('ServerTokenManager', () => { describe('authenticate', () => { it('should not throw if token is valid', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); 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); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); 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 tokenManager1 = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); + const tokenManager2 = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); const { token } = await tokenManager1.getToken(); @@ -67,11 +87,13 @@ describe('ServerTokenManager', () => { new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), + { logger }, ); const tokenManager2 = ServerTokenManager.fromConfig( new ConfigReader({ backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, }), + { logger }, ); const tokenManager3 = ServerTokenManager.fromConfig( new ConfigReader({ @@ -79,6 +101,7 @@ describe('ServerTokenManager', () => { auth: { keys: [{ secret: 'a1b2c3' }, { secret: 'd4e5f6' }] }, }, }), + { logger }, ); const { token: token1 } = await tokenManager1.getToken(); @@ -93,11 +116,13 @@ describe('ServerTokenManager', () => { new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), + { logger }, ); const tokenManager2 = ServerTokenManager.fromConfig( new ConfigReader({ backend: { auth: { keys: [{ secret: 'd4e5f6' }] } }, }), + { logger }, ); const { token } = await tokenManager1.getToken(); @@ -113,6 +138,7 @@ describe('ServerTokenManager', () => { new ConfigReader({ backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, }), + { logger }, ); const { token } = await noopTokenManager.getToken(); @@ -121,37 +147,94 @@ describe('ServerTokenManager', () => { /invalid server token/i, ); }); + + it('should throw for server tokens created by a different generated secret', async () => { + (process.env as any).NODE_ENV = 'development'; + const tokenManager1 = ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [{ secret: 'a1b2c3' }] } }, + }), + { logger }, + ); + const tokenManager2 = ServerTokenManager.fromConfig(emptyConfig, { + logger, + }); + + const { token } = await tokenManager2.getToken(); + + await expect(tokenManager1.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(); + describe('fromConfig', () => { + describe('NODE_ENV === production', () => { + it('should throw if backend auth configuration is missing', () => { + expect(() => + ServerTokenManager.fromConfig(emptyConfig, { logger }), + ).toThrow(); + }); + + it('should throw if no keys are included in the configuration', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { auth: { keys: [] } }, + }), + { logger }, + ), + ).toThrow(); + }); + + it('should throw if any key is missing a secret property', () => { + expect(() => + ServerTokenManager.fromConfig( + new ConfigReader({ + backend: { + auth: { + keys: [{ secret: '123' }, {}, { secret: '789' }], + }, + }, + }), + { logger }, + ), + ).toThrow(); + }); }); - it('should throw if no keys are included in the configuration', () => { - expect(() => + describe('NODE_ENV === development', () => { + const generateSyncSpy = jest.spyOn(JWK, 'generateSync'); + + beforeEach(() => { + (process.env as any).NODE_ENV = 'development'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should generate a key if no config is provided', () => { + ServerTokenManager.fromConfig(emptyConfig, { logger }); + + expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192); + }); + + it('should generate a key if no keys are provided in the configuration', () => { ServerTokenManager.fromConfig( new ConfigReader({ backend: { auth: { keys: [] } }, }), - ), - ).toThrow(); - }); + { logger }, + ); - it('should throw if any key is missing a secret property', () => { - expect(() => - ServerTokenManager.fromConfig( - new ConfigReader({ - backend: { - auth: { - keys: [{ secret: '123' }, {}, { secret: '789' }], - }, - }, - }), - ), - ).toThrow(); + expect(generateSyncSpy).toHaveBeenCalledWith('oct', 192); + }); + + it('should use provided secrets if config is provided', () => { + ServerTokenManager.fromConfig(configWithSecret, { logger }); + expect(generateSyncSpy).not.toHaveBeenCalled(); + }); }); }); @@ -180,7 +263,9 @@ describe('ServerTokenManager', () => { }); it('should accept signed tokens', async () => { - const tokenManager = ServerTokenManager.fromConfig(configWithSecret); + const tokenManager = ServerTokenManager.fromConfig(configWithSecret, { + logger, + }); 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 index 35a07509b6..d6693edba1 100644 --- a/packages/backend-common/src/tokens/ServerTokenManager.ts +++ b/packages/backend-common/src/tokens/ServerTokenManager.ts @@ -18,8 +18,11 @@ import { JWKS, JWK, JWT } from 'jose'; import { Config } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; import { TokenManager } from './types'; +import { Logger } from 'winston'; class NoopTokenManager implements TokenManager { + public readonly isInsecureServerTokenManager: boolean = true; + async getToken() { return { token: '' }; } @@ -41,16 +44,32 @@ export class ServerTokenManager implements TokenManager { return new NoopTokenManager(); } - static fromConfig(config: Config) { - return new ServerTokenManager( - config - .getConfigArray('backend.auth.keys') - .map(key => key.getString('secret')), + static fromConfig(config: Config, options: { logger: Logger }) { + const { logger } = options; + + const keys = config.getOptionalConfigArray('backend.auth.keys'); + if (keys?.length) { + return new ServerTokenManager(keys.map(key => key.getString('secret'))); + } + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'You must configure at least one key in backend.auth.keys for production.', + ); + } + + // For development, if a secret has not been configured, we auto generate a secret instead of throwing. + const generatedDevOnlyKey = JWK.generateSync('oct', 24 * 8); + if (generatedDevOnlyKey.k === undefined) { + throw new Error('Internal error, JWK key generation returned no data'); + } + logger.warn( + 'Generated a secret for backend-to-backend authentication: DEVELOPMENT USE ONLY.', ); + return new ServerTokenManager([generatedDevOnlyKey.k]); } - private constructor(secrets?: string[]) { - if (!secrets?.length) { + private constructor(secrets: string[]) { + if (!secrets.length) { throw new Error( 'No secrets provided when constructing ServerTokenManager', ); diff --git a/packages/backend/package.json b/packages/backend/package.json index 80ae3b0998..491ecedbb2 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -39,6 +39,9 @@ "@backstage/plugin-jenkins-backend": "^0.1.9", "@backstage/plugin-kubernetes-backend": "^0.4.0", "@backstage/plugin-kafka-backend": "^0.2.12", + "@backstage/plugin-permission-backend": "^0.2.0", + "@backstage/plugin-permission-common": "^0.2.0", + "@backstage/plugin-permission-node": "^0.2.0", "@backstage/plugin-proxy-backend": "^0.2.14", "@backstage/plugin-rollbar-backend": "^0.1.16", "@backstage/plugin-scaffolder-backend": "^0.15.17", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7a79c278ff..94c2d88012 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -55,13 +55,19 @@ import graphql from './plugins/graphql'; import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; +import permission from './plugins/permission'; import { PluginEnvironment } from './types'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; function makeCreateEnv(config: Config) { const root = getRootLogger(); const reader = UrlReaders.default({ logger: root, config }); const discovery = SingleHostDiscovery.fromConfig(config); - const tokenManager = ServerTokenManager.noop(); + const tokenManager = ServerTokenManager.fromConfig(config, { logger: root }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); root.info(`Created UrlReader ${reader}`); @@ -72,7 +78,16 @@ 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, tokenManager }; + return { + logger, + cache, + database, + config, + reader, + discovery, + tokenManager, + permissions, + }; }; } @@ -114,6 +129,7 @@ async function main() { const techInsightsEnv = useHotMemoize(module, () => createEnv('tech-insights'), ); + const permissionEnv = useHotMemoize(module, () => createEnv('permission')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -132,6 +148,7 @@ async function main() { apiRouter.use('/graphql', await graphql(graphqlEnv)); apiRouter.use('/badges', await badges(badgesEnv)); apiRouter.use('/jenkins', await jenkins(jenkinsEnv)); + apiRouter.use('/permission', await permission(permissionEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts new file mode 100644 index 0000000000..71a9b90311 --- /dev/null +++ b/packages/backend/src/plugins/permission.ts @@ -0,0 +1,48 @@ +/* + * 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 { IdentityClient } from '@backstage/plugin-auth-backend'; +import { createRouter } from '@backstage/plugin-permission-backend'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionPolicy, + PolicyDecision, +} from '@backstage/plugin-permission-node'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +class AllowAllPermissionPolicy implements PermissionPolicy { + async handle(): Promise { + return { + result: AuthorizeResult.ALLOW, + }; + } +} + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const { logger, discovery } = env; + return await createRouter({ + logger, + discovery, + policy: new AllowAllPermissionPolicy(), + identity: new IdentityClient({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }), + }); +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 4be9c036b3..e3826d69ae 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -23,6 +23,7 @@ import { TokenManager, UrlReader, } from '@backstage/backend-common'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export type PluginEnvironment = { logger: Logger; @@ -32,4 +33,5 @@ export type PluginEnvironment = { reader: UrlReader; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; + permissions: ServerPermissionClient; }; diff --git a/plugins/permission-common/api-report.md b/plugins/permission-common/api-report.md index 043b27554c..70795d14ac 100644 --- a/plugins/permission-common/api-report.md +++ b/plugins/permission-common/api-report.md @@ -68,8 +68,17 @@ export type PermissionAttributes = { }; // @public -export class PermissionClient { - constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }); +export interface PermissionAuthorizer { + // (undocumented) + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; +} + +// @public +export class PermissionClient implements PermissionAuthorizer { + constructor(options: { discovery: DiscoveryApi; config: Config }); authorize( requests: AuthorizeRequest[], options?: AuthorizeRequestOptions, diff --git a/plugins/permission-common/src/PermissionClient.test.ts b/plugins/permission-common/src/PermissionClient.test.ts index b2e66986a1..a432735a5e 100644 --- a/plugins/permission-common/src/PermissionClient.test.ts +++ b/plugins/permission-common/src/PermissionClient.test.ts @@ -26,14 +26,14 @@ const server = setupServer(); const token = 'fake-token'; const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; -const discoveryApi: DiscoveryApi = { +const discovery: DiscoveryApi = { async getBaseUrl() { return mockBaseUrl; }, }; const client: PermissionClient = new PermissionClient({ - discoveryApi, - configApi: new ConfigReader({ permission: { enabled: true } }), + discovery, + config: new ConfigReader({ permission: { enabled: true } }), }); const mockPermission: Permission = { @@ -158,8 +158,8 @@ describe('PermissionClient', () => { }, ); const disabled = new PermissionClient({ - discoveryApi, - configApi: new ConfigReader({ permission: { enabled: false } }), + discovery, + config: new ConfigReader({ permission: { enabled: false } }), }); const response = await disabled.authorize([mockAuthorizeRequest]); expect(response[0]).toEqual( @@ -180,8 +180,8 @@ describe('PermissionClient', () => { }, ); const disabled = new PermissionClient({ - discoveryApi, - configApi: new ConfigReader({}), + discovery, + config: new ConfigReader({}), }); const response = await disabled.authorize([mockAuthorizeRequest]); expect(response[0]).toEqual( diff --git a/plugins/permission-common/src/PermissionClient.ts b/plugins/permission-common/src/PermissionClient.ts index f98d40a3c4..62e5ec8172 100644 --- a/plugins/permission-common/src/PermissionClient.ts +++ b/plugins/permission-common/src/PermissionClient.ts @@ -28,6 +28,10 @@ import { PermissionCondition, } from './types/api'; import { DiscoveryApi } from './types/discovery'; +import { + PermissionAuthorizer, + AuthorizeRequestOptions, +} from './types/permission'; const permissionCriteriaSchema: z.ZodSchema< PermissionCriteria @@ -59,26 +63,18 @@ const responseSchema = z.array( ), ); -/** - * Options for authorization requests; currently only an optional auth token. - * @public - */ -export type AuthorizeRequestOptions = { - token?: string; -}; - /** * An isomorphic client for requesting authorization for Backstage permissions. * @public */ -export class PermissionClient { +export class PermissionClient implements PermissionAuthorizer { private readonly enabled: boolean; - private readonly discoveryApi: DiscoveryApi; + private readonly discovery: DiscoveryApi; - constructor(options: { discoveryApi: DiscoveryApi; configApi: Config }) { - this.discoveryApi = options.discoveryApi; + constructor(options: { discovery: DiscoveryApi; config: Config }) { + this.discovery = options.discovery; this.enabled = - options.configApi.getOptionalBoolean('permission.enabled') ?? false; + options.config.getOptionalBoolean('permission.enabled') ?? false; } /** @@ -117,7 +113,7 @@ export class PermissionClient { }), ); - const permissionApi = await this.discoveryApi.getBaseUrl('permission'); + const permissionApi = await this.discovery.getBaseUrl('permission'); const response = await fetch(`${permissionApi}/authorize`, { method: 'POST', body: JSON.stringify(identifiedRequests), diff --git a/plugins/permission-common/src/types/index.ts b/plugins/permission-common/src/types/index.ts index e21fa19e8b..583a3577bd 100644 --- a/plugins/permission-common/src/types/index.ts +++ b/plugins/permission-common/src/types/index.ts @@ -23,4 +23,9 @@ export type { PermissionCriteria, } from './api'; export type { DiscoveryApi } from './discovery'; -export type { PermissionAttributes, Permission } from './permission'; +export type { + PermissionAttributes, + Permission, + PermissionAuthorizer, + AuthorizeRequestOptions, +} from './permission'; diff --git a/plugins/permission-common/src/types/permission.ts b/plugins/permission-common/src/types/permission.ts index 181b36065f..246f1bec72 100644 --- a/plugins/permission-common/src/types/permission.ts +++ b/plugins/permission-common/src/types/permission.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { AuthorizeRequest, AuthorizeResponse } from './api'; + /** * The attributes related to a given permission; these should be generic and widely applicable to * all permissions in the system. @@ -39,3 +41,22 @@ export type Permission = { attributes: PermissionAttributes; resourceType?: string; }; + +/** + * A client interacting with the permission backend can implement this authorizer interface. + * @public + */ +export interface PermissionAuthorizer { + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; +} + +/** + * Options for authorization requests. + * @public + */ +export type AuthorizeRequestOptions = { + token?: string; +}; diff --git a/plugins/permission-node/api-report.md b/plugins/permission-node/api-report.md index 3df1386d4d..25a8be06c8 100644 --- a/plugins/permission-node/api-report.md +++ b/plugins/permission-node/api-report.md @@ -4,11 +4,17 @@ ```ts import { AuthorizeRequest } from '@backstage/plugin-permission-common'; +import { AuthorizeRequestOptions } from '@backstage/plugin-permission-common'; +import { AuthorizeResponse } from '@backstage/plugin-permission-common'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend'; +import { Config } from '@backstage/config'; +import { PermissionAuthorizer } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Router } from 'express'; +import { TokenManager } from '@backstage/backend-common'; // @public export type ApplyConditionsRequest = { @@ -119,4 +125,21 @@ export type PolicyDecision = result: AuthorizeResult.ALLOW | AuthorizeResult.DENY; } | ConditionalPolicyDecision; + +// @public +export class ServerPermissionClient implements PermissionAuthorizer { + // (undocumented) + authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise; + // (undocumented) + static fromConfig( + config: Config, + options: { + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + }, + ): ServerPermissionClient; +} ``` diff --git a/plugins/permission-node/package.json b/plugins/permission-node/package.json index 5e0a300873..92755e2012 100644 --- a/plugins/permission-node/package.json +++ b/plugins/permission-node/package.json @@ -29,6 +29,8 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/backend-common": "^0.9.11", + "@backstage/config": "^0.1.11", "@backstage/plugin-auth-backend": "^0.5.0", "@backstage/plugin-permission-common": "^0.2.0", "@types/express": "^4.17.6", @@ -38,6 +40,7 @@ "devDependencies": { "@backstage/cli": "^0.10.1", "@types/supertest": "^2.0.8", + "msw": "^0.35.0", "supertest": "^6.1.3" }, "files": [ diff --git a/plugins/permission-node/src/ServerPermissionClient.test.ts b/plugins/permission-node/src/ServerPermissionClient.test.ts new file mode 100644 index 0000000000..2c29ef8939 --- /dev/null +++ b/plugins/permission-node/src/ServerPermissionClient.test.ts @@ -0,0 +1,119 @@ +/* + * 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 { ServerPermissionClient } from './ServerPermissionClient'; +import { + Permission, + Identified, + AuthorizeRequest, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; +import { ConfigReader } from '@backstage/config'; +import { + getVoidLogger, + PluginEndpointDiscovery, + ServerTokenManager, +} from '@backstage/backend-common'; +import { setupServer } from 'msw/node'; +import { RestContext, rest } from 'msw'; + +const server = setupServer(); +const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => { + const responses = req.body.map((r: Identified) => ({ + id: r.id, + result: AuthorizeResult.ALLOW, + })); + + return res(json(responses)); +}); +const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base'; +const discovery: PluginEndpointDiscovery = { + async getBaseUrl() { + return mockBaseUrl; + }, + async getExternalBaseUrl() { + return mockBaseUrl; + }, +}; +const testPermission: Permission = { + name: 'test.permission', + attributes: {}, + resourceType: 'test-resource', +}; +const config = new ConfigReader({ + permission: { enabled: true }, + backend: { auth: { keys: [{ secret: 'a-secret-key' }] } }, +}); +const logger = getVoidLogger(); + +describe('ServerPermissionClient', () => { + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); + afterAll(() => server.close()); + beforeEach(() => { + server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler)); + }); + afterEach(() => server.resetHandlers()); + + it('should bypass authorization if permissions are disabled', async () => { + const client = ServerPermissionClient.fromConfig(new ConfigReader({}), { + discovery, + tokenManager: ServerTokenManager.noop(), + }); + + await client.authorize([{ permission: testPermission }]); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should bypass authorization if permissions are enabled and request has valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testPermission }], { + token: (await tokenManager.getToken()).token, + }); + + expect(mockAuthorizeHandler).not.toHaveBeenCalled(); + }); + + it('should authorize normally if permissions are enabled and request does not have valid server token', async () => { + const tokenManager = ServerTokenManager.fromConfig(config, { logger }); + const client = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + await client.authorize([{ permission: testPermission }], { + token: 'a-user-token', + }); + + expect(mockAuthorizeHandler).toHaveBeenCalled(); + }); + + it('should error if permissions are enabled but a no-op token manager is configured', async () => { + expect(() => + ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager: ServerTokenManager.noop(), + }), + ).toThrowError( + 'You must configure at least one key in backend.auth.keys if permissions are enabled.', + ); + }); +}); diff --git a/plugins/permission-node/src/ServerPermissionClient.ts b/plugins/permission-node/src/ServerPermissionClient.ts new file mode 100644 index 0000000000..c85cc7048b --- /dev/null +++ b/plugins/permission-node/src/ServerPermissionClient.ts @@ -0,0 +1,108 @@ +/* + * 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 { + TokenManager, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { Config } from '@backstage/config'; +import { + AuthorizeRequest, + AuthorizeRequestOptions, + AuthorizeResponse, + AuthorizeResult, + PermissionClient, + PermissionAuthorizer, +} from '@backstage/plugin-permission-common'; + +/** + * A thin wrapper around + * {@link @backstage/plugin-permission-common#PermissionClient} that allows all + * backend-to-backend requests. + * @public + */ +export class ServerPermissionClient implements PermissionAuthorizer { + private readonly permissionClient: PermissionClient; + private readonly tokenManager: TokenManager; + private readonly permissionEnabled: boolean; + + static fromConfig( + config: Config, + options: { + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + }, + ) { + const { discovery, tokenManager } = options; + const permissionClient = new PermissionClient({ discovery, config }); + const permissionEnabled = + config.getOptionalBoolean('permission.enabled') ?? false; + + if ( + permissionEnabled && + (tokenManager as any).isInsecureServerTokenManager + ) { + throw new Error( + 'You must configure at least one key in backend.auth.keys if permissions are enabled.', + ); + } + + return new ServerPermissionClient({ + permissionClient, + tokenManager, + permissionEnabled, + }); + } + + private constructor(options: { + permissionClient: PermissionClient; + tokenManager: TokenManager; + permissionEnabled: boolean; + }) { + this.permissionClient = options.permissionClient; + this.tokenManager = options.tokenManager; + this.permissionEnabled = options.permissionEnabled; + } + + async authorize( + requests: AuthorizeRequest[], + options?: AuthorizeRequestOptions, + ): Promise { + // Check if permissions are enabled before validating the server token. That + // way when permissions are disabled, the noop token manager can be used + // without fouling up the logic inside the ServerPermissionClient, because + // the code path won't be reached. + if ( + !this.permissionEnabled || + (await this.isValidServerToken(options?.token)) + ) { + return requests.map(_ => ({ result: AuthorizeResult.ALLOW })); + } + return this.permissionClient.authorize(requests, options); + } + + private async isValidServerToken( + token: string | undefined, + ): Promise { + if (!token) { + return false; + } + return this.tokenManager + .authenticate(token) + .then(() => true) + .catch(() => false); + } +} diff --git a/plugins/permission-node/src/index.ts b/plugins/permission-node/src/index.ts index 39527bac71..32d71ddeb3 100644 --- a/plugins/permission-node/src/index.ts +++ b/plugins/permission-node/src/index.ts @@ -22,3 +22,4 @@ export * from './integration'; export * from './policy'; export * from './types'; +export { ServerPermissionClient } from './ServerPermissionClient'; diff --git a/plugins/permission-react/api-report.md b/plugins/permission-react/api-report.md index c10317495a..799b3ad54f 100644 --- a/plugins/permission-react/api-report.md +++ b/plugins/permission-react/api-report.md @@ -27,9 +27,9 @@ export class IdentityPermissionApi implements PermissionApi { authorize(request: AuthorizeRequest): Promise; // (undocumented) static create(options: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + config: Config; + discovery: DiscoveryApi; + identity: IdentityApi; }): IdentityPermissionApi; } diff --git a/plugins/permission-react/src/apis/IdentityPermissionApi.ts b/plugins/permission-react/src/apis/IdentityPermissionApi.ts index 19de564f84..818eca17fa 100644 --- a/plugins/permission-react/src/apis/IdentityPermissionApi.ts +++ b/plugins/permission-react/src/apis/IdentityPermissionApi.ts @@ -35,13 +35,13 @@ export class IdentityPermissionApi implements PermissionApi { ) {} static create(options: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + config: Config; + discovery: DiscoveryApi; + identity: IdentityApi; }) { - const { configApi, discoveryApi, identityApi } = options; - const permissionClient = new PermissionClient({ discoveryApi, configApi }); - return new IdentityPermissionApi(permissionClient, identityApi); + const { config, discovery, identity } = options; + const permissionClient = new PermissionClient({ discovery, config }); + return new IdentityPermissionApi(permissionClient, identity); } async authorize(request: AuthorizeRequest): Promise {