mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Update PermissionReaders to support new permission interface
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { CredentialGroup } from '../authentication/Credentials';
|
||||
import type { CredentialGroup, CredentialSet } from '../authentication/Credentials';
|
||||
import { IdentifierMap } from '../util/map/IdentifierMap';
|
||||
import type { PermissionReaderInput } from './PermissionReader';
|
||||
import { PermissionReader } from './PermissionReader';
|
||||
import type { Permission, PermissionSet } from './permissions/Permissions';
|
||||
import type { Permission, PermissionMap, PermissionSet } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* PermissionReader which sets all permissions to true or false
|
||||
@@ -21,12 +22,19 @@ export class AllStaticReader extends PermissionReader {
|
||||
});
|
||||
}
|
||||
|
||||
public async handle({ credentials }: PermissionReaderInput): Promise<PermissionSet> {
|
||||
public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise<PermissionMap> {
|
||||
const availablePermissions = new IdentifierMap<PermissionSet>();
|
||||
const permissions = this.createPermissions(credentials);
|
||||
for (const [ identifier ] of requestedModes) {
|
||||
availablePermissions.set(identifier, permissions);
|
||||
}
|
||||
return availablePermissions;
|
||||
}
|
||||
|
||||
private createPermissions(credentials: CredentialSet): PermissionSet {
|
||||
const result: PermissionSet = {};
|
||||
for (const [ key, value ] of Object.entries(credentials) as [CredentialGroup, Permission][]) {
|
||||
if (value) {
|
||||
result[key] = this.permissions;
|
||||
}
|
||||
for (const group of Object.keys(credentials) as CredentialGroup[]) {
|
||||
result[group] = this.permissions;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,56 +1,63 @@
|
||||
import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy';
|
||||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap';
|
||||
import type { MapEntry } from '../util/map/MapUtil';
|
||||
import { modify } from '../util/map/MapUtil';
|
||||
import type { PermissionReaderInput } from './PermissionReader';
|
||||
import { PermissionReader } from './PermissionReader';
|
||||
import type { PermissionSet } from './permissions/Permissions';
|
||||
import type { AccessMap, AccessMode, PermissionMap } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* A PermissionReader for auxiliary resources such as acl or shape resources.
|
||||
* By default, the access permissions of an auxiliary resource depend on those of its subject resource.
|
||||
* This authorizer calls the source authorizer with the identifier of the subject resource.
|
||||
* Determines the permissions of auxiliary resources by finding those of the corresponding subject resources.
|
||||
*/
|
||||
export class AuxiliaryReader extends PermissionReader {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly resourceReader: PermissionReader;
|
||||
private readonly reader: PermissionReader;
|
||||
private readonly auxiliaryStrategy: AuxiliaryStrategy;
|
||||
|
||||
public constructor(resourceReader: PermissionReader, auxiliaryStrategy: AuxiliaryStrategy) {
|
||||
public constructor(reader: PermissionReader, auxiliaryStrategy: AuxiliaryStrategy) {
|
||||
super();
|
||||
this.resourceReader = resourceReader;
|
||||
this.reader = reader;
|
||||
this.auxiliaryStrategy = auxiliaryStrategy;
|
||||
}
|
||||
|
||||
public async canHandle(auxiliaryAuth: PermissionReaderInput): Promise<void> {
|
||||
const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth);
|
||||
return this.resourceReader.canHandle(resourceAuth);
|
||||
}
|
||||
public async handle({ requestedModes, credentials }: PermissionReaderInput): Promise<PermissionMap> {
|
||||
// Finds all the dependent auxiliary identifiers
|
||||
const auxiliaries = this.findAuxiliaries(requestedModes);
|
||||
|
||||
public async handle(auxiliaryAuth: PermissionReaderInput): Promise<PermissionSet> {
|
||||
const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth);
|
||||
this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} on ${resourceAuth.identifier.path}`);
|
||||
return this.resourceReader.handle(resourceAuth);
|
||||
}
|
||||
// Replaces the dependent auxiliary identifies with the corresponding subject identifiers
|
||||
const updatedMap = modify(new IdentifierSetMultiMap(requestedModes),
|
||||
{ add: auxiliaries.values(), remove: auxiliaries.keys() });
|
||||
const result = await this.reader.handleSafe({ requestedModes: updatedMap, credentials });
|
||||
|
||||
public async handleSafe(auxiliaryAuth: PermissionReaderInput): Promise<PermissionSet> {
|
||||
const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth);
|
||||
this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} to ${resourceAuth.identifier.path}`);
|
||||
return this.resourceReader.handleSafe(resourceAuth);
|
||||
}
|
||||
|
||||
private getRequiredAuthorization(auxiliaryAuth: PermissionReaderInput): PermissionReaderInput {
|
||||
if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(auxiliaryAuth.identifier)) {
|
||||
throw new NotImplementedHttpError('AuxiliaryAuthorizer only supports auxiliary resources.');
|
||||
// Extracts the auxiliary permissions based on the subject permissions
|
||||
for (const [ identifier, [ subject ]] of auxiliaries) {
|
||||
this.logger.debug(`Mapping ${subject.path} permissions to ${identifier.path}`);
|
||||
result.set(identifier, result.get(subject) ?? {});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (this.auxiliaryStrategy.usesOwnAuthorization(auxiliaryAuth.identifier)) {
|
||||
throw new NotImplementedHttpError('Auxiliary resource uses its own permissions.');
|
||||
/**
|
||||
* Maps auxiliary resources that do not have their own authorization checks to their subject resource.
|
||||
*/
|
||||
private findAuxiliaries(requestedModes: AccessMap): IdentifierMap<MapEntry<AccessMap>> {
|
||||
const auxiliaries = new IdentifierMap<[ResourceIdentifier, ReadonlySet<AccessMode>]>();
|
||||
for (const [ identifier, modes ] of requestedModes.entrySets()) {
|
||||
if (this.isDependentAuxiliary(identifier)) {
|
||||
auxiliaries.set(identifier, [ this.auxiliaryStrategy.getSubjectIdentifier(identifier), modes ]);
|
||||
}
|
||||
}
|
||||
return auxiliaries;
|
||||
}
|
||||
|
||||
return {
|
||||
...auxiliaryAuth,
|
||||
identifier: this.auxiliaryStrategy.getSubjectIdentifier(auxiliaryAuth.identifier),
|
||||
};
|
||||
/**
|
||||
* Checks if the identifier is an auxiliary resource that uses subject permissions.
|
||||
*/
|
||||
private isDependentAuxiliary(identifier: ResourceIdentifier): boolean {
|
||||
return this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier) &&
|
||||
!this.auxiliaryStrategy.usesOwnAuthorization(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import type { CredentialSet } from '../authentication/Credentials';
|
||||
import { CredentialGroup } from '../authentication/Credentials';
|
||||
import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy';
|
||||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||||
import type { AccountSettings, AccountStore } from '../identity/interaction/email-password/storage/AccountStore';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { createErrorMessage } from '../util/errors/ErrorUtil';
|
||||
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
|
||||
import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy';
|
||||
import { filter } from '../util/IterableUtil';
|
||||
import { IdentifierMap } from '../util/map/IdentifierMap';
|
||||
import type { PermissionReaderInput } from './PermissionReader';
|
||||
import { PermissionReader } from './PermissionReader';
|
||||
import type { AclPermission } from './permissions/AclPermission';
|
||||
import type { PermissionSet } from './permissions/Permissions';
|
||||
import type { PermissionMap } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* Allows control access if the request is being made by the owner of the pod containing the resource.
|
||||
@@ -17,40 +22,54 @@ export class OwnerPermissionReader extends PermissionReader {
|
||||
|
||||
private readonly accountStore: AccountStore;
|
||||
private readonly aclStrategy: AuxiliaryIdentifierStrategy;
|
||||
private readonly identifierStrategy: IdentifierStrategy;
|
||||
|
||||
public constructor(accountStore: AccountStore, aclStrategy: AuxiliaryIdentifierStrategy) {
|
||||
public constructor(accountStore: AccountStore, aclStrategy: AuxiliaryIdentifierStrategy,
|
||||
identifierStrategy: IdentifierStrategy) {
|
||||
super();
|
||||
this.accountStore = accountStore;
|
||||
this.aclStrategy = aclStrategy;
|
||||
this.identifierStrategy = identifierStrategy;
|
||||
}
|
||||
|
||||
public async handle(input: PermissionReaderInput): Promise<PermissionSet> {
|
||||
public async handle(input: PermissionReaderInput): Promise<PermissionMap> {
|
||||
const result: PermissionMap = new IdentifierMap();
|
||||
const requestedResources = input.requestedModes.distinctKeys();
|
||||
const acls = [ ...filter(requestedResources, (id): boolean => this.aclStrategy.isAuxiliaryIdentifier(id)) ];
|
||||
if (acls.length === 0) {
|
||||
this.logger.debug(`No ACL resources found that need an ownership check.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
let podBaseUrl: ResourceIdentifier;
|
||||
try {
|
||||
await this.ensurePodOwner(input);
|
||||
podBaseUrl = await this.findPodBaseUrl(input.credentials);
|
||||
} catch (error: unknown) {
|
||||
this.logger.debug(`No pod owner Control permissions: ${createErrorMessage(error)}`);
|
||||
return {};
|
||||
return result;
|
||||
}
|
||||
this.logger.debug(`Granting Control permissions to owner on ${input.identifier.path}`);
|
||||
|
||||
return { [CredentialGroup.agent]: {
|
||||
read: true,
|
||||
write: true,
|
||||
append: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
control: true,
|
||||
} as AclPermission };
|
||||
for (const acl of acls) {
|
||||
if (this.identifierStrategy.contains(podBaseUrl, acl, true)) {
|
||||
this.logger.debug(`Granting Control permissions to owner on ${acl.path}`);
|
||||
result.set(acl, { [CredentialGroup.agent]: {
|
||||
read: true,
|
||||
write: true,
|
||||
append: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
control: true,
|
||||
} as AclPermission });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that all conditions are fulfilled to give the owner access.
|
||||
* Find the base URL of the pod the given credentials own.
|
||||
* Will throw an error if none can be found.
|
||||
*/
|
||||
private async ensurePodOwner({ credentials, identifier }: PermissionReaderInput): Promise<void> {
|
||||
// We only check ownership when an ACL resource is targeted to reduce the number of storage calls
|
||||
if (!this.aclStrategy.isAuxiliaryIdentifier(identifier)) {
|
||||
throw new NotImplementedHttpError('Exception is only granted when accessing ACL resources');
|
||||
}
|
||||
private async findPodBaseUrl(credentials: CredentialSet): Promise<ResourceIdentifier> {
|
||||
if (!credentials.agent?.webId) {
|
||||
throw new NotImplementedHttpError('Only authenticated agents could be owners');
|
||||
}
|
||||
@@ -63,8 +82,6 @@ export class OwnerPermissionReader extends PermissionReader {
|
||||
if (!settings.podBaseUrl) {
|
||||
throw new NotImplementedHttpError('This agent has no pod on the server');
|
||||
}
|
||||
if (!identifier.path.startsWith(settings.podBaseUrl)) {
|
||||
throw new NotImplementedHttpError('Not targeting the pod owned by this agent');
|
||||
}
|
||||
return { path: settings.podBaseUrl };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import { concat } from '../util/IterableUtil';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap';
|
||||
import { ensureTrailingSlash, trimTrailingSlashes } from '../util/PathUtil';
|
||||
|
||||
import type { PermissionReaderInput } from './PermissionReader';
|
||||
import { PermissionReader } from './PermissionReader';
|
||||
import type { PermissionSet } from './permissions/Permissions';
|
||||
import type { AccessMap, PermissionMap } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* Redirects requests to specific PermissionReaders based on their identifier.
|
||||
* The keys in the input map will be converted to regular expressions.
|
||||
* The keys are regular expression strings.
|
||||
* The regular expressions should all start with a slash
|
||||
* and will be evaluated relative to the base URL.
|
||||
*
|
||||
* Will error if no match is found.
|
||||
*/
|
||||
export class PathBasedReader extends PermissionReader {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
|
||||
private readonly baseUrl: string;
|
||||
private readonly paths: Map<RegExp, PermissionReader>;
|
||||
|
||||
@@ -25,30 +28,46 @@ export class PathBasedReader extends PermissionReader {
|
||||
this.paths = new Map(entries);
|
||||
}
|
||||
|
||||
public async canHandle(input: PermissionReaderInput): Promise<void> {
|
||||
const reader = this.findReader(input.identifier.path);
|
||||
await reader.canHandle(input);
|
||||
public async handle(input: PermissionReaderInput): Promise<PermissionMap> {
|
||||
const results: PermissionMap[] = [];
|
||||
for (const [ reader, readerModes ] of this.matchReaders(input.requestedModes)) {
|
||||
results.push(await reader.handleSafe({ credentials: input.credentials, requestedModes: readerModes }));
|
||||
}
|
||||
return new IdentifierMap(concat(results));
|
||||
}
|
||||
|
||||
public async handle(input: PermissionReaderInput): Promise<PermissionSet> {
|
||||
const reader = this.findReader(input.identifier.path);
|
||||
return reader.handle(input);
|
||||
/**
|
||||
* Returns for each reader the matching part of the access map.
|
||||
*/
|
||||
private matchReaders(accessMap: AccessMap): Map<PermissionReader, AccessMap> {
|
||||
const result = new Map<PermissionReader, AccessMap>();
|
||||
for (const [ identifier, modes ] of accessMap) {
|
||||
const reader = this.findReader(identifier.path);
|
||||
if (reader) {
|
||||
let matches = result.get(reader);
|
||||
if (!matches) {
|
||||
matches = new IdentifierSetMultiMap();
|
||||
result.set(reader, matches);
|
||||
}
|
||||
matches.set(identifier, modes);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the PermissionReader corresponding to the given path.
|
||||
* Errors if there is no match.
|
||||
*/
|
||||
private findReader(path: string): PermissionReader {
|
||||
private findReader(path: string): PermissionReader | undefined {
|
||||
if (path.startsWith(this.baseUrl)) {
|
||||
// We want to keep the leading slash
|
||||
const relative = path.slice(trimTrailingSlashes(this.baseUrl).length);
|
||||
for (const [ regex, reader ] of this.paths) {
|
||||
if (regex.test(relative)) {
|
||||
this.logger.debug(`Permission reader found for ${path}`);
|
||||
return reader;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new NotImplementedHttpError('No regex matches the given path.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CredentialSet } from '../authentication/Credentials';
|
||||
import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier';
|
||||
import { getLoggerFor } from '../logging/LogUtil';
|
||||
import type { ResourceSet } from '../storage/ResourceSet';
|
||||
import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError';
|
||||
@@ -31,28 +32,39 @@ export class PermissionBasedAuthorizer extends Authorizer {
|
||||
}
|
||||
|
||||
public async handle(input: AuthorizerInput): Promise<void> {
|
||||
const { credentials, modes, identifier, permissionSet } = input;
|
||||
|
||||
const modeString = [ ...modes ].join(',');
|
||||
this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`);
|
||||
const { credentials, requestedModes, availablePermissions } = input;
|
||||
|
||||
// Ensure all required modes are within the agent's permissions.
|
||||
for (const mode of modes) {
|
||||
try {
|
||||
this.requireModePermission(credentials, permissionSet, mode);
|
||||
} catch (error: unknown) {
|
||||
// If we know the operation will return a 404 regardless (= resource does not exist and is not being created),
|
||||
// and the agent is allowed to know about its existence (= the agent has Read permissions),
|
||||
// then immediately send the 404 here, as it makes any other agent permissions irrelevant.
|
||||
const exposeExistence = this.hasModePermission(permissionSet, AccessMode.read);
|
||||
if (exposeExistence && !modes.has(AccessMode.create) && !await this.resourceSet.hasResource(identifier)) {
|
||||
throw new NotFoundHttpError();
|
||||
for (const [ identifier, modes ] of requestedModes.entrySets()) {
|
||||
const modeString = [ ...modes ].join(',');
|
||||
this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`);
|
||||
const permissions = availablePermissions.get(identifier) ?? {};
|
||||
for (const mode of modes) {
|
||||
try {
|
||||
this.requireModePermission(credentials, permissions, mode);
|
||||
} catch (error: unknown) {
|
||||
await this.reportAccessError(identifier, modes, permissions, error);
|
||||
}
|
||||
// Otherwise, deny access based on existing grounds.
|
||||
throw error;
|
||||
}
|
||||
this.logger.debug(`${JSON.stringify(credentials)} has ${modeString} permissions for ${identifier.path}`);
|
||||
}
|
||||
this.logger.debug(`${JSON.stringify(credentials)} has ${modeString} permissions for ${identifier.path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we know the operation will return a 404 regardless (= resource does not exist and is not being created),
|
||||
* and the agent is allowed to know about its existence (= the agent has Read permissions),
|
||||
* then immediately send the 404 here, as it makes any other agent permissions irrelevant.
|
||||
*
|
||||
* Otherwise, deny access based on existing grounds.
|
||||
*/
|
||||
private async reportAccessError(identifier: ResourceIdentifier, modes: ReadonlySet<AccessMode>,
|
||||
permissions: PermissionSet, cause: unknown): Promise<never> {
|
||||
const exposeExistence = this.hasModePermission(permissions, AccessMode.read);
|
||||
if (exposeExistence && !modes.has(AccessMode.create) && !await this.resourceSet.hasResource(identifier)) {
|
||||
throw new NotFoundHttpError();
|
||||
}
|
||||
|
||||
throw cause;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { CredentialGroup } from '../authentication/Credentials';
|
||||
import { UnionHandler } from '../util/handlers/UnionHandler';
|
||||
import { IdentifierMap } from '../util/map/IdentifierMap';
|
||||
import { getDefault } from '../util/map/MapUtil';
|
||||
import type { PermissionReader } from './PermissionReader';
|
||||
import type { Permission, PermissionSet } from './permissions/Permissions';
|
||||
import type { Permission, PermissionMap } from './permissions/Permissions';
|
||||
|
||||
/**
|
||||
* Combines the results of multiple PermissionReaders.
|
||||
@@ -12,20 +14,30 @@ export class UnionPermissionReader extends UnionHandler<PermissionReader> {
|
||||
super(readers);
|
||||
}
|
||||
|
||||
protected async combine(results: PermissionSet[]): Promise<PermissionSet> {
|
||||
const result: PermissionSet = {};
|
||||
for (const permissionSet of results) {
|
||||
for (const [ key, value ] of Object.entries(permissionSet) as [ CredentialGroup, Permission | undefined ][]) {
|
||||
result[key] = this.applyPermissions(value, result[key]);
|
||||
}
|
||||
protected async combine(results: PermissionMap[]): Promise<PermissionMap> {
|
||||
const result: PermissionMap = new IdentifierMap();
|
||||
for (const permissionMap of results) {
|
||||
this.mergePermissionMaps(permissionMap, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all entries of the given map into the result map.
|
||||
*/
|
||||
private mergePermissionMaps(permissionMap: PermissionMap, result: PermissionMap): void {
|
||||
for (const [ identifier, permissionSet ] of permissionMap) {
|
||||
for (const [ credential, permission ] of Object.entries(permissionSet) as [CredentialGroup, Permission][]) {
|
||||
const resultSet = getDefault(result, identifier, {});
|
||||
resultSet[credential] = this.mergePermissions(permission, resultSet[credential]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given permissions to the result object according to the combination rules of the class.
|
||||
*/
|
||||
private applyPermissions(permissions?: Permission, result: Permission = {}): Permission {
|
||||
private mergePermissions(permissions?: Permission, result: Permission = {}): Permission {
|
||||
if (!permissions) {
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -441,6 +441,7 @@ export * from './util/locking/VoidLocker';
|
||||
// Util/Map
|
||||
export * from './util/map/HashMap';
|
||||
export * from './util/map/IdentifierMap';
|
||||
export * from './util/map/MapUtil';
|
||||
export * from './util/map/SetMultiMap';
|
||||
export * from './util/map/WrappedSetMultiMap';
|
||||
|
||||
|
||||
59
src/util/map/MapUtil.ts
Normal file
59
src/util/map/MapUtil.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { SetMultiMap } from './SetMultiMap';
|
||||
|
||||
export type MapKey<T> = T extends Map<infer TKey, any> ? TKey : never;
|
||||
export type MapValue<T> = T extends Map<any, infer TValue> ? TValue : never;
|
||||
export type MapEntry<T> = T extends Map<any, any> ? [MapKey<T>, MapValue<T>] : never;
|
||||
|
||||
/**
|
||||
* A simplified version of {@link MapConstructor} that only allows creating an empty {@link Map}.
|
||||
*/
|
||||
export type EmptyMapConstructor = new() => Map<any, any>;
|
||||
|
||||
/**
|
||||
* Options describing the necessary changes when calling {@link modify}.
|
||||
*/
|
||||
export type ModifyOptions<T extends SetMultiMap<any, any>> = {
|
||||
/**
|
||||
* Entries that need to be added to the Map.
|
||||
*/
|
||||
add?: Iterable<MapEntry<T>>;
|
||||
/**
|
||||
* Keys that need to be removed from the Map.
|
||||
*/
|
||||
remove?: Iterable<MapKey<T>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies a {@link SetMultiMap} in place by removing and adding the requested entries.
|
||||
* Removals happen before additions.
|
||||
*
|
||||
* @param map - Map to start from.
|
||||
* @param options - {@link ModifyOptions} describing the necessary changes.
|
||||
*/
|
||||
export function modify<T extends SetMultiMap<any, any>>(map: T, options: ModifyOptions<T>): T {
|
||||
for (const key of options.remove ?? []) {
|
||||
map.delete(key);
|
||||
}
|
||||
for (const [ key, val ] of options.add ?? []) {
|
||||
map.add(key, val);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the result of calling `map.get(key)`.
|
||||
* If there is no result, it instead returns the default value.
|
||||
* The Map will also be updated to assign that default value to the given key.
|
||||
*
|
||||
* @param map - Map to use.
|
||||
* @param key - Key to find the value for.
|
||||
* @param defaultValue - Value to insert and return if no result was found.
|
||||
*/
|
||||
export function getDefault<TKey, TValue>(map: Map<TKey, TValue>, key: TKey, defaultValue: TValue): TValue {
|
||||
const value = map.get(key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
map.set(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||
import { AllStaticReader } from '../../../src/authorization/AllStaticReader';
|
||||
import type { Permission } from '../../../src/authorization/permissions/Permissions';
|
||||
import { AccessMode } from '../../../src/authorization/permissions/Permissions';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
import { compareMaps } from '../../util/Util';
|
||||
|
||||
function getPermissions(allow: boolean): Permission {
|
||||
return {
|
||||
@@ -13,7 +16,7 @@ function getPermissions(allow: boolean): Permission {
|
||||
}
|
||||
|
||||
describe('An AllStaticReader', (): void => {
|
||||
const credentials = { [CredentialGroup.agent]: {}, [CredentialGroup.public]: undefined };
|
||||
const credentials = { [CredentialGroup.agent]: {}};
|
||||
const identifier = { path: 'http://test.com/resource' };
|
||||
|
||||
it('can handle everything.', async(): Promise<void> => {
|
||||
@@ -23,13 +26,13 @@ describe('An AllStaticReader', (): void => {
|
||||
|
||||
it('always returns permissions matching the given allow parameter.', async(): Promise<void> => {
|
||||
let authorizer = new AllStaticReader(true);
|
||||
await expect(authorizer.handle({ credentials, identifier, modes: new Set() })).resolves.toEqual({
|
||||
[CredentialGroup.agent]: getPermissions(true),
|
||||
});
|
||||
const requestedModes = new IdentifierSetMultiMap<AccessMode>([[ identifier, AccessMode.read ]]);
|
||||
let result = await authorizer.handle({ credentials, requestedModes });
|
||||
compareMaps(result, new IdentifierMap([[ identifier, { [CredentialGroup.agent]: getPermissions(true) }]]));
|
||||
|
||||
authorizer = new AllStaticReader(false);
|
||||
await expect(authorizer.handle({ credentials, identifier, modes: new Set() })).resolves.toEqual({
|
||||
[CredentialGroup.agent]: getPermissions(false),
|
||||
});
|
||||
|
||||
result = await authorizer.handle({ credentials, requestedModes });
|
||||
compareMaps(result, new IdentifierMap([[ identifier, { [CredentialGroup.agent]: getPermissions(false) }]]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +1,91 @@
|
||||
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||
import { AuxiliaryReader } from '../../../src/authorization/AuxiliaryReader';
|
||||
import type { PermissionReader } from '../../../src/authorization/PermissionReader';
|
||||
import type { AccessMode, PermissionSet } from '../../../src/authorization/permissions/Permissions';
|
||||
import type { PermissionReaderInput, PermissionReader } from '../../../src/authorization/PermissionReader';
|
||||
import type { AccessMap, PermissionMap, PermissionSet } from '../../../src/authorization/permissions/Permissions';
|
||||
import { AccessMode } from '../../../src/authorization/permissions/Permissions';
|
||||
import type { AuxiliaryStrategy } from '../../../src/http/auxiliary/AuxiliaryStrategy';
|
||||
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
|
||||
import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
|
||||
import { map } from '../../../src/util/IterableUtil';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
import { compareMaps } from '../../util/Util';
|
||||
|
||||
describe('An AuxiliaryReader', (): void => {
|
||||
const suffix = '.dummy';
|
||||
const suffix1 = '.dummy1';
|
||||
const suffix2 = '.dummy2';
|
||||
const credentials = {};
|
||||
const modes = new Set<AccessMode>();
|
||||
const subjectIdentifier = { path: 'http://test.com/foo' };
|
||||
const auxiliaryIdentifier = { path: 'http://test.com/foo.dummy' };
|
||||
const auxiliaryIdentifier1 = { path: 'http://test.com/foo.dummy1' };
|
||||
const auxiliaryIdentifier2 = { path: 'http://test.com/foo.dummy2' };
|
||||
const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }};
|
||||
let source: jest.Mocked<PermissionReader>;
|
||||
let strategy: jest.Mocked<AuxiliaryStrategy>;
|
||||
let reader: AuxiliaryReader;
|
||||
|
||||
function handleSafe({ requestedModes }: PermissionReaderInput): PermissionMap {
|
||||
return new IdentifierMap(map(requestedModes.distinctKeys(), (identifier): [ResourceIdentifier, PermissionSet] =>
|
||||
[ identifier, permissionSet ]));
|
||||
}
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
source = {
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue(permissionSet),
|
||||
handleSafe: jest.fn().mockResolvedValue(permissionSet),
|
||||
};
|
||||
handleSafe: jest.fn(handleSafe),
|
||||
} as any;
|
||||
|
||||
strategy = {
|
||||
isAuxiliaryIdentifier: jest.fn((identifier: ResourceIdentifier): boolean => identifier.path.endsWith(suffix)),
|
||||
isAuxiliaryIdentifier: jest.fn((identifier: ResourceIdentifier): boolean =>
|
||||
identifier.path.endsWith(suffix1) || identifier.path.endsWith(suffix2)),
|
||||
getSubjectIdentifier: jest.fn((identifier: ResourceIdentifier): ResourceIdentifier =>
|
||||
({ path: identifier.path.slice(0, -suffix.length) })),
|
||||
({ path: identifier.path.slice(0, -suffix1.length) })),
|
||||
usesOwnAuthorization: jest.fn().mockReturnValue(false),
|
||||
} as any;
|
||||
reader = new AuxiliaryReader(source, strategy);
|
||||
});
|
||||
|
||||
it('can handle auxiliary resources if the source supports the subject resource.', async(): Promise<void> => {
|
||||
await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.resolves.toBeUndefined();
|
||||
expect(source.canHandle).toHaveBeenLastCalledWith(
|
||||
{ identifier: subjectIdentifier, credentials, modes },
|
||||
);
|
||||
await expect(reader.canHandle({ identifier: subjectIdentifier, credentials, modes }))
|
||||
.rejects.toThrow(NotImplementedHttpError);
|
||||
|
||||
strategy.usesOwnAuthorization.mockReturnValueOnce(true);
|
||||
await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.rejects.toThrow(NotImplementedHttpError);
|
||||
|
||||
source.canHandle.mockRejectedValue(new Error('no source support'));
|
||||
await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.rejects.toThrow('no source support');
|
||||
});
|
||||
|
||||
it('handles resources by sending the updated parameters to the source.', async(): Promise<void> => {
|
||||
await expect(reader.handle({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.resolves.toBe(permissionSet);
|
||||
expect(source.handle).toHaveBeenLastCalledWith(
|
||||
{ identifier: subjectIdentifier, credentials, modes },
|
||||
);
|
||||
// Safety checks are not present when calling `handle`
|
||||
await expect(reader.handle({ identifier: subjectIdentifier, credentials, modes }))
|
||||
.rejects.toThrow(NotImplementedHttpError);
|
||||
const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([
|
||||
[ auxiliaryIdentifier1, AccessMode.delete ],
|
||||
[{ path: 'http://example.com/other' }, AccessMode.read ],
|
||||
]);
|
||||
const permissionMap: PermissionMap = new IdentifierMap([
|
||||
[ subjectIdentifier, permissionSet ],
|
||||
[{ path: 'http://example.com/other' }, permissionSet ],
|
||||
[ auxiliaryIdentifier1, permissionSet ],
|
||||
]);
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap);
|
||||
const mock = source.handleSafe.mock.calls[0][0];
|
||||
expect(mock.credentials).toBe(credentials);
|
||||
expect(mock.requestedModes.get(subjectIdentifier)).toEqual(new Set([ AccessMode.delete ]));
|
||||
expect(mock.requestedModes.get({ path: 'http://example.com/other' })).toEqual(new Set([ AccessMode.read ]));
|
||||
expect(mock.requestedModes.size).toBe(2);
|
||||
expect(mock.requestedModes).not.toEqual(requestedModes);
|
||||
});
|
||||
|
||||
it('combines both checking and handling when calling handleSafe.', async(): Promise<void> => {
|
||||
await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.resolves.toBe(permissionSet);
|
||||
expect(source.handleSafe).toHaveBeenLastCalledWith(
|
||||
{ identifier: subjectIdentifier, credentials, modes },
|
||||
);
|
||||
it('applies an empty PermissionSet if no permissions were found for the subject.', async(): Promise<void> => {
|
||||
source.handleSafe.mockResolvedValueOnce(new IdentifierMap());
|
||||
const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([
|
||||
[ auxiliaryIdentifier1, AccessMode.delete ],
|
||||
]);
|
||||
const permissionMap: PermissionMap = new IdentifierMap([
|
||||
[ auxiliaryIdentifier1, {}],
|
||||
]);
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap);
|
||||
});
|
||||
|
||||
await expect(reader.handleSafe({ identifier: subjectIdentifier, credentials, modes }))
|
||||
.rejects.toThrow(NotImplementedHttpError);
|
||||
|
||||
strategy.usesOwnAuthorization.mockReturnValueOnce(true);
|
||||
await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.rejects.toThrow(NotImplementedHttpError);
|
||||
|
||||
source.handleSafe.mockRejectedValue(new Error('no source support'));
|
||||
await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes }))
|
||||
.rejects.toThrow('no source support');
|
||||
it('combines modes if multiple different auxiliary resources have the same subject.', async(): Promise<void> => {
|
||||
const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([
|
||||
[ auxiliaryIdentifier1, AccessMode.write ],
|
||||
[ auxiliaryIdentifier2, AccessMode.read ],
|
||||
[ subjectIdentifier, AccessMode.delete ],
|
||||
]);
|
||||
const resultSet = { [CredentialGroup.agent]: { read: true, write: true, delete: true }};
|
||||
source.handleSafe.mockResolvedValueOnce(new IdentifierMap([[ subjectIdentifier, resultSet ]]));
|
||||
const permissionMap: PermissionMap = new IdentifierMap([
|
||||
[ subjectIdentifier, resultSet ],
|
||||
[ auxiliaryIdentifier1, resultSet ],
|
||||
[ auxiliaryIdentifier2, resultSet ],
|
||||
]);
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap);
|
||||
expect(source.handleSafe.mock.calls[0][0].requestedModes.get(subjectIdentifier))
|
||||
.toEqual(new Set([ AccessMode.write, AccessMode.read, AccessMode.delete ]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,27 @@ import type { CredentialSet } from '../../../src/authentication/Credentials';
|
||||
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||
import { OwnerPermissionReader } from '../../../src/authorization/OwnerPermissionReader';
|
||||
import { AclMode } from '../../../src/authorization/permissions/AclPermission';
|
||||
import type { AccessMode } from '../../../src/authorization/permissions/Permissions';
|
||||
import type { AccessMap } from '../../../src/authorization/permissions/Permissions';
|
||||
import type { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy';
|
||||
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
|
||||
import type {
|
||||
AccountSettings,
|
||||
AccountStore,
|
||||
} from '../../../src/identity/interaction/email-password/storage/AccountStore';
|
||||
import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
import { compareMaps } from '../../util/Util';
|
||||
|
||||
describe('An OwnerPermissionReader', (): void => {
|
||||
const owner = 'http://test.com/alice/profile/card#me';
|
||||
const podBaseUrl = 'http://test.com/alice/';
|
||||
const owner = 'http://example.com/alice/profile/card#me';
|
||||
const podBaseUrl = 'http://example.com/alice/';
|
||||
let credentials: CredentialSet;
|
||||
let identifier: ResourceIdentifier;
|
||||
let modes: Set<AccessMode>;
|
||||
let requestedModes: AccessMap;
|
||||
let settings: AccountSettings;
|
||||
let accountStore: jest.Mocked<AccountStore>;
|
||||
let aclStrategy: jest.Mocked<AuxiliaryIdentifierStrategy>;
|
||||
const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/');
|
||||
let reader: OwnerPermissionReader;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
@@ -26,7 +30,7 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
|
||||
identifier = { path: `${podBaseUrl}.acl` };
|
||||
|
||||
modes = new Set<AccessMode | AclMode>([ AclMode.control ]) as Set<AccessMode>;
|
||||
requestedModes = new IdentifierSetMultiMap([[ identifier, AclMode.control ]]) as any;
|
||||
|
||||
settings = {
|
||||
useIdp: true,
|
||||
@@ -47,44 +51,45 @@ describe('An OwnerPermissionReader', (): void => {
|
||||
isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')),
|
||||
} as any;
|
||||
|
||||
reader = new OwnerPermissionReader(accountStore, aclStrategy);
|
||||
reader = new OwnerPermissionReader(accountStore, aclStrategy, identifierStrategy);
|
||||
});
|
||||
|
||||
it('returns empty permissions for non-ACL resources.', async(): Promise<void> => {
|
||||
identifier.path = podBaseUrl;
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({});
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if there is no agent WebID.', async(): Promise<void> => {
|
||||
credentials = {};
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({});
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if the agent has no account.', async(): Promise<void> => {
|
||||
credentials.agent!.webId = 'http://test.com/someone/else';
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({});
|
||||
credentials.agent!.webId = 'http://example.com/someone/else';
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if the account has no pod.', async(): Promise<void> => {
|
||||
delete settings.podBaseUrl;
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({});
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns empty permissions if the target identifier is not in the pod.', async(): Promise<void> => {
|
||||
identifier.path = 'http://somewhere.else/.acl';
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({});
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap());
|
||||
});
|
||||
|
||||
it('returns full permissions if the owner is accessing an ACL resource in their pod.', async(): Promise<void> => {
|
||||
await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({
|
||||
[CredentialGroup.agent]: {
|
||||
compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap([[
|
||||
identifier,
|
||||
{ [CredentialGroup.agent]: {
|
||||
read: true,
|
||||
write: true,
|
||||
append: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
control: true,
|
||||
},
|
||||
});
|
||||
}},
|
||||
]]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||
import { PathBasedReader } from '../../../src/authorization/PathBasedReader';
|
||||
import type { PermissionReader, PermissionReaderInput } from '../../../src/authorization/PermissionReader';
|
||||
import type { PermissionSet } from '../../../src/authorization/permissions/Permissions';
|
||||
import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError';
|
||||
import type { PermissionMap, PermissionSet } from '../../../src/authorization/permissions/Permissions';
|
||||
import { AccessMode } from '../../../src/authorization/permissions/Permissions';
|
||||
import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier';
|
||||
import { map } from '../../../src/util/IterableUtil';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
import { joinUrl } from '../../../src/util/PathUtil';
|
||||
import { compareMaps } from '../../util/Util';
|
||||
|
||||
describe('A PathBasedReader', (): void => {
|
||||
const baseUrl = 'http://test.com/foo/';
|
||||
const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }};
|
||||
let input: PermissionReaderInput;
|
||||
let readers: jest.Mocked<PermissionReader>[];
|
||||
let reader: PathBasedReader;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
input = {
|
||||
identifier: { path: `${baseUrl}first` },
|
||||
credentials: {},
|
||||
modes: new Set(),
|
||||
};
|
||||
function handleSafe({ requestedModes }: PermissionReaderInput): PermissionMap {
|
||||
return new IdentifierMap(map(requestedModes.distinctKeys(), (identifier): [ResourceIdentifier, PermissionSet] =>
|
||||
[ identifier, permissionSet ]));
|
||||
}
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
readers = [
|
||||
{ canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) },
|
||||
{ canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) },
|
||||
{ canHandle: jest.fn(), handleSafe: jest.fn(handleSafe) },
|
||||
{ canHandle: jest.fn(), handleSafe: jest.fn(handleSafe) },
|
||||
] as any;
|
||||
const paths = {
|
||||
'/first': readers[0],
|
||||
@@ -29,26 +32,31 @@ describe('A PathBasedReader', (): void => {
|
||||
reader = new PathBasedReader(baseUrl, paths);
|
||||
});
|
||||
|
||||
it('can only handle requests with a matching path.', async(): Promise<void> => {
|
||||
input.identifier.path = 'http://wrongsite/';
|
||||
await expect(reader.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
|
||||
input.identifier.path = `${baseUrl}third`;
|
||||
await expect(reader.canHandle(input)).rejects.toThrow(NotImplementedHttpError);
|
||||
input.identifier.path = `${baseUrl}first`;
|
||||
await expect(reader.canHandle(input)).resolves.toBeUndefined();
|
||||
input.identifier.path = `${baseUrl}second`;
|
||||
await expect(reader.canHandle(input)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('can only handle requests supported by the stored readers.', async(): Promise<void> => {
|
||||
await expect(reader.canHandle(input)).resolves.toBeUndefined();
|
||||
readers[0].canHandle.mockRejectedValueOnce(new Error('not supported'));
|
||||
await expect(reader.canHandle(input)).rejects.toThrow('not supported');
|
||||
});
|
||||
|
||||
it('passes the handle requests to the matching reader.', async(): Promise<void> => {
|
||||
await expect(reader.handle(input)).resolves.toBe(permissionSet);
|
||||
expect(readers[0].handle).toHaveBeenCalledTimes(1);
|
||||
expect(readers[0].handle).toHaveBeenLastCalledWith(input);
|
||||
const input: PermissionReaderInput = {
|
||||
credentials: {},
|
||||
requestedModes: new IdentifierSetMultiMap<AccessMode>([
|
||||
[{ path: joinUrl(baseUrl, 'first') }, AccessMode.read ],
|
||||
[{ path: joinUrl(baseUrl, 'second') }, AccessMode.read ],
|
||||
[{ path: joinUrl(baseUrl, 'nothere') }, AccessMode.read ],
|
||||
[{ path: 'http://wrongsite' }, AccessMode.read ],
|
||||
]),
|
||||
};
|
||||
|
||||
const result = new IdentifierMap([
|
||||
[{ path: joinUrl(baseUrl, 'first') }, permissionSet ],
|
||||
[{ path: joinUrl(baseUrl, 'second') }, permissionSet ],
|
||||
]);
|
||||
|
||||
await expect(reader.handle(input)).resolves.toEqual(result);
|
||||
expect(readers[0].handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(readers[0].handleSafe.mock.calls[0][0].credentials).toEqual({});
|
||||
compareMaps(readers[0].handleSafe.mock.calls[0][0].requestedModes,
|
||||
new IdentifierSetMultiMap([[{ path: joinUrl(baseUrl, 'first') }, AccessMode.read ]]));
|
||||
|
||||
expect(readers[1].handleSafe).toHaveBeenCalledTimes(1);
|
||||
expect(readers[1].handleSafe.mock.calls[0][0].credentials).toEqual({});
|
||||
compareMaps(readers[1].handleSafe.mock.calls[0][0].requestedModes,
|
||||
new IdentifierSetMultiMap([[{ path: joinUrl(baseUrl, 'second') }, AccessMode.read ]]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,17 +6,18 @@ import type { ResourceSet } from '../../../src/storage/ResourceSet';
|
||||
import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError';
|
||||
import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError';
|
||||
import { UnauthorizedHttpError } from '../../../src/util/errors/UnauthorizedHttpError';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
|
||||
describe('A PermissionBasedAuthorizer', (): void => {
|
||||
const identifier = { path: 'http://example.com/foo' };
|
||||
let input: AuthorizerInput;
|
||||
let resourceSet: jest.Mocked<ResourceSet>;
|
||||
let authorizer: PermissionBasedAuthorizer;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
input = {
|
||||
identifier: { path: 'http://test.com/foo' },
|
||||
modes: new Set<AccessMode>(),
|
||||
permissionSet: {},
|
||||
requestedModes: new IdentifierSetMultiMap(),
|
||||
availablePermissions: new IdentifierMap(),
|
||||
credentials: {},
|
||||
};
|
||||
resourceSet = {
|
||||
@@ -31,28 +32,34 @@ describe('A PermissionBasedAuthorizer', (): void => {
|
||||
});
|
||||
|
||||
it('allows access if the permissions are matched by the reader output.', async(): Promise<void> => {
|
||||
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
|
||||
input.permissionSet = {
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
|
||||
[[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]],
|
||||
);
|
||||
input.availablePermissions = new IdentifierMap([[ identifier, {
|
||||
[CredentialGroup.public]: { read: true, write: false },
|
||||
[CredentialGroup.agent]: { write: true },
|
||||
};
|
||||
}]]);
|
||||
await expect(authorizer.handle(input)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws an UnauthorizedHttpError when an unauthenticated request has no access.', async(): Promise<void> => {
|
||||
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
|
||||
input.permissionSet = {
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
|
||||
[[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]],
|
||||
);
|
||||
input.availablePermissions = new IdentifierMap([[ identifier, {
|
||||
[CredentialGroup.public]: { read: true, write: false },
|
||||
};
|
||||
}]]);
|
||||
await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError);
|
||||
});
|
||||
|
||||
it('throws a ForbiddenHttpError when an authenticated request has no access.', async(): Promise<void> => {
|
||||
input.credentials = { agent: { webId: 'http://test.com/#me' }};
|
||||
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
|
||||
input.permissionSet = {
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
|
||||
[[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]],
|
||||
);
|
||||
input.availablePermissions = new IdentifierMap([[ identifier, {
|
||||
[CredentialGroup.public]: { read: true, write: false },
|
||||
};
|
||||
}]]);
|
||||
await expect(authorizer.handle(input)).rejects.toThrow(ForbiddenHttpError);
|
||||
});
|
||||
|
||||
@@ -62,10 +69,48 @@ describe('A PermissionBasedAuthorizer', (): void => {
|
||||
|
||||
it('throws a 404 in case the target resource does not exist and would not be written to.', async(): Promise<void> => {
|
||||
resourceSet.hasResource.mockResolvedValueOnce(false);
|
||||
input.modes = new Set([ AccessMode.delete ]);
|
||||
input.permissionSet = {
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([[ identifier, AccessMode.delete ]]);
|
||||
input.availablePermissions = new IdentifierMap([[ identifier, {
|
||||
[CredentialGroup.public]: { read: true },
|
||||
};
|
||||
}]]);
|
||||
await expect(authorizer.handle(input)).rejects.toThrow(NotFoundHttpError);
|
||||
});
|
||||
|
||||
it('throws a ForbiddenHttpError if only some identifiers are authorized.', async(): Promise<void> => {
|
||||
const identifier2 = { path: 'http://example.com/no-access' };
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([
|
||||
[ identifier, AccessMode.read ],
|
||||
[ identifier, AccessMode.write ],
|
||||
[ identifier2, AccessMode.read ],
|
||||
[ identifier2, AccessMode.write ],
|
||||
]);
|
||||
input.availablePermissions = new IdentifierMap([
|
||||
[ identifier, {
|
||||
[CredentialGroup.public]: { read: true, write: false },
|
||||
[CredentialGroup.agent]: { write: true },
|
||||
}],
|
||||
[ identifier2, {
|
||||
[CredentialGroup.public]: { read: false },
|
||||
[CredentialGroup.agent]: { write: true },
|
||||
}],
|
||||
]);
|
||||
await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError);
|
||||
});
|
||||
|
||||
it('throws a ForbiddenHttpError if identifiers have no PermissionMap entry.', async(): Promise<void> => {
|
||||
const identifier2 = { path: 'http://example.com/no-access' };
|
||||
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([
|
||||
[ identifier, AccessMode.read ],
|
||||
[ identifier, AccessMode.write ],
|
||||
[ identifier2, AccessMode.read ],
|
||||
[ identifier2, AccessMode.write ],
|
||||
]);
|
||||
input.availablePermissions = new IdentifierMap([
|
||||
[ identifier, {
|
||||
[CredentialGroup.public]: { read: true, write: false },
|
||||
[CredentialGroup.agent]: { write: true },
|
||||
}],
|
||||
]);
|
||||
await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { CredentialGroup } from '../../../src/authentication/Credentials';
|
||||
import type { PermissionReader, PermissionReaderInput } from '../../../src/authorization/PermissionReader';
|
||||
import type { PermissionSet } from '../../../src/authorization/permissions/Permissions';
|
||||
import { AccessMode } from '../../../src/authorization/permissions/Permissions';
|
||||
import { UnionPermissionReader } from '../../../src/authorization/UnionPermissionReader';
|
||||
import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap';
|
||||
import { compareMaps } from '../../util/Util';
|
||||
|
||||
describe('A UnionPermissionReader', (): void => {
|
||||
const input: PermissionReaderInput =
|
||||
{ credentials: {}, identifier: { path: 'http://test.com/foo' }, modes: new Set() };
|
||||
const identifier = { path: 'http://example.com/foo' };
|
||||
const input: PermissionReaderInput = { credentials: {},
|
||||
requestedModes: new IdentifierSetMultiMap<AccessMode>([[ identifier, AccessMode.read ]]) };
|
||||
let readers: jest.Mocked<PermissionReader>[];
|
||||
let unionReader: UnionPermissionReader;
|
||||
|
||||
@@ -12,11 +17,11 @@ describe('A UnionPermissionReader', (): void => {
|
||||
readers = [
|
||||
{
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue({}),
|
||||
handle: jest.fn().mockResolvedValue(new IdentifierMap()),
|
||||
} as any,
|
||||
{
|
||||
canHandle: jest.fn(),
|
||||
handle: jest.fn().mockResolvedValue({}),
|
||||
handle: jest.fn().mockResolvedValue(new IdentifierMap()),
|
||||
} as any,
|
||||
];
|
||||
|
||||
@@ -25,33 +30,46 @@ describe('A UnionPermissionReader', (): void => {
|
||||
|
||||
it('only uses the results of readers that can handle the input.', async(): Promise<void> => {
|
||||
readers[0].canHandle.mockRejectedValue(new Error('bad request'));
|
||||
readers[0].handle.mockResolvedValue({ [CredentialGroup.agent]: { read: true }});
|
||||
readers[1].handle.mockResolvedValue({ [CredentialGroup.agent]: { write: true }});
|
||||
await expect(unionReader.handle(input)).resolves.toEqual({ [CredentialGroup.agent]: { write: true }});
|
||||
readers[0].handle.mockResolvedValue(
|
||||
new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { read: true }}]]),
|
||||
);
|
||||
readers[1].handle.mockResolvedValue(
|
||||
new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { write: true }}]]),
|
||||
);
|
||||
compareMaps(await unionReader.handle(input),
|
||||
new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { write: true }}]]));
|
||||
});
|
||||
|
||||
it('combines results.', async(): Promise<void> => {
|
||||
readers[0].handle.mockResolvedValue(
|
||||
{ [CredentialGroup.agent]: { read: true }, [CredentialGroup.public]: undefined },
|
||||
);
|
||||
readers[1].handle.mockResolvedValue(
|
||||
{ [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }},
|
||||
);
|
||||
await expect(unionReader.handle(input)).resolves.toEqual({
|
||||
[CredentialGroup.agent]: { read: true, write: true },
|
||||
[CredentialGroup.public]: { read: false },
|
||||
});
|
||||
const identifier2 = { path: 'http://example.com/foo2' };
|
||||
const identifier3 = { path: 'http://example.com/foo3' };
|
||||
readers[0].handle.mockResolvedValue(new IdentifierMap([
|
||||
[ identifier, { [CredentialGroup.agent]: { read: true }, [CredentialGroup.public]: undefined }],
|
||||
[ identifier2, { [CredentialGroup.agent]: { write: true }}],
|
||||
[ identifier3, { [CredentialGroup.agent]: { append: false }, [CredentialGroup.public]: { delete: true }}],
|
||||
]));
|
||||
readers[1].handle.mockResolvedValue(new IdentifierMap<PermissionSet>([
|
||||
[ identifier, { [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }}],
|
||||
[ identifier2, { [CredentialGroup.public]: { read: false }}],
|
||||
]));
|
||||
compareMaps(await unionReader.handle(input), new IdentifierMap([
|
||||
[ identifier, { [CredentialGroup.agent]: { read: true, write: true }, [CredentialGroup.public]: { read: false }}],
|
||||
[ identifier2, { [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }}],
|
||||
[ identifier3, { [CredentialGroup.agent]: { append: false }, [CredentialGroup.public]: { delete: true }}],
|
||||
]));
|
||||
});
|
||||
|
||||
it('merges same fields using false > true > undefined.', async(): Promise<void> => {
|
||||
readers[0].handle.mockResolvedValue(
|
||||
{ [CredentialGroup.agent]: { read: true, write: false, append: undefined, create: true, delete: undefined }},
|
||||
);
|
||||
readers[1].handle.mockResolvedValue(
|
||||
{ [CredentialGroup.agent]: { read: false, write: true, append: true, create: true, delete: undefined }},
|
||||
);
|
||||
await expect(unionReader.handle(input)).resolves.toEqual({
|
||||
readers[0].handle.mockResolvedValue(new IdentifierMap(
|
||||
[[ identifier,
|
||||
{ [CredentialGroup.agent]: { read: true, write: false, append: undefined, create: true, delete: undefined }}]],
|
||||
));
|
||||
readers[1].handle.mockResolvedValue(new IdentifierMap(
|
||||
[[ identifier, { [CredentialGroup.agent]:
|
||||
{ read: false, write: true, append: true, create: true, delete: undefined }}]],
|
||||
));
|
||||
compareMaps(await unionReader.handle(input), new IdentifierMap([[ identifier, {
|
||||
[CredentialGroup.agent]: { read: false, write: false, append: true, create: true },
|
||||
});
|
||||
}]]));
|
||||
});
|
||||
});
|
||||
|
||||
55
test/unit/util/map/MapUtil.test.ts
Normal file
55
test/unit/util/map/MapUtil.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { getDefault, modify } from '../../../../src/util/map/MapUtil';
|
||||
import { WrappedSetMultiMap } from '../../../../src/util/map/WrappedSetMultiMap';
|
||||
import { compareMaps } from '../../../util/Util';
|
||||
|
||||
describe('MapUtil', (): void => {
|
||||
const key1 = 'key1';
|
||||
const key2 = 'key2';
|
||||
const key3 = 'key3';
|
||||
|
||||
describe('#modify', (): void => {
|
||||
it('modifies the map as specified.', async(): Promise<void> => {
|
||||
const map = new WrappedSetMultiMap(undefined, [
|
||||
[ key1, 123 ],
|
||||
[ key2, 123 ],
|
||||
]);
|
||||
const add: Iterable<[string, number]> = [[ key1, 456 ], [ key3, 123 ]];
|
||||
const remove = [ key2 ];
|
||||
|
||||
const expected = new WrappedSetMultiMap(undefined, [
|
||||
[ key1, 123 ],
|
||||
[ key1, 456 ],
|
||||
[ key3, 123 ],
|
||||
]);
|
||||
|
||||
modify(map, { add, remove });
|
||||
compareMaps(map, expected);
|
||||
});
|
||||
it('defaults to empty add and delete Iterables.', async(): Promise<void> => {
|
||||
const map = new WrappedSetMultiMap(undefined, [
|
||||
[ key1, 123 ],
|
||||
[ key2, 123 ],
|
||||
]);
|
||||
|
||||
const expected = new WrappedSetMultiMap(undefined, [
|
||||
[ key1, 123 ],
|
||||
[ key2, 123 ],
|
||||
]);
|
||||
|
||||
modify(map, {});
|
||||
compareMaps(map, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getDefault', (): void => {
|
||||
it('returns the value it finds in the Map for the given key.', async(): Promise<void> => {
|
||||
const map = new Map([[ key1, 123 ]]);
|
||||
expect(getDefault(map, key1, 999)).toBe(123);
|
||||
});
|
||||
|
||||
it('returns the default value if it finds no value for the given key.', async(): Promise<void> => {
|
||||
const map = new Map([[ key1, 123 ]]);
|
||||
expect(getDefault(map, key2, 999)).toBe(999);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user