Merge pull request #8551 from backstage/authz-batching
permissions: more efficient integration between permission backend and plugin backends
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
NoForeignRootFieldsEntityPolicy,
|
||||
parseEntityRef,
|
||||
SchemaValidEntityPolicy,
|
||||
stringifyEntityRef,
|
||||
Validators,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
} from '@backstage/integration';
|
||||
import { createHash } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import lodash from 'lodash';
|
||||
import lodash, { keyBy } from 'lodash';
|
||||
import { EntitiesCatalog, EntitiesSearchFilter } from '../catalog';
|
||||
import {
|
||||
DatabaseLocationsCatalog,
|
||||
@@ -415,18 +416,27 @@ export class NextCatalogBuilder {
|
||||
);
|
||||
const permissionIntegrationRouter = createPermissionIntegrationRouter({
|
||||
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
getResource: async (resourceRef: string) => {
|
||||
const parsed = parseEntityRef(resourceRef);
|
||||
getResources: async (resourceRefs: string[]) => {
|
||||
const { entities } = await entitiesCatalog.entities({
|
||||
filter: {
|
||||
anyOf: resourceRefs.map(resourceRef => {
|
||||
const { kind, namespace, name } = parseEntityRef(resourceRef);
|
||||
|
||||
const { entities } = await unauthorizedEntitiesCatalog.entities({
|
||||
filter: basicEntityFilter({
|
||||
kind: parsed.kind,
|
||||
'metadata.namespace': parsed.namespace,
|
||||
'metadata.name': parsed.name,
|
||||
}),
|
||||
return basicEntityFilter({
|
||||
kind,
|
||||
'metadata.namespace': namespace,
|
||||
'metadata.name': name,
|
||||
});
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return entities[0];
|
||||
const entitiesByRef = keyBy(entities, stringifyEntityRef);
|
||||
|
||||
return resourceRefs.map(
|
||||
resourceRef =>
|
||||
entitiesByRef[stringifyEntityRef(parseEntityRef(resourceRef))],
|
||||
);
|
||||
},
|
||||
rules: this.permissionRules,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,9 @@ import { EntitiesCatalog } from '../catalog';
|
||||
import { LocationService, RefreshService } from './types';
|
||||
import { basicEntityFilter } from './request';
|
||||
import { createNextRouter } from './NextRouter';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
|
||||
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
|
||||
|
||||
describe('createNextRouter readonly disabled', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
@@ -433,3 +436,87 @@ describe('createNextRouter readonly enabled', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NextRouter permissioning', () => {
|
||||
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
let app: express.Express;
|
||||
let refreshService: RefreshService;
|
||||
|
||||
const fakeRule = {
|
||||
name: 'FAKE_RULE',
|
||||
description: 'fake rule',
|
||||
apply: () => true,
|
||||
toQuery: () => ({ key: '', values: [] }),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
entitiesCatalog = {
|
||||
entities: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
batchAddOrUpdateEntities: jest.fn(),
|
||||
entityAncestry: jest.fn(),
|
||||
};
|
||||
locationService = {
|
||||
getLocation: jest.fn(),
|
||||
createLocation: jest.fn(),
|
||||
listLocations: jest.fn(),
|
||||
deleteLocation: jest.fn(),
|
||||
};
|
||||
refreshService = { refresh: jest.fn() };
|
||||
const router = await createNextRouter({
|
||||
entitiesCatalog,
|
||||
locationService,
|
||||
logger: getVoidLogger(),
|
||||
refreshService,
|
||||
config: new ConfigReader(undefined),
|
||||
permissionIntegrationRouter: createPermissionIntegrationRouter({
|
||||
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
|
||||
rules: [fakeRule],
|
||||
getResources: jest.fn((resourceRefs: string[]) =>
|
||||
Promise.resolve(
|
||||
resourceRefs.map(resourceRef => ({ id: resourceRef })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
});
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('accepts and evaluates conditions at the apply-conditions endpoint', async () => {
|
||||
const spideySense: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'component',
|
||||
metadata: {
|
||||
name: 'spidey-sense',
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entities.mockResolvedValueOnce({
|
||||
entities: [spideySense],
|
||||
pageInfo: { hasNextPage: false },
|
||||
});
|
||||
|
||||
const requestBody = {
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceType: 'catalog-entity',
|
||||
resourceRef: 'component:default/spidey-sense',
|
||||
conditions: { rule: 'FAKE_RULE', params: ['user:default/spiderman'] },
|
||||
},
|
||||
],
|
||||
};
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send(requestBody);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({
|
||||
items: [{ id: '123', result: AuthorizeResult.ALLOW }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.10.1",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.5",
|
||||
"@backstage/plugin-auth-backend": "^0.6.0",
|
||||
"@backstage/plugin-permission-common": "^0.3.0",
|
||||
"@backstage/plugin-permission-node": "^0.2.3",
|
||||
"@types/express": "*",
|
||||
"dataloader": "^2.0.0",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.6.1",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0",
|
||||
@@ -34,6 +37,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.10.4",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^6.1.6",
|
||||
"msw": "^0.35.0"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { AddressInfo } from 'net';
|
||||
import { Server } from 'http';
|
||||
import express, { Router } from 'express';
|
||||
import express, { Router, RequestHandler } from 'express';
|
||||
import { RestContext, rest } from 'msw';
|
||||
import { setupServer, SetupServerApi } from 'msw/node';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
@@ -39,14 +39,16 @@ describe('PermissionIntegrationClient', () => {
|
||||
|
||||
const mockApplyConditionsHandler = jest.fn(
|
||||
(_req, res, { json }: RestContext) => {
|
||||
return res(json({ result: AuthorizeResult.ALLOW }));
|
||||
return res(
|
||||
json({ items: [{ id: '123', result: AuthorizeResult.ALLOW }] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
|
||||
const mockBaseUrl = 'http://backstage:9191';
|
||||
const discovery: PluginEndpointDiscovery = {
|
||||
async getBaseUrl() {
|
||||
return mockBaseUrl;
|
||||
async getBaseUrl(pluginId) {
|
||||
return `${mockBaseUrl}/${pluginId}`;
|
||||
},
|
||||
async getExternalBaseUrl() {
|
||||
throw new Error('Not implemented.');
|
||||
@@ -64,7 +66,7 @@ describe('PermissionIntegrationClient', () => {
|
||||
server.listen({ onUnhandledRequest: 'error' });
|
||||
server.use(
|
||||
rest.post(
|
||||
`${mockBaseUrl}/.well-known/backstage/permissions/apply-conditions`,
|
||||
`${mockBaseUrl}/plugin-1/.well-known/backstage/permissions/apply-conditions`,
|
||||
mockApplyConditionsHandler,
|
||||
),
|
||||
);
|
||||
@@ -77,30 +79,39 @@ describe('PermissionIntegrationClient', () => {
|
||||
});
|
||||
|
||||
it('should make a POST request to the correct endpoint', async () => {
|
||||
await client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
});
|
||||
await client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
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,
|
||||
});
|
||||
await client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockApplyConditionsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
expect.anything(),
|
||||
@@ -109,25 +120,29 @@ describe('PermissionIntegrationClient', () => {
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
const response = await client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
|
||||
expect.objectContaining([{ id: '123', 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,
|
||||
});
|
||||
await client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
]);
|
||||
|
||||
const request = mockApplyConditionsHandler.mock.calls[0][0];
|
||||
expect(request.headers.has('authorization')).toEqual(false);
|
||||
@@ -135,12 +150,15 @@ describe('PermissionIntegrationClient', () => {
|
||||
|
||||
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,
|
||||
},
|
||||
'plugin-1',
|
||||
[
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
],
|
||||
'Bearer fake-token',
|
||||
);
|
||||
|
||||
@@ -156,36 +174,88 @@ describe('PermissionIntegrationClient', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
}),
|
||||
client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
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 }));
|
||||
return res(
|
||||
json({ items: [{ id: '123', outcome: AuthorizeResult.ALLOW }] }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.applyConditions({
|
||||
pluginId: 'test-plugin',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
}),
|
||||
client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
]),
|
||||
).rejects.toThrowError(/invalid input/i);
|
||||
});
|
||||
|
||||
it('should batch requests to plugin backends', async () => {
|
||||
mockApplyConditionsHandler.mockImplementationOnce(
|
||||
(_req, res, { json }: RestContext) => {
|
||||
return res(
|
||||
json({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '456', result: AuthorizeResult.DENY },
|
||||
{ id: '789', result: AuthorizeResult.ALLOW },
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
{
|
||||
id: '789',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: mockConditions,
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '456', result: AuthorizeResult.DENY },
|
||||
{ id: '789', result: AuthorizeResult.ALLOW },
|
||||
]);
|
||||
|
||||
expect(mockApplyConditionsHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with @backstage/plugin-permission-node', () => {
|
||||
let server: Server;
|
||||
let client: PermissionIntegrationClient;
|
||||
let routerSpy: RequestHandler;
|
||||
|
||||
beforeAll(async () => {
|
||||
const router = Router();
|
||||
@@ -193,7 +263,10 @@ describe('PermissionIntegrationClient', () => {
|
||||
router.use(
|
||||
createPermissionIntegrationRouter({
|
||||
resourceType: 'test-resource',
|
||||
getResource: async resourceRef => ({ id: resourceRef }),
|
||||
getResources: async resourceRefs =>
|
||||
resourceRefs.map(resourceRef => ({
|
||||
id: resourceRef,
|
||||
})),
|
||||
rules: [
|
||||
{
|
||||
name: 'RULE_1',
|
||||
@@ -217,7 +290,9 @@ describe('PermissionIntegrationClient', () => {
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use('/test-plugin', router);
|
||||
routerSpy = jest.fn(router);
|
||||
|
||||
app.use('/plugin-1', routerSpy);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
server = app.listen(resolve);
|
||||
@@ -252,41 +327,45 @@ describe('PermissionIntegrationClient', () => {
|
||||
|
||||
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 });
|
||||
client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'RULE_1', params: ['no'] },
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([{ id: '123', 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: {
|
||||
client.applyConditions('plugin-1', [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'testResource1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
allOf: [
|
||||
{
|
||||
allOf: [
|
||||
{ rule: 'RULE_1', params: ['no'] },
|
||||
{ rule: 'RULE_2', params: ['yes'] },
|
||||
{ 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 });
|
||||
]),
|
||||
).resolves.toEqual([{ id: '123', result: AuthorizeResult.ALLOW }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,20 +17,28 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
import { PluginEndpointDiscovery } from '@backstage/backend-common';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import {
|
||||
ApplyConditionsRequest,
|
||||
ApplyConditionsResponse,
|
||||
ApplyConditionsRequestEntry,
|
||||
ApplyConditionsResponseEntry,
|
||||
ConditionalPolicyDecision,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
|
||||
const responseSchema = z.object({
|
||||
result: z.literal(AuthorizeResult.ALLOW).or(z.literal(AuthorizeResult.DENY)),
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
result: z
|
||||
.literal(AuthorizeResult.ALLOW)
|
||||
.or(z.literal(AuthorizeResult.DENY)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type ResourcePolicyDecision = ConditionalPolicyDecision & {
|
||||
resourceRef: string;
|
||||
};
|
||||
|
||||
export class PermissionIntegrationClient {
|
||||
private readonly discovery: PluginEndpointDiscovery;
|
||||
|
||||
@@ -39,32 +47,26 @@ export class PermissionIntegrationClient {
|
||||
}
|
||||
|
||||
async applyConditions(
|
||||
{
|
||||
pluginId,
|
||||
resourceRef,
|
||||
resourceType,
|
||||
conditions,
|
||||
}: {
|
||||
resourceRef: string;
|
||||
pluginId: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
},
|
||||
pluginId: string,
|
||||
decisions: readonly ApplyConditionsRequestEntry[],
|
||||
authHeader?: string,
|
||||
): Promise<ApplyConditionsResponse> {
|
||||
): Promise<ApplyConditionsResponseEntry[]> {
|
||||
const endpoint = `${await this.discovery.getBaseUrl(
|
||||
pluginId,
|
||||
)}/.well-known/backstage/permissions/apply-conditions`;
|
||||
|
||||
const request: ApplyConditionsRequest = {
|
||||
resourceRef,
|
||||
resourceType,
|
||||
conditions,
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
body: JSON.stringify({
|
||||
items: decisions.map(
|
||||
({ id, resourceRef, resourceType, conditions }) => ({
|
||||
id,
|
||||
resourceRef,
|
||||
resourceType,
|
||||
conditions,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
headers: {
|
||||
...(authHeader ? { authorization: authHeader } : {}),
|
||||
'content-type': 'application/json',
|
||||
@@ -77,6 +79,8 @@ export class PermissionIntegrationClient {
|
||||
);
|
||||
}
|
||||
|
||||
return responseSchema.parse(await response.json());
|
||||
const result = responseSchema.parse(await response.json());
|
||||
|
||||
return result.items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,30 @@ 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 {
|
||||
ApplyConditionsRequestEntry,
|
||||
ApplyConditionsResponseEntry,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
|
||||
import { createRouter } from './router';
|
||||
|
||||
const mockApplyConditions: jest.MockedFunction<
|
||||
InstanceType<typeof PermissionIntegrationClient>['applyConditions']
|
||||
> = jest.fn();
|
||||
> = jest.fn(
|
||||
async (
|
||||
_pluginId: string,
|
||||
decisions: readonly ApplyConditionsRequestEntry[],
|
||||
) =>
|
||||
decisions.map(decision => ({
|
||||
id: decision.id,
|
||||
result:
|
||||
(decision.conditions as any).params[0] === 'yes'
|
||||
? (AuthorizeResult.ALLOW as const)
|
||||
: (AuthorizeResult.DENY as const),
|
||||
})),
|
||||
);
|
||||
|
||||
jest.mock('./PermissionIntegrationClient', () => ({
|
||||
PermissionIntegrationClient: jest.fn(() => ({
|
||||
applyConditions: mockApplyConditions,
|
||||
@@ -34,7 +50,7 @@ jest.mock('./PermissionIntegrationClient', () => ({
|
||||
}));
|
||||
|
||||
const policy = {
|
||||
handle: jest.fn().mockImplementation((_req, identity) => {
|
||||
handle: jest.fn().mockImplementation(async (_req, identity) => {
|
||||
if (identity) {
|
||||
return { result: AuthorizeResult.ALLOW };
|
||||
}
|
||||
@@ -70,6 +86,10 @@ describe('createRouter', () => {
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns ok', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
@@ -158,18 +178,14 @@ describe('createRouter', () => {
|
||||
});
|
||||
|
||||
describe('conditional policy result', () => {
|
||||
beforeEach(() => {
|
||||
policy.handle.mockReturnValueOnce({
|
||||
it('returns conditions if no resourceRef is supplied', async () => {
|
||||
policy.handle.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: {
|
||||
anyOf: [{ rule: 'test-rule', params: ['abc'] }],
|
||||
},
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns conditions if no resourceRef is supplied', async () => {
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.send([
|
||||
@@ -188,21 +204,399 @@ describe('createRouter', () => {
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: { anyOf: [{ rule: 'test-rule', params: ['abc'] }] },
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each<ApplyConditionsResponse['result']>([
|
||||
AuthorizeResult.ALLOW,
|
||||
AuthorizeResult.DENY,
|
||||
it('makes separate batched requests to multiple plugin backends', async () => {
|
||||
policy.handle
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-2',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-2',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '123',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'resource:1',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '345',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'resource:3',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-2',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '234',
|
||||
resourceType: 'test-resource-2',
|
||||
resourceRef: 'resource:2',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '456',
|
||||
resourceType: 'test-resource-2',
|
||||
resourceRef: 'resource:4',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.DENY },
|
||||
{ id: '456', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves definitive results unchanged', async () => {
|
||||
policy.handle
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-2',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.ALLOW,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-2',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.DENY,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:4',
|
||||
},
|
||||
{
|
||||
id: '567',
|
||||
permission: {
|
||||
name: 'test.permission.5',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:5',
|
||||
},
|
||||
{
|
||||
id: '678',
|
||||
permission: {
|
||||
name: 'test.permission.6',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '123',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'resource:1',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '456',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'resource:4',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-2',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '234',
|
||||
resourceType: 'test-resource-2',
|
||||
resourceRef: 'resource:2',
|
||||
conditions: { rule: 'test-rule', params: ['no'] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '567',
|
||||
resourceType: 'test-resource-2',
|
||||
resourceRef: 'resource:5',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.DENY },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{ id: '456', result: AuthorizeResult.ALLOW },
|
||||
{ id: '567', result: AuthorizeResult.ALLOW },
|
||||
{ id: '678', result: AuthorizeResult.DENY },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves conditional results without resourceRefs unchanged', async () => {
|
||||
policy.handle
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-2',
|
||||
resourceType: 'test-resource-2',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.ALLOW,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
.send([
|
||||
{
|
||||
id: '123',
|
||||
permission: {
|
||||
name: 'test.permission.1',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:1',
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
permission: {
|
||||
name: 'test.permission.2',
|
||||
resourceType: 'test-resource-2',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:2',
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
permission: {
|
||||
name: 'test.permission.3',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
resourceRef: 'resource:3',
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
permission: {
|
||||
name: 'test.permission.4',
|
||||
resourceType: 'test-resource-1',
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-1',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '123',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'resource:1',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(mockApplyConditions).toHaveBeenCalledWith(
|
||||
'plugin-2',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '234',
|
||||
resourceType: 'test-resource-2',
|
||||
resourceRef: 'resource:2',
|
||||
conditions: { rule: 'test-rule', params: ['yes'] },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual([
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.ALLOW },
|
||||
{ id: '345', result: AuthorizeResult.ALLOW },
|
||||
{
|
||||
id: '456',
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'plugin-1',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params: ['abc'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each<[ApplyConditionsResponseEntry['result'], string]>([
|
||||
[AuthorizeResult.ALLOW, 'yes'],
|
||||
[AuthorizeResult.DENY, 'no'],
|
||||
])(
|
||||
'applies conditions and returns %s if resourceRef is supplied',
|
||||
async result => {
|
||||
mockApplyConditions.mockResolvedValueOnce({
|
||||
result,
|
||||
async (result, params) => {
|
||||
policy.handle.mockResolvedValue({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-1',
|
||||
conditions: { rule: 'test-rule', params },
|
||||
});
|
||||
|
||||
mockApplyConditions.mockResolvedValueOnce([
|
||||
{
|
||||
id: '123',
|
||||
result,
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
result,
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/authorize')
|
||||
.auth('test-token', { type: 'bearer' })
|
||||
@@ -216,15 +610,33 @@ describe('createRouter', () => {
|
||||
attributes: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
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'] }] },
|
||||
},
|
||||
'test-plugin',
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: '123',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'test/resource',
|
||||
conditions: { rule: 'test-rule', params },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: '234',
|
||||
resourceType: 'test-resource-1',
|
||||
resourceRef: 'test/resource',
|
||||
conditions: { rule: 'test-rule', params },
|
||||
}),
|
||||
],
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
@@ -234,6 +646,10 @@ describe('createRouter', () => {
|
||||
id: '123',
|
||||
result,
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
result,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
@@ -247,10 +663,10 @@ describe('createRouter', () => {
|
||||
[{ id: '123' }],
|
||||
[{ id: '123', permission: { name: 'test.permission' } }],
|
||||
[{ id: '123', permission: { attributes: { invalid: 'attribute' } } }],
|
||||
])('returns a 500 error for invalid request %#', async requestBody => {
|
||||
])('returns a 400 error for invalid request %#', async requestBody => {
|
||||
const response = await request(app).post('/authorize').send(requestBody);
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
@@ -261,7 +677,7 @@ describe('createRouter', () => {
|
||||
});
|
||||
|
||||
it('returns a 500 error if the policy returns a different resourceType', async () => {
|
||||
policy.handle.mockReturnValueOnce({
|
||||
policy.handle.mockResolvedValueOnce({
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
pluginId: 'test-plugin',
|
||||
resourceType: 'test-resource-2',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
errorHandler,
|
||||
PluginEndpointDiscovery,
|
||||
} from '@backstage/backend-common';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import {
|
||||
BackstageIdentityResponse,
|
||||
IdentityClient,
|
||||
@@ -32,8 +33,14 @@ import {
|
||||
AuthorizeRequest,
|
||||
Identified,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { PermissionPolicy } from '@backstage/plugin-permission-node';
|
||||
import {
|
||||
ApplyConditionsRequestEntry,
|
||||
ApplyConditionsResponseEntry,
|
||||
PermissionPolicy,
|
||||
} from '@backstage/plugin-permission-node';
|
||||
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
|
||||
import { memoize } from 'lodash';
|
||||
import DataLoader from 'dataloader';
|
||||
|
||||
const requestSchema: z.ZodSchema<Identified<AuthorizeRequest>[]> = z.array(
|
||||
z.object({
|
||||
@@ -70,45 +77,52 @@ export interface RouterOptions {
|
||||
}
|
||||
|
||||
const handleRequest = async (
|
||||
{ id, resourceRef, ...request }: Identified<AuthorizeRequest>,
|
||||
requests: Identified<AuthorizeRequest>[],
|
||||
user: BackstageIdentityResponse | undefined,
|
||||
policy: PermissionPolicy,
|
||||
permissionIntegrationClient: PermissionIntegrationClient,
|
||||
authHeader?: string,
|
||||
): Promise<Identified<AuthorizeResponse>> => {
|
||||
const response = await policy.handle(request, user);
|
||||
): Promise<Identified<AuthorizeResponse>[]> => {
|
||||
const applyConditionsLoaderFor = memoize((pluginId: string) => {
|
||||
return new DataLoader<
|
||||
ApplyConditionsRequestEntry,
|
||||
ApplyConditionsResponseEntry
|
||||
>(batch =>
|
||||
permissionIntegrationClient.applyConditions(pluginId, batch, authHeader),
|
||||
);
|
||||
});
|
||||
|
||||
if (response.result === AuthorizeResult.CONDITIONAL) {
|
||||
// Sanity check that any resource provided matches the one expected by the permission
|
||||
if (request.permission.resourceType !== response.resourceType) {
|
||||
throw new Error(
|
||||
`Invalid resource conditions returned from permission policy for permission ${request.permission.name}`,
|
||||
);
|
||||
}
|
||||
return Promise.all(
|
||||
requests.map(({ id, resourceRef, ...request }) =>
|
||||
policy.handle(request, user).then(decision => {
|
||||
if (decision.result !== AuthorizeResult.CONDITIONAL) {
|
||||
return {
|
||||
id,
|
||||
...decision,
|
||||
};
|
||||
}
|
||||
|
||||
if (resourceRef) {
|
||||
return {
|
||||
id,
|
||||
...(await permissionIntegrationClient.applyConditions(
|
||||
{
|
||||
resourceRef,
|
||||
pluginId: response.pluginId,
|
||||
resourceType: response.resourceType,
|
||||
conditions: response.conditions,
|
||||
},
|
||||
authHeader,
|
||||
)),
|
||||
};
|
||||
}
|
||||
if (decision.resourceType !== request.permission.resourceType) {
|
||||
throw new Error(
|
||||
`Invalid resource conditions returned from permission policy for permission ${request.permission.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
result: AuthorizeResult.CONDITIONAL,
|
||||
conditions: response.conditions,
|
||||
};
|
||||
}
|
||||
if (!resourceRef) {
|
||||
return {
|
||||
id,
|
||||
...decision,
|
||||
};
|
||||
}
|
||||
|
||||
return { id, ...response };
|
||||
return applyConditionsLoaderFor(decision.pluginId).load({
|
||||
id,
|
||||
resourceRef,
|
||||
...decision,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -142,24 +156,27 @@ export async function createRouter(
|
||||
const token = IdentityClient.getBearerToken(req.header('authorization'));
|
||||
const user = token ? await identity.authenticate(token) : undefined;
|
||||
|
||||
const body = requestSchema.parse(req.body);
|
||||
const parseResult = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new InputError(parseResult.error.toString());
|
||||
}
|
||||
|
||||
const body = parseResult.data;
|
||||
|
||||
res.json(
|
||||
await Promise.all(
|
||||
body.map(request =>
|
||||
handleRequest(
|
||||
request,
|
||||
user,
|
||||
policy,
|
||||
permissionIntegrationClient,
|
||||
req.header('authorization'),
|
||||
),
|
||||
),
|
||||
await handleRequest(
|
||||
body,
|
||||
user,
|
||||
policy,
|
||||
permissionIntegrationClient,
|
||||
req.header('authorization'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -9,25 +9,34 @@ 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 express from 'express';
|
||||
import { Identified } from '@backstage/plugin-permission-common';
|
||||
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 = {
|
||||
resourceRef: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
items: ApplyConditionsRequestEntry[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ApplyConditionsRequestEntry = Identified<{
|
||||
resourceRef: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type ApplyConditionsResponse = {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
items: ApplyConditionsResponseEntry[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type ApplyConditionsResponseEntry = Identified<DefinitivePolicyDecision>;
|
||||
|
||||
// @public
|
||||
export type Condition<TRule> = TRule extends PermissionRule<
|
||||
any,
|
||||
@@ -92,8 +101,8 @@ export const createConditionTransformer: <
|
||||
export const createPermissionIntegrationRouter: <TResource>(options: {
|
||||
resourceType: string;
|
||||
rules: PermissionRule<TResource, any, unknown[]>[];
|
||||
getResource: (resourceRef: string) => Promise<TResource | undefined>;
|
||||
}) => Router;
|
||||
getResources: (resourceRefs: string[]) => Promise<(TResource | undefined)[]>;
|
||||
}) => express.Router;
|
||||
|
||||
// @public
|
||||
export const createPermissionRule: <
|
||||
@@ -104,6 +113,11 @@ export const createPermissionRule: <
|
||||
rule: PermissionRule<TResource, TQuery, TParams>,
|
||||
) => PermissionRule<TResource, TQuery, TParams>;
|
||||
|
||||
// @public
|
||||
export type DefinitivePolicyDecision = {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const makeCreatePermissionRule: <TResource, TQuery>() => <
|
||||
TParams extends unknown[],
|
||||
@@ -137,9 +151,7 @@ export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
|
||||
|
||||
// @public
|
||||
export type PolicyDecision =
|
||||
| {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
}
|
||||
| DefinitivePolicyDecision
|
||||
| ConditionalPolicyDecision;
|
||||
|
||||
// @public
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.10.1",
|
||||
"@backstage/config": "^0.1.11",
|
||||
"@backstage/errors": "^0.1.5",
|
||||
"@backstage/plugin-auth-backend": "^0.6.0",
|
||||
"@backstage/plugin-permission-common": "^0.3.0",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^4.1.0",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+219
-43
@@ -16,15 +16,13 @@
|
||||
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import express, { Express, Router } from 'express';
|
||||
import request from 'supertest';
|
||||
import request, { Response } from 'supertest';
|
||||
import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter';
|
||||
|
||||
const mockGetResource: jest.MockedFunction<
|
||||
(resourceRef: string) => Promise<any>
|
||||
> = jest.fn((resourceRef: string) =>
|
||||
Promise.resolve({
|
||||
resourceRef,
|
||||
}),
|
||||
const mockGetResources: jest.MockedFunction<
|
||||
Parameters<typeof createPermissionIntegrationRouter>[0]['getResources']
|
||||
> = jest.fn(async resourceRefs =>
|
||||
resourceRefs.map(resourceRef => ({ id: resourceRef })),
|
||||
);
|
||||
|
||||
const testRule1 = {
|
||||
@@ -47,16 +45,20 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
let app: Express;
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeAll(() => {
|
||||
router = createPermissionIntegrationRouter({
|
||||
resourceType: 'test-resource',
|
||||
getResource: mockGetResource,
|
||||
getResources: mockGetResources,
|
||||
rules: [testRule1, testRule2],
|
||||
});
|
||||
|
||||
app = express().use(router);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('works', async () => {
|
||||
expect(router).toBeDefined();
|
||||
});
|
||||
@@ -70,7 +72,6 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
{ rule: 'test-rule-2', params: [{}] },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
not: { rule: 'test-rule-2', params: [{}] },
|
||||
},
|
||||
@@ -96,13 +97,25 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions,
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ result: AuthorizeResult.ALLOW });
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -137,74 +150,237 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions,
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({ result: AuthorizeResult.DENY });
|
||||
expect(response.body).toEqual({
|
||||
items: [{ id: '123', result: AuthorizeResult.DENY }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe('batched requests', () => {
|
||||
let response: Response;
|
||||
|
||||
beforeEach(async () => {
|
||||
response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource-1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-1', params: [] },
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
resourceRef: 'default:test/resource-1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-2', params: [] },
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
resourceRef: 'default:test/resource-2',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { not: { rule: 'test-rule-1', params: [] } },
|
||||
},
|
||||
{
|
||||
id: '456',
|
||||
resourceRef: 'default:test/resource-3',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { not: { rule: 'test-rule-2', params: [] } },
|
||||
},
|
||||
{
|
||||
id: '567',
|
||||
resourceRef: 'default:test/resource-4',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
anyOf: [
|
||||
{ rule: 'test-rule-1', params: [] },
|
||||
{ rule: 'test-rule-2', params: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('processes batched requests', () => {
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{ id: '123', result: AuthorizeResult.ALLOW },
|
||||
{ id: '234', result: AuthorizeResult.DENY },
|
||||
{ id: '345', result: AuthorizeResult.DENY },
|
||||
{ id: '456', result: AuthorizeResult.ALLOW },
|
||||
{ id: '567', result: AuthorizeResult.ALLOW },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('calls getResources for all required resources at once', () => {
|
||||
expect(mockGetResources).toHaveBeenCalledWith([
|
||||
'default:test/resource-1',
|
||||
'default:test/resource-2',
|
||||
'default:test/resource-3',
|
||||
'default:test/resource-4',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 when called with incorrect resource type', async () => {
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-incorrect-resource',
|
||||
conditions: {
|
||||
anyOf: [],
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource-1',
|
||||
resourceType: 'test-incorrect-resource-1',
|
||||
conditions: {
|
||||
anyOf: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
resourceRef: 'default:test/resource-2',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
anyOf: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
resourceRef: 'default:test/resource-3',
|
||||
resourceType: 'test-incorrect-resource-2',
|
||||
conditions: {
|
||||
anyOf: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.error && response.error.text).toMatch(
|
||||
/unexpected resource type: test-incorrect-resource/i,
|
||||
/unexpected resource types: test-incorrect-resource-1, test-incorrect-resource-2/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 400 when resource is not found', async () => {
|
||||
mockGetResource.mockReturnValueOnce(Promise.resolve(undefined));
|
||||
it('returns 200/DENY when resource is not found', async () => {
|
||||
mockGetResources.mockImplementationOnce(async resourceRefs =>
|
||||
resourceRefs.map(() => undefined),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: {
|
||||
not: {
|
||||
rule: 'testRule1',
|
||||
params: ['a', 1],
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-1', params: [] },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.error && response.error.text).toMatch(
|
||||
/resource for ref default:test\/resource not found/i,
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.DENY,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('interleaves responses for present and missing resources', async () => {
|
||||
mockGetResources.mockImplementationOnce(async resourceRefs =>
|
||||
resourceRefs.map(resourceRef =>
|
||||
resourceRef === 'default:test/missing-resource'
|
||||
? undefined
|
||||
: { id: resourceRef },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
resourceRef: 'default:test/resource-1',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-1', params: [] },
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
resourceRef: 'default:test/missing-resource',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-1', params: [] },
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
resourceRef: 'default:test/resource-2',
|
||||
resourceType: 'test-resource',
|
||||
conditions: { rule: 'test-rule-1', params: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: '123',
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
{
|
||||
id: '234',
|
||||
result: AuthorizeResult.DENY,
|
||||
},
|
||||
{
|
||||
id: '345',
|
||||
result: AuthorizeResult.ALLOW,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
'',
|
||||
{},
|
||||
{ resourceType: 'test-resource-type' },
|
||||
{ resourceRef: 'test/resource-ref' },
|
||||
[{ resourceType: 'test-resource-type' }],
|
||||
{ items: [{ resourceType: 'test-resource-type' }] },
|
||||
{ items: [{ resourceRef: 'test/resource-ref' }] },
|
||||
{
|
||||
resourceType: 'test-resource-type',
|
||||
resourceRef: 'test/resource-ref',
|
||||
items: [
|
||||
{
|
||||
resourceType: 'test-resource-type',
|
||||
resourceRef: 'test/resource-ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ conditions: { anyOf: [] } },
|
||||
{ items: [{ conditions: { anyOf: [] } }] },
|
||||
])(`returns 400 for invalid input %#`, async input => {
|
||||
const response = await request(app)
|
||||
.post('/.well-known/backstage/permissions/apply-conditions')
|
||||
.send(input);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.error && response.error.text).toMatch(
|
||||
/invalid request body/i,
|
||||
);
|
||||
expect(response.error && response.error.text).toMatch(/invalid/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express, { Response, Router } from 'express';
|
||||
import express, { Response } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { z } from 'zod';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
Identified,
|
||||
PermissionCondition,
|
||||
PermissionCriteria,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
@@ -28,6 +32,7 @@ import {
|
||||
isNotCriteria,
|
||||
isOrCriteria,
|
||||
} from './util';
|
||||
import { DefinitivePolicyDecision } from '../policy/types';
|
||||
|
||||
const permissionCriteriaSchema: z.ZodSchema<
|
||||
PermissionCriteria<PermissionCondition>
|
||||
@@ -44,9 +49,14 @@ const permissionCriteriaSchema: z.ZodSchema<
|
||||
);
|
||||
|
||||
const applyConditionsRequestSchema = z.object({
|
||||
resourceRef: z.string(),
|
||||
resourceType: z.string(),
|
||||
conditions: permissionCriteriaSchema,
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
resourceRef: z.string(),
|
||||
resourceType: z.string(),
|
||||
conditions: permissionCriteriaSchema,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -55,10 +65,19 @@ const applyConditionsRequestSchema = z.object({
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApplyConditionsRequest = {
|
||||
export type ApplyConditionsRequestEntry = Identified<{
|
||||
resourceRef: string;
|
||||
resourceType: string;
|
||||
conditions: PermissionCriteria<PermissionCondition>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A batch of {@link ApplyConditionsRequestEntry} objects.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApplyConditionsRequest = {
|
||||
items: ApplyConditionsRequestEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,15 +86,29 @@ export type ApplyConditionsRequest = {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApplyConditionsResponseEntry = Identified<DefinitivePolicyDecision>;
|
||||
|
||||
/**
|
||||
* A batch of {@link ApplyConditionsResponseEntry} objects.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApplyConditionsResponse = {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
items: ApplyConditionsResponseEntry[];
|
||||
};
|
||||
|
||||
const applyConditions = <TResource>(
|
||||
criteria: PermissionCriteria<PermissionCondition>,
|
||||
resource: TResource,
|
||||
resource: TResource | undefined,
|
||||
getRule: (name: string) => PermissionRule<TResource, unknown>,
|
||||
): boolean => {
|
||||
// If resource was not found, deny. This avoids leaking information from the
|
||||
// apply-conditions API which would allow a user to differentiate between
|
||||
// non-existent resources and resources to which they do not have access.
|
||||
if (resource === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isAndCriteria(criteria)) {
|
||||
return criteria.allOf.every(child =>
|
||||
applyConditions(child, resource, getRule),
|
||||
@@ -92,84 +125,107 @@ const applyConditions = <TResource>(
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an express Router which provides an authorization route to allow integration between the
|
||||
* permission backend and other Backstage backend plugins. Plugin owners that wish to support
|
||||
* conditional authorization for their resources should add the router created by this function
|
||||
* to their express app inside their `createRouter` implementation.
|
||||
* Create an express Router which provides an authorization route to allow
|
||||
* integration between the permission backend and other Backstage backend
|
||||
* plugins. Plugin owners that wish to support conditional authorization for
|
||||
* their resources should add the router created by this function to their
|
||||
* express app inside their `createRouter` implementation.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* To make this concrete, we can use the Backstage software catalog as an example. The catalog has
|
||||
* conditional rules around access to specific _entities_ in the catalog. The _type_ of resource is
|
||||
* captured here as `resourceType`, a string identifier (`catalog-entity` in this example) that can
|
||||
* be provided with permission definitions. This is merely a _type_ to verify that conditions in an
|
||||
* authorization policy are constructed correctly, not a reference to a specific resource.
|
||||
* To make this concrete, we can use the Backstage software catalog as an
|
||||
* example. The catalog has conditional rules around access to specific
|
||||
* _entities_ in the catalog. The _type_ of resource is captured here as
|
||||
* `resourceType`, a string identifier (`catalog-entity` in this example) that
|
||||
* can be provided with permission definitions. This is merely a _type_ to
|
||||
* verify that conditions in an authorization policy are constructed correctly,
|
||||
* not a reference to a specific resource.
|
||||
*
|
||||
* The `rules` parameter is an array of {@link PermissionRule}s that introduce conditional
|
||||
* filtering logic for resources; for the catalog, these are things like `isEntityOwner` or
|
||||
* `hasAnnotation`. Rules describe how to filter a list of resources, and the `conditions` returned
|
||||
* allow these rules to be applied with specific parameters (such as 'group:default/team-a', or
|
||||
* The `rules` parameter is an array of {@link PermissionRule}s that introduce
|
||||
* conditional filtering logic for resources; for the catalog, these are things
|
||||
* like `isEntityOwner` or `hasAnnotation`. Rules describe how to filter a list
|
||||
* of resources, and the `conditions` returned allow these rules to be applied
|
||||
* with specific parameters (such as 'group:default/team-a', or
|
||||
* 'backstage.io/edit-url').
|
||||
*
|
||||
* The `getResource` argument should load a resource by reference. For the catalog, this is an
|
||||
* {@link @backstage/catalog-model#EntityRef}. For other plugins, this can be any serialized format.
|
||||
* This is used to construct the `createPermissionIntegrationRouter`, a function to add an
|
||||
* authorization route to your backend plugin. This route will be called by the `permission-backend`
|
||||
* when authorization conditions relating to this plugin need to be evaluated.
|
||||
* The `getResources` argument should load resources based on a reference
|
||||
* identifier. For the catalog, this is an
|
||||
* {@link @backstage/catalog-model#EntityRef}. For other plugins, this can be
|
||||
* any serialized format. This is used to construct the
|
||||
* `createPermissionIntegrationRouter`, a function to add an authorization route
|
||||
* to your backend plugin. This function will be called by the
|
||||
* `permission-backend` when authorization conditions relating to this plugin
|
||||
* need to be evaluated.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const createPermissionIntegrationRouter = <TResource>(options: {
|
||||
resourceType: string;
|
||||
rules: PermissionRule<TResource, any>[];
|
||||
getResource: (resourceRef: string) => Promise<TResource | undefined>;
|
||||
}): Router => {
|
||||
const { resourceType, rules, getResource } = options;
|
||||
getResources: (
|
||||
resourceRefs: string[],
|
||||
) => Promise<Array<TResource | undefined>>;
|
||||
}): express.Router => {
|
||||
const { resourceType, rules, getResources } = options;
|
||||
const router = Router();
|
||||
|
||||
const getRule = createGetRule(rules);
|
||||
|
||||
const assertValidResourceTypes = (
|
||||
requests: ApplyConditionsRequestEntry[],
|
||||
) => {
|
||||
const invalidResourceTypes = requests
|
||||
.filter(request => request.resourceType !== resourceType)
|
||||
.map(request => request.resourceType);
|
||||
|
||||
if (invalidResourceTypes.length) {
|
||||
throw new InputError(
|
||||
`Unexpected resource types: ${invalidResourceTypes.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
router.use(express.json());
|
||||
|
||||
router.post(
|
||||
'/.well-known/backstage/permissions/apply-conditions',
|
||||
express.json(),
|
||||
async (
|
||||
req,
|
||||
res: Response<
|
||||
| {
|
||||
result: Omit<AuthorizeResult, AuthorizeResult.CONDITIONAL>;
|
||||
}
|
||||
| string
|
||||
>,
|
||||
) => {
|
||||
async (req, res: Response<ApplyConditionsResponse | string>) => {
|
||||
const parseResult = applyConditionsRequestSchema.safeParse(req.body);
|
||||
|
||||
if (!parseResult.success) {
|
||||
return res.status(400).send(`Invalid request body.`);
|
||||
throw new InputError(parseResult.error.toString());
|
||||
}
|
||||
|
||||
const { data: body } = parseResult;
|
||||
const body = parseResult.data;
|
||||
|
||||
if (body.resourceType !== resourceType) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(`Unexpected resource type: ${body.resourceType}.`);
|
||||
}
|
||||
assertValidResourceTypes(body.items);
|
||||
|
||||
const resource = await getResource(body.resourceRef);
|
||||
const resourceRefs = Array.from(
|
||||
new Set(body.items.map(({ resourceRef }) => resourceRef)),
|
||||
);
|
||||
const resourceArray = await getResources(resourceRefs);
|
||||
const resources = resourceRefs.reduce((acc, resourceRef, index) => {
|
||||
acc[resourceRef] = resourceArray[index];
|
||||
|
||||
if (!resource) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(`Resource for ref ${body.resourceRef} not found.`);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, TResource | undefined>);
|
||||
|
||||
return res.status(200).json({
|
||||
result: applyConditions(body.conditions, resource, getRule)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
items: body.items.map(request => ({
|
||||
id: request.id,
|
||||
result: applyConditions(
|
||||
request.conditions,
|
||||
resources[request.resourceRef],
|
||||
getRule,
|
||||
)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.use(errorHandler());
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
export type {
|
||||
ConditionalPolicyDecision,
|
||||
DefinitivePolicyDecision,
|
||||
PermissionPolicy,
|
||||
PolicyAuthorizeRequest,
|
||||
PolicyDecision,
|
||||
|
||||
@@ -35,6 +35,19 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-backend';
|
||||
*/
|
||||
export type PolicyAuthorizeRequest = Omit<AuthorizeRequest, 'resourceRef'>;
|
||||
|
||||
/**
|
||||
* A definitive result to an authorization request, returned by the {@link PermissionPolicy}.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This indicates that the policy unconditionally allows (or denies) the request.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type DefinitivePolicyDecision = {
|
||||
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conditional result to an authorization request, returned by the {@link PermissionPolicy}.
|
||||
*
|
||||
@@ -61,7 +74,7 @@ export type ConditionalPolicyDecision = {
|
||||
* @public
|
||||
*/
|
||||
export type PolicyDecision =
|
||||
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
|
||||
| DefinitivePolicyDecision
|
||||
| ConditionalPolicyDecision;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user