feat: Change permission interface to store identifiers

This commit is contained in:
Joachim Van Herwegen 2022-06-29 10:54:04 +02:00
parent b5d5071403
commit 23f0b37c28
11 changed files with 84 additions and 45 deletions

View File

@ -1,4 +1,5 @@
[ [
"AccessMap",
"Adapter", "Adapter",
"BaseHttpError", "BaseHttpError",
"BasicConditions", "BasicConditions",
@ -8,15 +9,16 @@
"Dict", "Dict",
"Error", "Error",
"EventEmitter", "EventEmitter",
"HashMap",
"HttpErrorOptions", "HttpErrorOptions",
"HttpResponse", "HttpResponse",
"IdentifierMap",
"IdentifierSetMultiMap",
"NodeJS.Dict", "NodeJS.Dict",
"Permission", "PermissionMap",
"PermissionSet",
"Promise", "Promise",
"Readonly", "Readonly",
"RegExp", "RegExp",
"Set",
"Settings", "Settings",
"Template", "Template",
"TemplateEngine", "TemplateEngine",

View File

@ -1,7 +1,6 @@
import type { CredentialSet } from '../authentication/Credentials'; import type { CredentialSet } from '../authentication/Credentials';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { AsyncHandler } from '../util/handlers/AsyncHandler'; import { AsyncHandler } from '../util/handlers/AsyncHandler';
import type { AccessMode, PermissionSet } from './permissions/Permissions'; import type { AccessMap, PermissionMap } from './permissions/Permissions';
export interface AuthorizerInput { export interface AuthorizerInput {
/** /**
@ -9,17 +8,13 @@ export interface AuthorizerInput {
*/ */
credentials: CredentialSet; credentials: CredentialSet;
/** /**
* Identifier of the resource that will be read/modified. * Requested access modes per resource.
*/ */
identifier: ResourceIdentifier; requestedModes: AccessMap;
/** /**
* Modes that are requested on the resource. * Actual permissions available per resource and per credential group.
*/ */
modes: Set<AccessMode>; availablePermissions: PermissionMap;
/**
* Permissions that are available for the request.
*/
permissionSet: PermissionSet;
} }
/** /**

View File

@ -1,26 +1,22 @@
import type { CredentialSet } from '../authentication/Credentials'; import type { CredentialSet } from '../authentication/Credentials';
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
import { AsyncHandler } from '../util/handlers/AsyncHandler'; import { AsyncHandler } from '../util/handlers/AsyncHandler';
import type { AccessMode, PermissionSet } from './permissions/Permissions'; import type { AccessMap, PermissionMap } from './permissions/Permissions';
export interface PermissionReaderInput { export interface PermissionReaderInput {
/** /**
* Credentials of the entity that wants to use the resource. * Credentials of the entity requesting access to resources.
*/ */
credentials: CredentialSet; credentials: CredentialSet;
/** /**
* Identifier of the resource that will be read/modified. * For each credential, the reader will check which of the given per-resource access modes are available.
* However, non-exhaustive information about other access modes and resources can still be returned.
*/ */
identifier: ResourceIdentifier; requestedModes: AccessMap;
/**
* This is the minimum set of access modes the output needs to contain,
* allowing the handler to limit its search space to this set.
* However, non-exhaustive information about other access modes can still be returned.
*/
modes: Set<AccessMode>;
} }
/** /**
* Discovers the permissions of the given credentials on the given identifier. * Discovers the permissions of the given credentials on the given identifier.
* In case the reader finds no permission for the requested identifiers and credentials
* it can return an empty or incomplete map.
*/ */
export abstract class PermissionReader extends AsyncHandler<PermissionReaderInput, PermissionSet> {} export abstract class PermissionReader extends AsyncHandler<PermissionReaderInput, PermissionMap> {}

View File

@ -1,8 +1,8 @@
import type { Operation } from '../../http/Operation'; import type { Operation } from '../../http/Operation';
import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import { AsyncHandler } from '../../util/handlers/AsyncHandler';
import type { AccessMode } from './Permissions'; import type { AccessMap } from './Permissions';
/** /**
* Extracts all {@link AccessMode}s that are necessary to execute the given {@link Operation}. * Extracts all {@link AccessMode}s that are necessary to execute the given {@link Operation}.
*/ */
export abstract class ModesExtractor extends AsyncHandler<Operation, Set<AccessMode>> {} export abstract class ModesExtractor extends AsyncHandler<Operation, AccessMap> {}

View File

@ -1,4 +1,5 @@
import type { CredentialGroup } from '../../authentication/Credentials'; import type { CredentialGroup } from '../../authentication/Credentials';
import type { IdentifierMap, IdentifierSetMultiMap } from '../../util/map/IdentifierMap';
/** /**
* Different modes that require permission. * Different modes that require permission.
@ -11,9 +12,22 @@ export enum AccessMode {
delete = 'delete', delete = 'delete',
} }
/**
* Access modes per identifier.
*/
export type AccessMap = IdentifierSetMultiMap<AccessMode>;
/** /**
* A data interface indicating which permissions are required (based on the context). * A data interface indicating which permissions are required (based on the context).
*/ */
export type Permission = Partial<Record<AccessMode, boolean>>; export type Permission = Partial<Record<AccessMode, boolean>>;
/**
* Permission per CredentialGroup.
*/
export type PermissionSet = Partial<Record<CredentialGroup, Permission>>; export type PermissionSet = Partial<Record<CredentialGroup, Permission>>;
/**
* PermissionSet per identifier.
*/
export type PermissionMap = IdentifierMap<PermissionSet>;

View File

@ -1,4 +1,4 @@
import type { PermissionSet } from '../authorization/permissions/Permissions'; import type { PermissionMap } from '../authorization/permissions/Permissions';
import type { Conditions } from '../storage/Conditions'; import type { Conditions } from '../storage/Conditions';
import type { Representation } from './representation/Representation'; import type { Representation } from './representation/Representation';
import type { RepresentationPreferences } from './representation/RepresentationPreferences'; import type { RepresentationPreferences } from './representation/RepresentationPreferences';
@ -27,7 +27,7 @@ export interface Operation {
/** /**
* The permissions available for the current operation. * The permissions available for the current operation.
*/ */
permissionSet?: PermissionSet; availablePermissions?: PermissionMap;
/** /**
* Representation of the body and metadata headers. * Representation of the body and metadata headers.
*/ */

View File

@ -438,6 +438,7 @@ export * from './util/locking/VoidLocker';
// Util/Map // Util/Map
export * from './util/map/HashMap'; export * from './util/map/HashMap';
export * from './util/map/IdentifierMap';
export * from './util/map/SetMultiMap'; export * from './util/map/SetMultiMap';
export * from './util/map/WrappedSetMultiMap'; export * from './util/map/WrappedSetMultiMap';

View File

@ -63,15 +63,15 @@ export class AuthorizingHttpHandler extends OperationHttpHandler {
const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request); const credentials: CredentialSet = await this.credentialsExtractor.handleSafe(request);
this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`); this.logger.verbose(`Extracted credentials: ${JSON.stringify(credentials)}`);
const modes = await this.modesExtractor.handleSafe(operation); const requestedModes = await this.modesExtractor.handleSafe(operation);
this.logger.verbose(`Required modes are read: ${[ ...modes ].join(',')}`); this.logger.verbose(`Retrieved required modes: ${[ ...requestedModes ].join(',')}`);
const permissionSet = await this.permissionReader.handleSafe({ credentials, identifier: operation.target, modes }); const availablePermissions = await this.permissionReader.handleSafe({ credentials, requestedModes });
this.logger.verbose(`Available permissions are ${JSON.stringify(permissionSet)}`); this.logger.verbose(`Available permissions are ${JSON.stringify(availablePermissions)}`);
try { try {
await this.authorizer.handleSafe({ credentials, identifier: operation.target, modes, permissionSet }); await this.authorizer.handleSafe({ credentials, requestedModes, availablePermissions });
operation.permissionSet = permissionSet; operation.availablePermissions = availablePermissions;
} catch (error: unknown) { } catch (error: unknown) {
this.logger.verbose(`Authorization failed: ${(error as any).message}`); this.logger.verbose(`Authorization failed: ${(error as any).message}`);
throw error; throw error;

View File

@ -0,0 +1,28 @@
import type { ResourceIdentifier } from '../../http/representation/ResourceIdentifier';
import { HashMap } from './HashMap';
import { WrappedSetMultiMap } from './WrappedSetMultiMap';
/**
* Converts a {@link ResourceIdentifier} into a string unique to that identifier.
*/
export function identifierHashFn(identifier: ResourceIdentifier): string {
return identifier.path;
}
/**
* A specific implementation of {@link HashMap} where the key type is {@link ResourceIdentifier}.
*/
export class IdentifierMap<T> extends HashMap<ResourceIdentifier, T> {
public constructor(iterable?: Iterable<readonly [ResourceIdentifier, T]>) {
super(identifierHashFn, iterable);
}
}
/**
* A specific implementation of {@link WrappedSetMultiMap} where the key type is {@link ResourceIdentifier}.
*/
export class IdentifierSetMultiMap<T> extends WrappedSetMultiMap<ResourceIdentifier, T> {
public constructor(iterable?: Iterable<readonly [ResourceIdentifier, T | ReadonlySet<T>]>) {
super(IdentifierMap, iterable);
}
}

View File

@ -3,6 +3,7 @@ import type { CredentialsExtractor } from '../../../src/authentication/Credentia
import type { Authorizer } from '../../../src/authorization/Authorizer'; import type { Authorizer } from '../../../src/authorization/Authorizer';
import type { PermissionReader } from '../../../src/authorization/PermissionReader'; import type { PermissionReader } from '../../../src/authorization/PermissionReader';
import type { ModesExtractor } from '../../../src/authorization/permissions/ModesExtractor'; import type { ModesExtractor } from '../../../src/authorization/permissions/ModesExtractor';
import type { AccessMap, PermissionMap } from '../../../src/authorization/permissions/Permissions';
import { AccessMode } from '../../../src/authorization/permissions/Permissions'; import { AccessMode } from '../../../src/authorization/permissions/Permissions';
import type { Operation } from '../../../src/http/Operation'; import type { Operation } from '../../../src/http/Operation';
import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation'; import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
@ -11,11 +12,15 @@ import type { HttpRequest } from '../../../src/server/HttpRequest';
import type { HttpResponse } from '../../../src/server/HttpResponse'; import type { HttpResponse } from '../../../src/server/HttpResponse';
import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler'; import type { OperationHttpHandler } from '../../../src/server/OperationHttpHandler';
import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError';
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
describe('An AuthorizingHttpHandler', (): void => { describe('An AuthorizingHttpHandler', (): void => {
const credentials = { [CredentialGroup.public]: {}}; const credentials = { [CredentialGroup.public]: {}};
const modes = new Set([ AccessMode.read ]); const target = { path: 'http://test.com/foo' };
const permissionSet = { [CredentialGroup.public]: { read: true }}; const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([[ target, AccessMode.read ]]);
const availablePermissions: PermissionMap = new IdentifierMap(
[[ target, { [CredentialGroup.public]: { read: true }}]],
);
const request: HttpRequest = {} as any; const request: HttpRequest = {} as any;
const response: HttpResponse = {} as any; const response: HttpResponse = {} as any;
let operation: Operation; let operation: Operation;
@ -28,7 +33,7 @@ describe('An AuthorizingHttpHandler', (): void => {
beforeEach(async(): Promise<void> => { beforeEach(async(): Promise<void> => {
operation = { operation = {
target: { path: 'http://test.com/foo' }, target,
method: 'GET', method: 'GET',
preferences: {}, preferences: {},
body: new BasicRepresentation(), body: new BasicRepresentation(),
@ -38,10 +43,10 @@ describe('An AuthorizingHttpHandler', (): void => {
handleSafe: jest.fn().mockResolvedValue(credentials), handleSafe: jest.fn().mockResolvedValue(credentials),
} as any; } as any;
modesExtractor = { modesExtractor = {
handleSafe: jest.fn().mockResolvedValue(modes), handleSafe: jest.fn().mockResolvedValue(requestedModes),
} as any; } as any;
permissionReader = { permissionReader = {
handleSafe: jest.fn().mockResolvedValue(permissionSet), handleSafe: jest.fn().mockResolvedValue(availablePermissions),
} as any; } as any;
authorizer = { authorizer = {
handleSafe: jest.fn(), handleSafe: jest.fn(),
@ -62,13 +67,12 @@ describe('An AuthorizingHttpHandler', (): void => {
expect(modesExtractor.handleSafe).toHaveBeenCalledTimes(1); expect(modesExtractor.handleSafe).toHaveBeenCalledTimes(1);
expect(modesExtractor.handleSafe).toHaveBeenLastCalledWith(operation); expect(modesExtractor.handleSafe).toHaveBeenLastCalledWith(operation);
expect(permissionReader.handleSafe).toHaveBeenCalledTimes(1); expect(permissionReader.handleSafe).toHaveBeenCalledTimes(1);
expect(permissionReader.handleSafe).toHaveBeenLastCalledWith({ credentials, identifier: operation.target, modes }); expect(permissionReader.handleSafe).toHaveBeenLastCalledWith({ credentials, requestedModes });
expect(authorizer.handleSafe).toHaveBeenCalledTimes(1); expect(authorizer.handleSafe).toHaveBeenCalledTimes(1);
expect(authorizer.handleSafe) expect(authorizer.handleSafe).toHaveBeenLastCalledWith({ credentials, requestedModes, availablePermissions });
.toHaveBeenLastCalledWith({ credentials, identifier: operation.target, modes, permissionSet });
expect(source.handleSafe).toHaveBeenCalledTimes(1); expect(source.handleSafe).toHaveBeenCalledTimes(1);
expect(source.handleSafe).toHaveBeenLastCalledWith({ request, response, operation }); expect(source.handleSafe).toHaveBeenLastCalledWith({ request, response, operation });
expect(operation.permissionSet).toBe(permissionSet); expect(operation.availablePermissions).toBe(availablePermissions);
}); });
it('errors if authorization fails.', async(): Promise<void> => { it('errors if authorization fails.', async(): Promise<void> => {

View File

@ -17,7 +17,6 @@ describe('A MethodFilterHandler', (): void => {
operation = { operation = {
method: 'PATCH', method: 'PATCH',
preferences: {}, preferences: {},
permissionSet: {},
target: { path: 'http://example.com/foo' }, target: { path: 'http://example.com/foo' },
body: new BasicRepresentation(), body: new BasicRepresentation(),
}; };