Merge pull request #7942 from backstage/permission-common

Add permission-common package
This commit is contained in:
Ben Lambert
2021-11-17 16:48:55 +01:00
committed by GitHub
15 changed files with 777 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+7
View File
@@ -0,0 +1,7 @@
# @backstage/plugin-permission-common
> NOTE: THIS PACKAGE IS EXPERIMENTAL, HERE BE DRAGONS
Isomorphic types and client for Backstage permissions and authorization. For
more information, see the [authorization
PRFC](https://github.com/backstage/backstage/pull/7761).
+95
View File
@@ -0,0 +1,95 @@
## API Report File for "@backstage/plugin-permission-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
// @public
export type AuthorizeRequest = {
permission: Permission;
resourceRef?: string;
};
// @public
export type AuthorizeRequestOptions = {
token?: string;
};
// @public
export type AuthorizeResponse =
| {
result: AuthorizeResult.ALLOW | AuthorizeResult.DENY;
}
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria<PermissionCondition>;
};
// @public
export enum AuthorizeResult {
ALLOW = 'ALLOW',
CONDITIONAL = 'CONDITIONAL',
DENY = 'DENY',
}
// @public
export type DiscoveryApi = {
getBaseUrl(pluginId: string): Promise<string>;
};
// @public
export type Identified<T> = T & {
id: string;
};
// @public
export function isCreatePermission(permission: Permission): boolean;
// @public
export function isDeletePermission(permission: Permission): boolean;
// @public
export function isReadPermission(permission: Permission): boolean;
// @public
export function isUpdatePermission(permission: Permission): boolean;
// @public
export type Permission = {
name: string;
attributes: PermissionAttributes;
resourceType?: string;
};
// @public
export type PermissionAttributes = {
action?: 'create' | 'read' | 'update' | 'delete';
};
// @public
export class PermissionClient {
constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean });
authorize(
requests: AuthorizeRequest[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
}
// @public
export type PermissionCondition<TParams extends unknown[] = unknown[]> = {
rule: string;
params: TParams;
};
// @public
export type PermissionCriteria<TQuery> =
| {
allOf: PermissionCriteria<TQuery>[];
}
| {
anyOf: PermissionCriteria<TQuery>[];
}
| {
not: PermissionCriteria<TQuery>;
}
| TQuery;
```
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 interface Config {
/** Configuration options for Backstage permissions and authorization */
permission?: {
/**
* Whether authorization is enabled in Backstage. Defaults to false, which means authorization
* requests will be automatically allowed without invoking the authorization policy.
* @visibility frontend
*/
enabled?: boolean;
};
}
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-permission-common",
"description": "Isomorphic types and client for Backstage permissions and authorization",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/permission-common"
},
"keywords": [
"backstage",
"permissions"
],
"license": "Apache-2.0",
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts",
"scripts": {
"build": "backstage-cli build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"bugs": {
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@backstage/errors": "^0.1.2",
"cross-fetch": "^3.0.6",
"uuid": "^8.0.0",
"zod": "^3.11.6"
},
"devDependencies": {
"@backstage/cli": "^0.8.0",
"@types/jest": "^26.0.7",
"msw": "^0.35.0"
}
}
@@ -0,0 +1,167 @@
/*
* 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 { RestContext, rest } from 'msw';
import { setupServer } from 'msw/node';
import { PermissionClient } from './PermissionClient';
import { AuthorizeRequest, AuthorizeResult, Identified } from './types/api';
import { DiscoveryApi } from './types/discovery';
import { Permission } from './types/permission';
const server = setupServer();
const token = 'fake-token';
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi: DiscoveryApi = {
async getBaseUrl() {
return mockBaseUrl;
},
};
const client: PermissionClient = new PermissionClient({
discoveryApi,
enabled: true,
});
const mockPermission: Permission = {
name: 'test.permission',
attributes: {},
resourceType: 'test-resource',
};
const mockAuthorizeRequest = {
permission: mockPermission,
resourceRef: 'foo',
};
describe('PermissionClient', () => {
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());
describe('authorize', () => {
const mockAuthorizeHandler = jest.fn((req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
id: a.id,
result: AuthorizeResult.ALLOW,
}));
return res(json(responses));
});
beforeEach(() => {
server.use(rest.post(`${mockBaseUrl}/authorize`, mockAuthorizeHandler));
});
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch entities from correct endpoint', async () => {
await client.authorize([mockAuthorizeRequest]);
expect(mockAuthorizeHandler).toHaveBeenCalled();
});
it('should include a request body', async () => {
await client.authorize([mockAuthorizeRequest]);
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.body[0]).toEqual(
expect.objectContaining({
permission: mockPermission,
resourceRef: 'foo',
}),
);
});
it('should return the response from the fetch request', async () => {
const response = await client.authorize([mockAuthorizeRequest]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
});
it('should not include authorization headers if no token is supplied', async () => {
await client.authorize([mockAuthorizeRequest]);
const request = mockAuthorizeHandler.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.authorize([mockAuthorizeRequest], { token });
const request = mockAuthorizeHandler.mock.calls[0][0];
expect(request.headers.get('authorization')).toEqual('Bearer fake-token');
});
it('should forward response errors', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(_req, res, { status }: RestContext) => {
return res(status(401));
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
).rejects.toThrowError(/request failed with 401/i);
});
it('should reject responses with missing ids', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(_req, res, { json }: RestContext) => {
return res(json([{ id: 'wrong-id', result: AuthorizeResult.ALLOW }]));
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
).rejects.toThrowError(/Unexpected authorization response/i);
});
it('should reject invalid responses', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
id: a.id,
outcome: AuthorizeResult.ALLOW,
}));
return res(json(responses));
},
);
await expect(
client.authorize([mockAuthorizeRequest], { token }),
).rejects.toThrowError(/invalid input/i);
});
it('should allow all when authorization is not enabled', async () => {
mockAuthorizeHandler.mockImplementationOnce(
(req, res, { json }: RestContext) => {
const responses = req.body.map((a: Identified<AuthorizeRequest>) => ({
id: a.id,
outcome: AuthorizeResult.DENY,
}));
return res(json(responses));
},
);
const disabled = new PermissionClient({ discoveryApi, enabled: false });
const response = await disabled.authorize([mockAuthorizeRequest]);
expect(response[0]).toEqual(
expect.objectContaining({ result: AuthorizeResult.ALLOW }),
);
expect(mockAuthorizeHandler).not.toBeCalled();
});
});
});
@@ -0,0 +1,159 @@
/*
* 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 { ResponseError } from '@backstage/errors';
import fetch from 'cross-fetch';
import * as uuid from 'uuid';
import { z } from 'zod';
import {
AuthorizeResult,
AuthorizeRequest,
AuthorizeResponse,
Identified,
PermissionCriteria,
PermissionCondition,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
const permissionCriteriaSchema: z.ZodSchema<
PermissionCriteria<PermissionCondition>
> = z.lazy(() =>
z
.object({
rule: z.string(),
params: z.array(z.unknown()),
})
.or(z.object({ anyOf: z.array(permissionCriteriaSchema) }))
.or(z.object({ allOf: z.array(permissionCriteriaSchema) }))
.or(z.object({ not: permissionCriteriaSchema })),
);
const responseSchema = z.array(
z
.object({
id: z.string(),
result: z
.literal(AuthorizeResult.ALLOW)
.or(z.literal(AuthorizeResult.DENY)),
})
.or(
z.object({
id: z.string(),
result: z.literal(AuthorizeResult.CONDITIONAL),
conditions: permissionCriteriaSchema,
}),
),
);
/**
* Options for authorization requests; currently only an optional auth token.
* @public
*/
export type AuthorizeRequestOptions = {
token?: string;
};
/**
* An isomorphic client for requesting authorization for Backstage permissions.
* @public
*/
export class PermissionClient {
private readonly enabled: boolean;
private readonly discoveryApi: DiscoveryApi;
constructor(options: { discoveryApi: DiscoveryApi; enabled?: boolean }) {
this.discoveryApi = options.discoveryApi;
this.enabled = options.enabled ?? false;
}
/**
* Request authorization from the permission-backend for the given set of permissions.
*
* Authorization requests check that a given Backstage user can perform a protected operation,
* potentially for a specific resource (such as a catalog entity). The Backstage identity token
* should be included in the `options` if available.
*
* Permissions can be imported from plugins exposing them, such as `catalogEntityReadPermission`.
*
* The response will be either ALLOW or DENY when either the permission has no resourceType, or a
* resourceRef is provided in the request. For permissions with a resourceType, CONDITIONAL may be
* returned if no resourceRef is provided in the request. Conditional responses are intended only
* for backends which have access to the data source for permissioned resources, so that filters
* can be applied when loading collections of resources.
* @public
*/
async authorize(
requests: AuthorizeRequest[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]> {
// TODO(permissions): it would be great to provide some kind of typing guarantee that
// conditional responses will only ever be returned for requests containing a resourceType
// but no resourceRef. That way clients who aren't prepared to handle filtering according
// to conditions can be guaranteed that they won't unexpectedly get a CONDITIONAL response.
if (!this.enabled) {
return requests.map(_ => ({ result: AuthorizeResult.ALLOW }));
}
const identifiedRequests: Identified<AuthorizeRequest>[] = requests.map(
request => ({
id: uuid.v4(),
...request,
}),
);
const permissionApi = await this.discoveryApi.getBaseUrl('permission');
const response = await fetch(`${permissionApi}/authorize`, {
method: 'POST',
body: JSON.stringify(identifiedRequests),
headers: {
...this.getAuthorizationHeader(options?.token),
'content-type': 'application/json',
},
});
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
const identifiedResponses = await response.json();
this.assertValidResponses(identifiedRequests, identifiedResponses);
const responsesById = identifiedResponses.reduce((acc, r) => {
acc[r.id] = r;
return acc;
}, {} as Record<string, Identified<AuthorizeResponse>>);
return identifiedRequests.map(request => responsesById[request.id]);
}
private getAuthorizationHeader(token?: string): Record<string, string> {
return token ? { Authorization: `Bearer ${token}` } : {};
}
private assertValidResponses(
requests: Identified<AuthorizeRequest>[],
json: any,
): asserts json is Identified<AuthorizeResponse>[] {
const authorizedResponses = responseSchema.parse(json);
const responseIds = authorizedResponses.map(r => r.id);
const hasAllRequestIds = requests.every(r => responseIds.includes(r.id));
if (!hasAllRequestIds) {
throw new Error(
'Unexpected authorization response from permission-backend',
);
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* 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.
*/
/**
* Isomorphic types and client for Backstage permissions and authorization
*
* @packageDocumentation
*/
export * from './types';
export * from './permissions';
export * from './PermissionClient';
@@ -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 * from './util';
@@ -0,0 +1,49 @@
/*
* 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 { Permission } from '../types';
/**
* Check if a given permission is related to a create action.
* @public
*/
export function isCreatePermission(permission: Permission) {
return permission.attributes.action === 'create';
}
/**
* Check if a given permission is related to a read action.
* @public
*/
export function isReadPermission(permission: Permission) {
return permission.attributes.action === 'read';
}
/**
* Check if a given permission is related to an update action.
* @public
*/
export function isUpdatePermission(permission: Permission) {
return permission.attributes.action === 'update';
}
/**
* Check if a given permission is related to a delete action.
* @public
*/
export function isDeletePermission(permission: Permission) {
return permission.attributes.action === 'delete';
}
@@ -0,0 +1,86 @@
/*
* 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 { Permission } from './permission';
/**
* A request with a UUID identifier, so that batched responses can be matched up with the original
* requests.
* @public
*/
export type Identified<T> = T & { id: string };
/**
* The result of an authorization request.
* @public
*/
export enum AuthorizeResult {
/**
* The authorization request is denied.
*/
DENY = 'DENY',
/**
* The authorization request is allowed.
*/
ALLOW = 'ALLOW',
/**
* The authorization request is allowed if the provided conditions are met.
*/
CONDITIONAL = 'CONDITIONAL',
}
/**
* An authorization request for {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeRequest = {
permission: Permission;
resourceRef?: string;
};
/**
* A condition returned with a CONDITIONAL authorization response.
*
* Conditions are a reference to a rule defined by a plugin, and parameters to apply the rule. For
* example, a rule might be `isOwner` from the catalog-backend, and params may be a list of entity
* claims from a identity token.
* @public
*/
export type PermissionCondition<TParams extends unknown[] = unknown[]> = {
rule: string;
params: TParams;
};
/**
* Composes several {@link PermissionCondition}s as criteria with a nested AND/OR structure.
* @public
*/
export type PermissionCriteria<TQuery> =
| { allOf: PermissionCriteria<TQuery>[] }
| { anyOf: PermissionCriteria<TQuery>[] }
| { not: PermissionCriteria<TQuery> }
| TQuery;
/**
* An authorization response from {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeResponse =
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria<PermissionCondition>;
};
@@ -0,0 +1,24 @@
/*
* 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.
*/
/**
* This is a copy of the core DiscoveryApi, to avoid importing core.
*
* @public
*/
export type DiscoveryApi = {
getBaseUrl(pluginId: string): Promise<string>;
};
@@ -0,0 +1,26 @@
/*
* 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 { AuthorizeResult } from './api';
export type {
AuthorizeRequest,
AuthorizeResponse,
Identified,
PermissionCondition,
PermissionCriteria,
} from './api';
export type { DiscoveryApi } from './discovery';
export type { PermissionAttributes, Permission } from './permission';
@@ -0,0 +1,41 @@
/*
* 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.
*/
/**
* The attributes related to a given permission; these should be generic and widely applicable to
* all permissions in the system.
* @public
*/
export type PermissionAttributes = {
action?: 'create' | 'read' | 'update' | 'delete';
};
/**
* A permission that can be checked through authorization.
*
* Permissions are the "what" part of authorization, the action to be performed. This may be reading
* an entity from the catalog, executing a software template, or any other action a plugin author
* may wish to protect.
*
* To evaluate authorization, a permission is paired with a Backstage identity (the "who") and
* evaluated using an authorization policy.
* @public
*/
export type Permission = {
name: string;
attributes: PermissionAttributes;
resourceType?: string;
};
+1 -1
View File
@@ -29630,7 +29630,7 @@ zip-stream@^4.1.0:
compress-commons "^4.1.0"
readable-stream "^3.6.0"
zod@^3.9.5:
zod@^3.11.6, zod@^3.9.5:
version "3.11.6"
resolved "https://registry.npmjs.org/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483"
integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==