Add permission-common package

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-11-10 11:58:05 -07:00
committed by Mike Lewis
parent 6f8e995145
commit 7bba7a8998
12 changed files with 708 additions and 0 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/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).
+116
View File
@@ -0,0 +1,116 @@
## API Report File for "@backstage/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;
};
// @public
export enum AuthorizeResult {
ALLOW = 'ALLOW',
CONDITIONAL = 'CONDITIONAL',
DENY = 'DENY',
}
// @public
export type DiscoveryApi = {
getBaseUrl(pluginId: string): Promise<string>;
};
// @public
export class Permission {
constructor(
name: string,
attributes: PermissionAttributes,
resourceType?: string | undefined,
);
// (undocumented)
readonly attributes: PermissionAttributes;
// (undocumented)
static create({ name, attributes, resourceType }: PermissionJSON): Permission;
// (undocumented)
is(permission: Permission): boolean;
// (undocumented)
get isCreate(): boolean;
// (undocumented)
get isDelete(): boolean;
// (undocumented)
get isRead(): boolean;
// (undocumented)
get isUpdate(): boolean;
// (undocumented)
readonly name: string;
// (undocumented)
readonly resourceType?: string | undefined;
// (undocumented)
toJSON(): PermissionJSON;
}
// @public
export enum PermissionAction {
// (undocumented)
Create = 'create',
// (undocumented)
Delete = 'delete',
// (undocumented)
Read = 'read',
// (undocumented)
Update = 'update',
}
// @public
export type PermissionAttributes = {
action?: PermissionAction;
};
// @public
export class PermissionClient {
constructor(options: { discoveryApi: DiscoveryApi });
authorize(
requests: AuthorizeRequest[],
options?: AuthorizeRequestOptions,
): Promise<AuthorizeResponse[]>;
}
// @public
export type PermissionCondition<TParams extends any[] = any> = {
rule: string;
params: TParams;
};
// @public
export type PermissionCriteria =
| {
allOf: PermissionCriteria[];
}
| {
anyOf: PermissionCriteria[];
}
| PermissionCondition<any>;
// @public
export type PermissionJSON = {
name: string;
attributes: PermissionAttributes;
resourceType?: string;
};
```
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@backstage/permission-common",
"description": "Isomorphic types and client for Backstage permissions and authorization",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/permission-common"
},
"keywords": [
"backstage",
"permissions"
],
"license": "Apache-2.0",
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli build --outputs cjs,types",
"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"
},
"devDependencies": {
"@backstage/cli": "^0.8.0",
"@types/jest": "^26.0.7",
"msw": "^0.35.0"
}
}
@@ -0,0 +1,76 @@
/*
* 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 {
PermissionAction,
PermissionAttributes,
PermissionJSON,
} from './types/permission';
/**
* 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 class Permission {
constructor(
readonly name: string,
readonly attributes: PermissionAttributes,
readonly resourceType?: string,
) {}
is(permission: Permission) {
return this.name === permission.name;
}
get isCreate() {
return this.attributes.action === PermissionAction.Create;
}
get isRead() {
return this.attributes.action === PermissionAction.Read;
}
get isUpdate() {
return this.attributes.action === PermissionAction.Update;
}
get isDelete() {
return this.attributes.action === PermissionAction.Delete;
}
toJSON(): PermissionJSON {
return {
name: this.name,
attributes: this.attributes,
resourceType: this.resourceType,
};
}
static create({
name,
attributes,
resourceType,
}: PermissionJSON): Permission {
return new Permission(name, attributes, resourceType);
}
}
@@ -0,0 +1,129 @@
/*
* 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 { AuthorizeResult, Identified, AuthorizeRequestJSON } from './types/api';
import { DiscoveryApi } from './types/discovery';
import { Permission } from './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 });
const mockPermission = Permission.create({
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<AuthorizeRequestJSON>) => ({
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 invalid responses', 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);
});
});
});
@@ -0,0 +1,124 @@
/*
* 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 {
AuthorizeResult,
AuthorizeRequest,
AuthorizeResponse,
AuthorizeRequestJSON,
Identified,
} from './types/api';
import { DiscoveryApi } from './types/discovery';
/**
* 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 discoveryApi: DiscoveryApi;
constructor(options: { discoveryApi: DiscoveryApi }) {
this.discoveryApi = options.discoveryApi;
}
/**
* 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[]> {
const identifiedRequests: Identified<AuthorizeRequestJSON>[] = requests.map(
request => ({
id: uuid.v4(),
permission: request.permission.toJSON(),
resourceRef: request.resourceRef,
}),
);
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<AuthorizeRequestJSON>[],
json: any,
): asserts json is Identified<AuthorizeResponse>[] {
const responses = Array.isArray(json) ? json : [];
const authorizedResponses: Identified<AuthorizeResponse>[] =
responses.filter(
(r: any): r is Identified<AuthorizeResponse> =>
typeof r === 'object' &&
typeof r.id === 'string' &&
r.result in AuthorizeResult,
);
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 './Permission';
export * from './PermissionClient';
@@ -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';
import { PermissionJSON } from './permission';
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;
};
export type AuthorizeRequestJSON = {
permission: PermissionJSON;
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 any[] = any> = {
rule: string;
params: TParams;
};
/**
* Composes several {@link PermissionCondition}s as criteria with a nested AND/OR structure.
* @public
*/
export type PermissionCriteria =
| { allOf: PermissionCriteria[] }
| { anyOf: PermissionCriteria[] }
| PermissionCondition<any>;
/**
* An authorization response from {@link PermissionClient#authorize}.
* @public
*/
export type AuthorizeResponse =
| { result: AuthorizeResult.ALLOW | AuthorizeResult.DENY }
| {
result: AuthorizeResult.CONDITIONAL;
conditions: PermissionCriteria;
};
@@ -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 type {
AuthorizeRequest,
AuthorizeResponse,
AuthorizeResult,
PermissionCondition,
PermissionCriteria,
} from './api';
export type { DiscoveryApi } from './discovery';
export { PermissionAction } from './permission';
export type { PermissionAttributes, PermissionJSON } from './permission';
@@ -0,0 +1,45 @@
/*
* 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 basic operation being performed in relation to this permission, expressed as a CRUD action.
* @public
*/
export enum PermissionAction {
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
/**
* 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?: PermissionAction;
};
/**
* JSON serializable representation of a {@link Permission}.
* @public
*/
export type PermissionJSON = {
name: string;
attributes: PermissionAttributes;
resourceType?: string;
};