Merge pull request #8357 from backstage/example-app-permission-integration

Example app permission integration
This commit is contained in:
MT Lewis
2021-12-22 14:32:25 +00:00
committed by GitHub
28 changed files with 619 additions and 77 deletions
+11 -2
View File
@@ -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),
+6 -1
View File
@@ -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;
};
+23
View File
@@ -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;
}
```
+3
View File
@@ -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);
}
}
+1
View File
@@ -22,3 +22,4 @@
export * from './integration';
export * from './policy';
export * from './types';
export { ServerPermissionClient } from './ServerPermissionClient';
+3 -3
View File
@@ -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> {