Merge pull request #8357 from backstage/example-app-permission-integration
Example app permission integration
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/app-defaults': patch
|
||||
---
|
||||
|
||||
Added an instance of PermissionApi to the apis included by default in createApp.
|
||||
@@ -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 });
|
||||
```
|
||||
@@ -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 });
|
||||
```
|
||||
@@ -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 });
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -493,7 +493,12 @@ export class ServerTokenManager implements TokenManager {
|
||||
// (undocumented)
|
||||
authenticate(token: string): Promise<void>;
|
||||
// (undocumented)
|
||||
static fromConfig(config: Config): ServerTokenManager;
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
logger: Logger_2;
|
||||
},
|
||||
): ServerTokenManager;
|
||||
// (undocumented)
|
||||
getToken(): Promise<{
|
||||
token: string;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<PolicyDecision> {
|
||||
return {
|
||||
result: AuthorizeResult.ALLOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function createPlugin(
|
||||
env: PluginEnvironment,
|
||||
): Promise<Router> {
|
||||
const { logger, discovery } = env;
|
||||
return await createRouter({
|
||||
logger,
|
||||
discovery,
|
||||
policy: new AllowAllPermissionPolicy(),
|
||||
identity: new IdentityClient({
|
||||
discovery,
|
||||
issuer: await discovery.getExternalBaseUrl('auth'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<AuthorizeResponse[]>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class PermissionClient implements PermissionAuthorizer {
|
||||
constructor(options: { discovery: DiscoveryApi; config: Config });
|
||||
authorize(
|
||||
requests: AuthorizeRequest[],
|
||||
options?: AuthorizeRequestOptions,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PermissionCondition>
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AuthorizeResponse[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for authorization requests.
|
||||
* @public
|
||||
*/
|
||||
export type AuthorizeRequestOptions = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
@@ -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<AuthorizeResponse[]>;
|
||||
// (undocumented)
|
||||
static fromConfig(
|
||||
config: Config,
|
||||
options: {
|
||||
discovery: PluginEndpointDiscovery;
|
||||
tokenManager: TokenManager;
|
||||
},
|
||||
): ServerPermissionClient;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<AuthorizeRequest>) => ({
|
||||
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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<AuthorizeResponse[]> {
|
||||
// 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<boolean> {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return this.tokenManager
|
||||
.authenticate(token)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,4 @@
|
||||
export * from './integration';
|
||||
export * from './policy';
|
||||
export * from './types';
|
||||
export { ServerPermissionClient } from './ServerPermissionClient';
|
||||
|
||||
@@ -27,9 +27,9 @@ export class IdentityPermissionApi implements PermissionApi {
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>;
|
||||
// (undocumented)
|
||||
static create(options: {
|
||||
configApi: Config;
|
||||
discoveryApi: DiscoveryApi;
|
||||
identityApi: IdentityApi;
|
||||
config: Config;
|
||||
discovery: DiscoveryApi;
|
||||
identity: IdentityApi;
|
||||
}): IdentityPermissionApi;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AuthorizeResponse> {
|
||||
|
||||
Reference in New Issue
Block a user