Merge pull request #8192 from backstage/permission-backend
Add @backstage/plugin-permission-backend
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-backend': minor
|
||||
---
|
||||
|
||||
New package containing the backend for authorization and permissions. For more information, see the [authorization PRFC](https://github.com/backstage/backstage/pull/7761).
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
# @backstage/plugin-permission-backend
|
||||
|
||||
> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS
|
||||
|
||||
Backend for Backstage authorization and permissions. For more information, see
|
||||
the [authorization PRFC](https://github.com/backstage/backstage/pull/7761).
|
||||
@@ -0,0 +1,26 @@
|
||||
## API Report File for "@backstage/plugin-permission-backend"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import express from 'express';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-backend';
|
||||
import { Logger as Logger_2 } from 'winston';
|
||||
import { PermissionPolicy } from '@backstage/plugin-permission-node';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
|
||||
// @public
|
||||
export function createRouter(options: RouterOptions): Promise<express.Router>;
|
||||
|
||||
// @public
|
||||
export interface RouterOptions {
|
||||
// (undocumented)
|
||||
discovery: PluginEndpointDiscovery;
|
||||
// (undocumented)
|
||||
identity: IdentityClient;
|
||||
// (undocumented)
|
||||
logger: Logger_2;
|
||||
// (undocumented)
|
||||
policy: PermissionPolicy;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@backstage/plugin-permission-backend",
|
||||
"version": "0.0.0",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.cjs.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli backend:dev",
|
||||
"build": "backstage-cli backend:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.9",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/plugin-auth-backend": "^0.4.7",
|
||||
"@backstage/plugin-permission-common": "^0.1.0",
|
||||
"@backstage/plugin-permission-node": "^0.0.0",
|
||||
"@types/express": "*",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.9.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2",
|
||||
"msw": "^0.35.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend for Backstage authorization and permissions.
|
||||
* @packageDocumentation
|
||||
*/
|
||||
export * from './service';
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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 { AddressInfo } from 'net';
|
||||
import { Server } from 'http';
|
||||
import express, { Router } from 'express';
|
||||
import { RestContext, rest } from 'msw';
|
||||
import { setupServer, SetupServerApi } from 'msw/node';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
|
||||
describe('PermissionIntegrationClient', () => {
|
||||
describe('applyConditions', () => {
|
||||
let server: SetupServerApi;
|
||||
|
||||
const mockConditions = {
|
||||
not: {
|
||||
allOf: [
|
||||
{ rule: 'RULE_1', params: [] },
|
||||
{ rule: 'RULE_2', params: ['abc'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockApplyConditionsHandler = jest.fn(
|
||||
(_req, res, { json }: RestContext) => {
|
||||
return res(json({ result: AuthorizeResult.ALLOW }));
|
||||
},
|
||||
);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
throw new Error('Not implemented.');
|
||||
},
|
||||
};
|
||||
|
||||
const client: PermissionIntegrationClient = new PermissionIntegrationClient(
|
||||
{
|
||||
discovery,
|
||||
},
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
server = setupServer();
|
||||
server.listen({ onUnhandledRequest: 'error' });
|
||||
server.use(
|
||||
rest.post(
|
||||
`${mockBaseUrl}/permissions/apply-conditions`,
|
||||
mockApplyConditionsHandler,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => server.close());
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should make a POST request to the correct endpoint', async () => {
|
||||
await client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
});
|
||||
|
||||
expect(mockApplyConditionsHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include a request body', async () => {
|
||||
await client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
});
|
||||
|
||||
expect(mockApplyConditionsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the response from the fetch request', async () => {
|
||||
const response = await client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
});
|
||||
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include authorization headers if no token is supplied', async () => {
|
||||
await client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
});
|
||||
|
||||
const request = mockApplyConditionsHandler.mock.calls[0][0];
|
||||
expect(request.headers.has('authorization')).toEqual(false);
|
||||
});
|
||||
|
||||
it('should include correctly-constructed authorization header if token is supplied', async () => {
|
||||
await client.applyConditions(
|
||||
{
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
'Bearer fake-token',
|
||||
);
|
||||
|
||||
const request = mockApplyConditionsHandler.mock.calls[0][0];
|
||||
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
|
||||
});
|
||||
|
||||
it('should forward response errors', async () => {
|
||||
mockApplyConditionsHandler.mockImplementationOnce(
|
||||
(_req, res, { status }: RestContext) => {
|
||||
return res(status(401));
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
}),
|
||||
).rejects.toThrowError(/401/i);
|
||||
});
|
||||
|
||||
it('should reject invalid responses', async () => {
|
||||
mockApplyConditionsHandler.mockImplementationOnce(
|
||||
(_req, res, { json }: RestContext) => {
|
||||
return res(json({ outcome: AuthorizeResult.ALLOW }));
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
}),
|
||||
).rejects.toThrowError(/invalid input/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with @backstage/plugin-permission-node', () => {
|
||||
let server: Server;
|
||||
let client: PermissionIntegrationClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = Router();
|
||||
|
||||
router.use(
|
||||
createPermissionIntegrationRouter({
|
||||
resourceType: 'test-resource',
|
||||
getResource: async resourceRef => ({ id: resourceRef }),
|
||||
rules: [
|
||||
{
|
||||
name: 'RULE_1',
|
||||
description: 'Test rule 1',
|
||||
apply: (_resource: any, input: 'yes' | 'no') => input === 'yes',
|
||||
toQuery: () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'RULE_2',
|
||||
description: 'Test rule 2',
|
||||
apply: (_resource: any, input: 'yes' | 'no') => input === 'yes',
|
||||
toQuery: () => {
|
||||
throw new Error('Not implemented');
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use('/test-plugin', router);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server = app.listen(resolve);
|
||||
});
|
||||
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl(pluginId: string) {
|
||||
const listenPort = (server.address()! as AddressInfo).port;
|
||||
|
||||
return `http://0.0.0.0:${listenPort}/${pluginId}`;
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
throw new Error('Not implemented.');
|
||||
},
|
||||
};
|
||||
|
||||
client = new PermissionIntegrationClient({
|
||||
discovery,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(
|
||||
async () =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close(err => (err ? reject(err) : resolve())),
|
||||
),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('works for simple conditions', async () => {
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'RULE_1', params: ['no'] },
|
||||
}),
|
||||
).resolves.toEqual({ result: AuthorizeResult.DENY });
|
||||
});
|
||||
|
||||
it('works for complex criteria', async () => {
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
allOf: [
|
||||
{
|
||||
allOf: [
|
||||
{ rule: 'RULE_1', params: ['yes'] },
|
||||
{ not: { rule: 'RULE_2', params: ['no'] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
not: {
|
||||
allOf: [
|
||||
{ rule: 'RULE_1', params: ['no'] },
|
||||
{ rule: 'RULE_2', params: ['yes'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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 fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
ApplyConditionsRequest,
|
||||
ApplyConditionsResponse,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
|
||||
const responseSchema = z.object({
|
||||
result: z.literal(AuthorizeResult.ALLOW).or(z.literal(AuthorizeResult.DENY)),
|
||||
});
|
||||
|
||||
export class PermissionIntegrationClient {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
|
||||
constructor(options: { discovery: PluginEndpointDiscovery }) {
|
||||
this.discovery = options.discovery;
|
||||
}
|
||||
|
||||
async applyConditions(
|
||||
{
|
||||
pluginId,
|
||||
resourceRef,
|
||||
resourceType,
|
||||
conditions,
|
||||
}: {
|
||||
resourceRef: string;
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
},
|
||||
authHeader?: string,
|
||||
): Promise<ApplyConditionsResponse> {
|
||||
const endpoint = `${await this.discovery.getBaseUrl(
|
||||
pluginId,
|
||||
)}/permissions/apply-conditions`;
|
||||
|
||||
const request: ApplyConditionsRequest = {
|
||||
resourceRef,
|
||||
resourceType,
|
||||
conditions,
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
headers: {
|
||||
...(authHeader ? { authorization: authHeader } : {}),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response from plugin upstream when applying conditions. Expected 200 but got ${response.status} - ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return responseSchema.parse(await response.json());
|
||||
}
|
||||
}
|
||||
@@ -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 { createRouter } from './router';
|
||||
export type { RouterOptions } from './router';
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import request from 'supertest';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { IdentityClient } from '@backstage/plugin-auth-backend';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { ApplyConditionsResponse } from '@backstage/plugin-permission-node';
|
||||
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
const mockApplyConditions: jest.MockedFunction<
|
||||
InstanceType<typeof PermissionIntegrationClient>['applyConditions']
|
||||
> = jest.fn();
|
||||
jest.mock('./PermissionIntegrationClient', () => ({
|
||||
PermissionIntegrationClient: jest.fn(() => ({
|
||||
applyConditions: mockApplyConditions,
|
||||
})),
|
||||
}));
|
||||
|
||||
const policy = {
|
||||
handle: jest.fn().mockImplementation((_req, identity) => {
|
||||
if (identity) {
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
return { result: AuthorizeResult.DENY };
|
||||
}),
|
||||
};
|
||||
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: getVoidLogger(),
|
||||
discovery: {
|
||||
getBaseUrl: jest.fn(),
|
||||
getExternalBaseUrl: jest.fn(),
|
||||
},
|
||||
identity: {
|
||||
authenticate: jest.fn(token => {
|
||||
if (!token) {
|
||||
throw new Error('No token supplied!');
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
id: 'test-user',
|
||||
token,
|
||||
});
|
||||
}),
|
||||
} as unknown as IdentityClient,
|
||||
policy,
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /authorize', () => {
|
||||
it('calls the permission policy', async () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission2',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
|
||||
expect(policy.handle).toHaveBeenCalledWith(
|
||||
{
|
||||
permission: {
|
||||
name: 'test.permission1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(policy.handle).toHaveBeenCalledWith(
|
||||
{
|
||||
permission: {
|
||||
name: 'test.permission2',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves identity from the Authorization header', async () => {
|
||||
const token = 'test-token';
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth(token, { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(policy.handle).toHaveBeenCalledWith(
|
||||
{
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
{ id: 'test-user', token: 'test-token' },
|
||||
);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('conditional policy result', () => {
|
||||
beforeEach(() => {
|
||||
policy.handle.mockReturnValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: {
|
||||
anyOf: [{ rule: 'test-rule', params: ['abc'] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns conditions if no resourceRef is supplied', async () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each<ApplyConditionsResponse['result']>([
|
||||
AuthorizeResult.ALLOW,
|
||||
AuthorizeResult.DENY,
|
||||
])(
|
||||
'applies conditions and returns %s if resourceRef is supplied',
|
||||
async result => {
|
||||
mockApplyConditions.mockResolvedValueOnce({
|
||||
result,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'test/resource',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
{
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'test/resource',
|
||||
conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] },
|
||||
},
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{
|
||||
id: '123',
|
||||
result,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'',
|
||||
{},
|
||||
[{ permission: { name: 'test.permission', attributes: {} } }],
|
||||
[{ id: '123' }],
|
||||
[{ id: '123', permission: { name: 'test.permission' } }],
|
||||
[{ id: '123', permission: { attributes: { invalid: 'attribute' } } }],
|
||||
])('returns a 500 error for invalid request %#', async requestBody => {
|
||||
const response = await request(app).post('/authorize').send(requestBody);
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringMatching(/invalid/i),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a 500 error if the policy returns a different resourceType', async () => {
|
||||
policy.handle.mockReturnValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: {
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: {},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
message: expect.stringMatching(/invalid resource conditions/i),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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 { z } from 'zod';
|
||||
import express, { Request, Response } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
errorHandler,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import {
|
||||
BackstageIdentity,
|
||||
IdentityClient,
|
||||
} from '@backstage/plugin-auth-backend';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
AuthorizeResponse,
|
||||
AuthorizeRequest,
|
||||
Identified,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { PermissionPolicy } from '@backstage/plugin-permission-node';
|
||||
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
|
||||
const requestSchema: z.ZodSchema<Identified<AuthorizeRequest>[]> = z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
resourceRef: z.string().optional(),
|
||||
permission: z.object({
|
||||
name: z.string(),
|
||||
resourceType: z.string().optional(),
|
||||
attributes: z.object({
|
||||
action: z
|
||||
.union([
|
||||
z.literal('create'),
|
||||
z.literal('read'),
|
||||
z.literal('update'),
|
||||
z.literal('delete'),
|
||||
])
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Options required when constructing a new {@link express#Router} using
|
||||
* {@link createRouter}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
discovery: PluginEndpointDiscovery;
|
||||
policy: PermissionPolicy;
|
||||
identity: IdentityClient;
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
{ id, resourceRef, ...request }: Identified<AuthorizeRequest>,
|
||||
user: BackstageIdentity | undefined,
|
||||
policy: PermissionPolicy,
|
||||
permissionIntegrationClient: PermissionIntegrationClient,
|
||||
authHeader?: string,
|
||||
): Promise<Identified<AuthorizeResponse>> => {
|
||||
const response = await policy.handle(request, user);
|
||||
|
||||
if (response.result === AuthorizeResult.CONDITIONAL) {
|
||||
// Sanity check that any resource provided matches the one expected by the permission
|
||||
if (request.permission.resourceType !== response.conditions.resourceType) {
|
||||
throw new Error(
|
||||
`Invalid resource conditions returned from permission policy for permission ${request.permission.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceRef) {
|
||||
return {
|
||||
id,
|
||||
...(await permissionIntegrationClient.applyConditions(
|
||||
{
|
||||
resourceRef,
|
||||
...response.conditions,
|
||||
},
|
||||
authHeader,
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
// TODO(mtlewis): this .conditions.conditions situation is a bit awkward. I think it's
|
||||
// worth exploring a bit of reorganization of the ConditionalPolicyResult type so that
|
||||
// the naming of property chains like this makes a bit more sense.
|
||||
conditions: response.conditions.conditions,
|
||||
};
|
||||
}
|
||||
|
||||
return { id, ...response };
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new {@link express#Router} which provides the backend API
|
||||
* for the permission system.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { policy, discovery, identity } = options;
|
||||
|
||||
const permissionIntegrationClient = new PermissionIntegrationClient({
|
||||
discovery,
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.get('/health', (_, response) => {
|
||||
response.send({ status: 'ok' });
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/authorize',
|
||||
async (
|
||||
req: Request<Identified<AuthorizeRequest>[]>,
|
||||
res: Response<Identified<AuthorizeResponse>[]>,
|
||||
) => {
|
||||
const token = IdentityClient.getBearerToken(req.header('authorization'));
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
|
||||
const body = requestSchema.parse(req.body);
|
||||
|
||||
res.json(
|
||||
await Promise.all(
|
||||
body.map(request =>
|
||||
handleRequest(
|
||||
request,
|
||||
user,
|
||||
policy,
|
||||
permissionIntegrationClient,
|
||||
req.header('authorization'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 {};
|
||||
Reference in New Issue
Block a user