Merge pull request #16333 from backstage/pf/optional-get-resources

createPermissionIntegrationRouter - optional getResources
This commit is contained in:
Vincenzo Scamporlino
2023-02-27 16:28:02 +01:00
committed by GitHub
10 changed files with 222 additions and 79 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': patch
---
Changed the `createPermissionIntegrationRouter` API to allow `getResources`, `resourceType` and `rules` to be optional
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/errors': patch
---
Added `NotImplementedError`, which can be used when the server does not recognize the request method and is incapable of supporting it for any resource.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-app-api': patch
---
Add support for `NotImplementedError`, properly returning 501 as status code.
@@ -38,6 +38,7 @@ import {
NotModifiedError,
serializeError,
} from '@backstage/errors';
import { NotImplementedError } from '@backstage/errors';
/**
* Options used to create a {@link MiddlewareFactory}.
@@ -257,6 +258,8 @@ function getStatusCode(error: Error): number {
return 404;
case ConflictError.name:
return 409;
case NotImplementedError.name:
return 501;
default:
break;
}
+3
View File
@@ -84,6 +84,9 @@ export class NotAllowedError extends CustomErrorBase {}
// @public
export class NotFoundError extends CustomErrorBase {}
// @public
export class NotImplementedError extends CustomErrorBase {}
// @public
export class NotModifiedError extends CustomErrorBase {}
+7
View File
@@ -74,6 +74,13 @@ export class ConflictError extends CustomErrorBase {}
*/
export class NotModifiedError extends CustomErrorBase {}
/**
* The server does not support the functionality required to fulfill the request.
*
* @public
*/
export class NotImplementedError extends CustomErrorBase {}
/**
* An error that forwards an underlying cause with additional context in the message.
*
+1
View File
@@ -24,6 +24,7 @@ export {
NotAllowedError,
NotFoundError,
NotModifiedError,
NotImplementedError,
} from './common';
export { CustomErrorBase } from './CustomErrorBase';
export { ResponseError } from './ResponseError';
+24 -11
View File
@@ -112,20 +112,33 @@ export const createConditionTransformer: <
) => ConditionTransformer<TQuery>;
// @public
export const createPermissionIntegrationRouter: <
export function createPermissionIntegrationRouter<
TResourceType extends string,
TResource,
>(options: {
>(
options: CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
>,
): express.Router;
// @public
export function createPermissionIntegrationRouter(options: {
permissions: Array<Permission>;
}): express.Router;
// @public
export type CreatePermissionIntegrationRouterResourceOptions<
TResourceType extends string,
TResource,
> = {
resourceType: TResourceType;
permissions?: Permission[] | undefined;
rules: PermissionRule<
TResource,
any,
NoInfer<TResourceType>,
PermissionRuleParams
>[];
getResources: (resourceRefs: string[]) => Promise<(TResource | undefined)[]>;
}) => express.Router;
permissions?: Array<Permission>;
rules: PermissionRule<TResource, any, NoInfer<TResourceType>>[];
getResources?: (
resourceRefs: string[],
) => Promise<Array<TResource | undefined>>;
};
// @public
export const createPermissionRule: <
@@ -19,18 +19,15 @@ import {
createPermission,
Permission,
} from '@backstage/plugin-permission-common';
import express, { Express, Router } from 'express';
import express from 'express';
import request, { Response } from 'supertest';
import { z } from 'zod';
import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter';
import {
createPermissionIntegrationRouter,
CreatePermissionIntegrationRouterResourceOptions,
} from './createPermissionIntegrationRouter';
import { createPermissionRule } from './createPermissionRule';
const mockGetResources: jest.MockedFunction<
Parameters<typeof createPermissionIntegrationRouter>[0]['getResources']
> = jest.fn(async resourceRefs =>
resourceRefs.map(resourceRef => ({ id: resourceRef })),
);
const testPermission: Permission = createPermission({
name: 'test.permission',
attributes: {},
@@ -56,29 +53,35 @@ const testRule2 = createPermissionRule({
toQuery: () => ({}),
});
const defaultMockedGetResources: CreatePermissionIntegrationRouterResourceOptions<
string,
{ id: string }
>['getResources'] = jest.fn(async resourceRefs =>
resourceRefs.map(resourceRef => ({ id: resourceRef })),
);
const createApp = (
mockedGetResources:
| typeof defaultMockedGetResources
| null = defaultMockedGetResources,
) => {
const router = mockedGetResources
? createPermissionIntegrationRouter({
resourceType: 'test-resource',
permissions: [testPermission],
getResources: mockedGetResources,
rules: [testRule1, testRule2],
})
: createPermissionIntegrationRouter({ permissions: [testPermission] });
return express().use(router);
};
describe('createPermissionIntegrationRouter', () => {
let app: Express;
let router: Router;
beforeAll(() => {
router = createPermissionIntegrationRouter({
resourceType: 'test-resource',
permissions: [testPermission],
getResources: mockGetResources,
rules: [testRule1, testRule2],
});
app = express().use(router);
});
afterEach(() => {
jest.clearAllMocks();
});
it('works', async () => {
expect(router).toBeDefined();
});
describe('POST /.well-known/backstage/permissions/apply-conditions', () => {
it.each([
{
@@ -150,7 +153,7 @@ describe('createPermissionIntegrationRouter', () => {
],
},
])('returns 200/ALLOW when criteria match (case %#)', async conditions => {
const response = await request(app)
const response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -238,7 +241,7 @@ describe('createPermissionIntegrationRouter', () => {
])(
'returns 200/DENY when criteria do not match (case %#)',
async conditions => {
const response = await request(app)
const response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -262,7 +265,7 @@ describe('createPermissionIntegrationRouter', () => {
let response: Response;
beforeEach(async () => {
response = await request(app)
response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -353,7 +356,7 @@ describe('createPermissionIntegrationRouter', () => {
});
it('calls getResources for all required resources at once', () => {
expect(mockGetResources).toHaveBeenCalledWith([
expect(defaultMockedGetResources).toHaveBeenCalledWith([
'default:test/resource-1',
'default:test/resource-2',
'default:test/resource-3',
@@ -363,7 +366,7 @@ describe('createPermissionIntegrationRouter', () => {
});
it('returns 400 when called with incorrect resource type', async () => {
const response = await request(app)
const response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -413,11 +416,14 @@ describe('createPermissionIntegrationRouter', () => {
});
it('returns 200/DENY when resource is not found', async () => {
mockGetResources.mockImplementationOnce(async resourceRefs =>
const mockedGetResources: CreatePermissionIntegrationRouterResourceOptions<
string,
{ id: string }
>['getResources'] = jest.fn(async resourceRefs =>
resourceRefs.map(() => undefined),
);
const response = await request(app)
const response = await request(createApp(mockedGetResources))
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -446,7 +452,10 @@ describe('createPermissionIntegrationRouter', () => {
});
it('interleaves responses for present and missing resources', async () => {
mockGetResources.mockImplementationOnce(async resourceRefs =>
const mockedGetResources: CreatePermissionIntegrationRouterResourceOptions<
string,
{ id: string }
>['getResources'] = jest.fn(async resourceRefs =>
resourceRefs.map(resourceRef =>
resourceRef === 'default:test/missing-resource'
? undefined
@@ -454,7 +463,7 @@ describe('createPermissionIntegrationRouter', () => {
),
);
const response = await request(app)
const response = await request(createApp(mockedGetResources))
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [
@@ -548,18 +557,31 @@ describe('createPermissionIntegrationRouter', () => {
],
},
])(`returns 400 for invalid input %#`, async input => {
const response = await request(app)
const response = await request(createApp())
.post('/.well-known/backstage/permissions/apply-conditions')
.send(input);
expect(response.status).toEqual(400);
expect(response.error && response.error.text).toMatch(/invalid/i);
});
it('returns 501 with no getResources implementation', async () => {
const response = await request(createApp(null))
.post('/.well-known/backstage/permissions/apply-conditions')
.send({
items: [],
});
expect(response.status).toEqual(501);
expect(response.body.error.message).toEqual(
`This plugin does not expose any permission rule or can't evaluate conditional decisions`,
);
});
});
describe('GET /.well-known/backstage/permissions/metadata', () => {
it('returns a list of permissions and rules used by a given backend', async () => {
const response = await request(app).get(
const response = await request(createApp()).get(
'/.well-known/backstage/permissions/metadata',
);
@@ -36,6 +36,7 @@ import {
isNotCriteria,
isOrCriteria,
} from './util';
import { NotImplementedError } from '@backstage/errors';
const permissionCriteriaSchema: z.ZodSchema<
PermissionCriteria<PermissionCondition>
@@ -159,6 +160,28 @@ const applyConditions = <TResourceType extends string, TResource>(
return rule.apply(resource, criteria.params ?? {});
};
/**
* Options for creating a permission integration router specific
* for a particular resource type.
*
* @public
*/
export type CreatePermissionIntegrationRouterResourceOptions<
TResourceType extends string,
TResource,
> = {
resourceType: TResourceType;
permissions?: Array<Permission>;
// Do not infer value of TResourceType from supplied rules.
// instead only consider the resourceType parameter, and
// consider any rules whose resource type does not match
// to be an error.
rules: PermissionRule<TResource, any, NoInfer<TResourceType>>[];
getResources?: (
resourceRefs: string[],
) => Promise<Array<TResource | undefined>>;
};
/**
* Create an express Router which provides an authorization route to allow
* integration between the permission backend and other Backstage backend
@@ -166,6 +189,9 @@ const applyConditions = <TResourceType extends string, TResource>(
* their resources should add the router created by this function to their
* express app inside their `createRouter` implementation.
*
* In case the `permissions` option is provided, the router also
* provides a route that exposes permissions and routes of a plugin.
*
* @remarks
*
* To make this concrete, we can use the Backstage software catalog as an
@@ -194,25 +220,44 @@ const applyConditions = <TResourceType extends string, TResource>(
*
* @public
*/
export const createPermissionIntegrationRouter = <
export function createPermissionIntegrationRouter<
TResourceType extends string,
TResource,
>(options: {
resourceType: TResourceType;
permissions?: Array<Permission>;
// Do not infer value of TResourceType from supplied rules.
// instead only consider the resourceType parameter, and
// consider any rules whose resource type does not match
// to be an error.
rules: PermissionRule<TResource, any, NoInfer<TResourceType>>[];
getResources: (
resourceRefs: string[],
) => Promise<Array<TResource | undefined>>;
}): express.Router => {
const { resourceType, permissions, rules, getResources } = options;
>(
options: CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
>,
): express.Router;
/**
*
* Create an express Router which provides a route that exposes
* permissions and routes of a plugin.
* @public
*/
export function createPermissionIntegrationRouter(options: {
permissions: Array<Permission>;
}): express.Router;
/**
* @public
*/
export function createPermissionIntegrationRouter<
TResourceType extends string,
TResource,
>(
options:
| { permissions: Array<Permission> }
| CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
>,
): express.Router {
const router = Router();
router.use(express.json());
const { permissions = [], rules = [] } = { rules: [], ...options };
router.get('/.well-known/backstage/permissions/metadata', (_, res) => {
const serializedRules: MetadataResponseSerializedRule[] = rules.map(
rule => ({
@@ -231,25 +276,35 @@ export const createPermissionIntegrationRouter = <
return res.json(responseJson);
});
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.post(
'/.well-known/backstage/permissions/apply-conditions',
async (req, res: Response<ApplyConditionsResponse | string>) => {
if (
!isCreatePermissionIntegrationRouterResourceOptions(options) ||
options.getResources === undefined
) {
throw new NotImplementedError(
`This plugin does not expose any permission rule or can't evaluate conditional decisions`,
);
}
const { resourceType, getResources } = options;
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(', ')}.`,
);
}
};
const parseResult = applyConditionsRequestSchema.safeParse(req.body);
if (!parseResult.success) {
@@ -288,4 +343,28 @@ export const createPermissionIntegrationRouter = <
router.use(errorHandler());
return router;
};
}
function isCreatePermissionIntegrationRouterResourceOptions<
TResourceType extends string,
TResource,
>(
options:
| { permissions: Array<Permission> }
| CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
>,
): options is CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
> {
return (
(
options as CreatePermissionIntegrationRouterResourceOptions<
TResourceType,
TResource
>
).resourceType !== undefined
);
}