diff --git a/.componentsignore b/.componentsignore index f3f4052325..f0418beb06 100644 --- a/.componentsignore +++ b/.componentsignore @@ -5,7 +5,7 @@ "Error", "EventEmitter", "HttpErrorOptions", - "PermissionSet", + "Permission", "Template", "TemplateEngine", "ValuePreferencesArg", diff --git a/config/ldp/authorization/allow-all.json b/config/ldp/authorization/allow-all.json index 141b64894d..af59acab28 100644 --- a/config/ldp/authorization/allow-all.json +++ b/config/ldp/authorization/allow-all.json @@ -7,7 +7,11 @@ "Always allows all operations." ], "@id": "urn:solid-server:default:Authorizer", - "@type": "AllowAllAuthorizer" + "@type": "PermissionBasedAuthorizer", + "reader": { + "@type": "AllStaticReader", + "allow": true + } } ] } diff --git a/config/ldp/authorization/authorizers/access-checkers/agent-class.json b/config/ldp/authorization/readers/access-checkers/agent-class.json similarity index 100% rename from config/ldp/authorization/authorizers/access-checkers/agent-class.json rename to config/ldp/authorization/readers/access-checkers/agent-class.json diff --git a/config/ldp/authorization/authorizers/access-checkers/agent-group.json b/config/ldp/authorization/readers/access-checkers/agent-group.json similarity index 100% rename from config/ldp/authorization/authorizers/access-checkers/agent-group.json rename to config/ldp/authorization/readers/access-checkers/agent-group.json diff --git a/config/ldp/authorization/authorizers/access-checkers/agent.json b/config/ldp/authorization/readers/access-checkers/agent.json similarity index 100% rename from config/ldp/authorization/authorizers/access-checkers/agent.json rename to config/ldp/authorization/readers/access-checkers/agent.json diff --git a/config/ldp/authorization/authorizers/acl.json b/config/ldp/authorization/readers/acl.json similarity index 68% rename from config/ldp/authorization/authorizers/acl.json rename to config/ldp/authorization/readers/acl.json index 708625669a..700e4c2ac5 100644 --- a/config/ldp/authorization/authorizers/acl.json +++ b/config/ldp/authorization/readers/acl.json @@ -1,14 +1,14 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "import": [ - "files-scs:config/ldp/authorization/authorizers/access-checkers/agent.json", - "files-scs:config/ldp/authorization/authorizers/access-checkers/agent-class.json", - "files-scs:config/ldp/authorization/authorizers/access-checkers/agent-group.json" + "files-scs:config/ldp/authorization/readers/access-checkers/agent.json", + "files-scs:config/ldp/authorization/readers/access-checkers/agent-class.json", + "files-scs:config/ldp/authorization/readers/access-checkers/agent-group.json" ], "@graph": [ { - "@id": "urn:solid-server:default:WebAclAuthorizer", - "@type": "WebAclAuthorizer", + "@id": "urn:solid-server:default:WebAclReader", + "@type": "WebAclReader", "aclStrategy": { "@id": "urn:solid-server:default:AclStrategy" }, diff --git a/config/ldp/authorization/webacl.json b/config/ldp/authorization/webacl.json index 6554811168..85a0a7a1f4 100644 --- a/config/ldp/authorization/webacl.json +++ b/config/ldp/authorization/webacl.json @@ -1,28 +1,31 @@ { "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^1.0.0/components/context.jsonld", "import": [ - "files-scs:config/ldp/authorization/authorizers/acl.json" + "files-scs:config/ldp/authorization/readers/acl.json" ], "@graph": [ { "comment": "Uses Web Access Control for authorization.", "@id": "urn:solid-server:default:Authorizer", - "@type": "WaterfallHandler", - "handlers": [ - { - "comment": "This authorizer will be used to prevent external access to containers used for internal storage.", - "@id": "urn:solid-server:default:PathBasedAuthorizer", - "@type": "PathBasedAuthorizer", - "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } - }, - { - "comment": "This authorizer makes sure that for auxiliary resources, the main authorizer gets called with the associated identifier.", - "@type": "AuxiliaryAuthorizer", - "resourceAuthorizer": { "@id": "urn:solid-server:default:WebAclAuthorizer" }, - "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" } - }, - { "@id": "urn:solid-server:default:WebAclAuthorizer" } - ] + "@type": "PermissionBasedAuthorizer", + "reader": { + "@type": "UnionPermissionReader", + "readers": [ + { + "comment": "This PermissionReader will be used to prevent external access to containers used for internal storage.", + "@id": "urn:solid-server:default:PathBasedReader", + "@type": "PathBasedReader", + "baseUrl": { "@id": "urn:solid-server:default:variable:baseUrl" } + }, + { + "comment": "This PermissionReader makes sure that for auxiliary resources, the main reader gets called with the associated identifier.", + "@type": "AuxiliaryReader", + "resourceReader": { "@id": "urn:solid-server:default:WebAclReader" }, + "auxiliaryStrategy": { "@id": "urn:solid-server:default:AuxiliaryStrategy" } + }, + { "@id": "urn:solid-server:default:WebAclReader" } + ] + } } ] } diff --git a/config/storage/key-value/resource-store.json b/config/storage/key-value/resource-store.json index be4a891743..94989aaf59 100644 --- a/config/storage/key-value/resource-store.json +++ b/config/storage/key-value/resource-store.json @@ -57,11 +57,14 @@ }, { "comment": "Block external access to the storage containers to avoid exposing internal data.", - "@id": "urn:solid-server:default:PathBasedAuthorizer", - "PathBasedAuthorizer:_paths": [ + "@id": "urn:solid-server:default:PathBasedReader", + "PathBasedReader:_paths": [ { - "PathBasedAuthorizer:_paths_key": "^/.internal(/.*)?$", - "PathBasedAuthorizer:_paths_value": { "@type": "DenyAllAuthorizer" } + "PathBasedReader:_paths_key": "^/.internal(/.*)?$", + "PathBasedReader:_paths_value": { + "@type": "AllStaticReader", + "allow": false + } } ] }, diff --git a/src/authorization/AllStaticReader.ts b/src/authorization/AllStaticReader.ts new file mode 100644 index 0000000000..57eafab6fc --- /dev/null +++ b/src/authorization/AllStaticReader.ts @@ -0,0 +1,32 @@ +import type { CredentialGroup } from '../authentication/Credentials'; +import type { Permission, PermissionSet } from '../ldp/permissions/Permissions'; +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; + +/** + * PermissionReader which sets all permissions to true or false + * independently of the identifier and requested permissions. + */ +export class AllStaticReader extends PermissionReader { + private readonly permissions: Permission; + + public constructor(allow: boolean) { + super(); + this.permissions = Object.freeze({ + read: allow, + write: allow, + append: allow, + control: allow, + }); + } + + public async handle({ credentials }: PermissionReaderInput): Promise { + const result: PermissionSet = {}; + for (const [ key, value ] of Object.entries(credentials) as [CredentialGroup, Permission][]) { + if (value) { + result[key] = this.permissions; + } + } + return result; + } +} diff --git a/src/authorization/AllowAllAuthorizer.ts b/src/authorization/AllowAllAuthorizer.ts deleted file mode 100644 index 12a2a12dc6..0000000000 --- a/src/authorization/AllowAllAuthorizer.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { PermissionSet } from '../ldp/permissions/PermissionSet'; -import { Authorizer } from './Authorizer'; -import { WebAclAuthorization } from './WebAclAuthorization'; - -const allowAll: PermissionSet = { - read: true, - write: true, - append: true, - control: true, -}; - -/** - * Authorizer which allows all access independent of the identifier and requested permissions. - */ -export class AllowAllAuthorizer extends Authorizer { - public async handle(): Promise { - return new WebAclAuthorization(allowAll, allowAll); - } -} diff --git a/src/authorization/Authorizer.ts b/src/authorization/Authorizer.ts index 148f8cfebc..e38a0bfb4e 100644 --- a/src/authorization/Authorizer.ts +++ b/src/authorization/Authorizer.ts @@ -1,5 +1,5 @@ import type { CredentialSet } from '../authentication/Credentials'; -import type { AccessMode } from '../ldp/permissions/PermissionSet'; +import type { AccessMode } from '../ldp/permissions/Permissions'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { AsyncHandler } from '../util/handlers/AsyncHandler'; import type { Authorization } from './Authorization'; @@ -20,7 +20,7 @@ export interface AuthorizerInput { } /** - * Verifies if the given credentials have access to the given permissions on the given resource. + * Verifies if the credentials provide access with the given permissions on the resource. * An {@link Error} with the necessary explanation will be thrown when permissions are not granted. */ export abstract class Authorizer extends AsyncHandler {} diff --git a/src/authorization/AuxiliaryAuthorizer.ts b/src/authorization/AuxiliaryReader.ts similarity index 57% rename from src/authorization/AuxiliaryAuthorizer.ts rename to src/authorization/AuxiliaryReader.ts index 3b7bab4a7e..758e0eff62 100644 --- a/src/authorization/AuxiliaryAuthorizer.ts +++ b/src/authorization/AuxiliaryReader.ts @@ -1,45 +1,46 @@ import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy'; +import type { PermissionSet } from '../ldp/permissions/Permissions'; import { getLoggerFor } from '../logging/LogUtil'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; -import type { Authorization } from './Authorization'; -import type { AuthorizerInput } from './Authorizer'; -import { Authorizer } from './Authorizer'; + +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; /** - * An authorizer for auxiliary resources such as acl or shape resources. + * A PermissionReader for auxiliary resources such as acl or shape resources. * The access permissions of an auxiliary resource depend on those of the resource it is associated with. * This authorizer calls the source authorizer with the identifier of the associated resource. */ -export class AuxiliaryAuthorizer extends Authorizer { +export class AuxiliaryReader extends PermissionReader { protected readonly logger = getLoggerFor(this); - private readonly resourceAuthorizer: Authorizer; + private readonly resourceReader: PermissionReader; private readonly auxiliaryStrategy: AuxiliaryIdentifierStrategy; - public constructor(resourceAuthorizer: Authorizer, auxiliaryStrategy: AuxiliaryIdentifierStrategy) { + public constructor(resourceReader: PermissionReader, auxiliaryStrategy: AuxiliaryIdentifierStrategy) { super(); - this.resourceAuthorizer = resourceAuthorizer; + this.resourceReader = resourceReader; this.auxiliaryStrategy = auxiliaryStrategy; } - public async canHandle(auxiliaryAuth: AuthorizerInput): Promise { + public async canHandle(auxiliaryAuth: PermissionReaderInput): Promise { const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); - return this.resourceAuthorizer.canHandle(resourceAuth); + return this.resourceReader.canHandle(resourceAuth); } - public async handle(auxiliaryAuth: AuthorizerInput): Promise { + public async handle(auxiliaryAuth: PermissionReaderInput): Promise { const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} on ${resourceAuth.identifier.path}`); - return this.resourceAuthorizer.handle(resourceAuth); + return this.resourceReader.handle(resourceAuth); } - public async handleSafe(auxiliaryAuth: AuthorizerInput): Promise { + public async handleSafe(auxiliaryAuth: PermissionReaderInput): Promise { const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} to ${resourceAuth.identifier.path}`); - return this.resourceAuthorizer.handleSafe(resourceAuth); + return this.resourceReader.handleSafe(resourceAuth); } - private getRequiredAuthorization(auxiliaryAuth: AuthorizerInput): AuthorizerInput { + private getRequiredAuthorization(auxiliaryAuth: PermissionReaderInput): PermissionReaderInput { if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(auxiliaryAuth.identifier)) { throw new NotImplementedHttpError('AuxiliaryAuthorizer only supports auxiliary resources.'); } diff --git a/src/authorization/DenyAllAuthorizer.ts b/src/authorization/DenyAllAuthorizer.ts deleted file mode 100644 index 93cb264174..0000000000 --- a/src/authorization/DenyAllAuthorizer.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; -import { Authorizer } from './Authorizer'; - -/** - * An authorizer that rejects all requests. - */ -export class DenyAllAuthorizer extends Authorizer { - public async handle(): Promise { - throw new ForbiddenHttpError(); - } -} diff --git a/src/authorization/PathBasedAuthorizer.ts b/src/authorization/PathBasedAuthorizer.ts deleted file mode 100644 index 0e7f6d341f..0000000000 --- a/src/authorization/PathBasedAuthorizer.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; -import { ensureTrailingSlash, trimTrailingSlashes } from '../util/PathUtil'; -import type { Authorization } from './Authorization'; -import type { AuthorizerInput } from './Authorizer'; -import { Authorizer } from './Authorizer'; - -/** - * Redirects requests to specific authorizers based on their identifier. - * The keys in the input map will be converted to regular expressions. - * 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 PathBasedAuthorizer extends Authorizer { - private readonly baseUrl: string; - private readonly paths: Map; - - public constructor(baseUrl: string, paths: Record) { - super(); - this.baseUrl = ensureTrailingSlash(baseUrl); - const entries = Object.entries(paths).map(([ key, val ]): [RegExp, Authorizer] => [ new RegExp(key, 'u'), val ]); - this.paths = new Map(entries); - } - - public async canHandle(input: AuthorizerInput): Promise { - const authorizer = this.findAuthorizer(input.identifier.path); - await authorizer.canHandle(input); - } - - public async handle(input: AuthorizerInput): Promise { - const authorizer = this.findAuthorizer(input.identifier.path); - return authorizer.handle(input); - } - - /** - * Find the authorizer corresponding to the given path. - * Errors if there is no match. - */ - private findAuthorizer(path: string): Authorizer { - if (path.startsWith(this.baseUrl)) { - // We want to keep the leading slash - const relative = path.slice(trimTrailingSlashes(this.baseUrl).length); - for (const [ regex, authorizer ] of this.paths) { - if (regex.test(relative)) { - return authorizer; - } - } - } - throw new NotImplementedHttpError('No regex matches the given path.'); - } -} diff --git a/src/authorization/PathBasedReader.ts b/src/authorization/PathBasedReader.ts new file mode 100644 index 0000000000..5dfe92cb1f --- /dev/null +++ b/src/authorization/PathBasedReader.ts @@ -0,0 +1,54 @@ +import type { PermissionSet } from '../ldp/permissions/Permissions'; +import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import { ensureTrailingSlash, trimTrailingSlashes } from '../util/PathUtil'; + +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; + +/** + * Redirects requests to specific PermissionReaders based on their identifier. + * The keys in the input map will be converted to regular expressions. + * 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 { + private readonly baseUrl: string; + private readonly paths: Map; + + public constructor(baseUrl: string, paths: Record) { + super(); + this.baseUrl = ensureTrailingSlash(baseUrl); + const entries = Object.entries(paths) + .map(([ key, val ]): [RegExp, PermissionReader] => [ new RegExp(key, 'u'), val ]); + this.paths = new Map(entries); + } + + public async canHandle(input: PermissionReaderInput): Promise { + const reader = this.findReader(input.identifier.path); + await reader.canHandle(input); + } + + public async handle(input: PermissionReaderInput): Promise { + const reader = this.findReader(input.identifier.path); + return reader.handle(input); + } + + /** + * Find the PermissionReader corresponding to the given path. + * Errors if there is no match. + */ + private findReader(path: string): PermissionReader { + 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)) { + return reader; + } + } + } + throw new NotImplementedHttpError('No regex matches the given path.'); + } +} diff --git a/src/authorization/PermissionBasedAuthorizer.ts b/src/authorization/PermissionBasedAuthorizer.ts new file mode 100644 index 0000000000..819067202a --- /dev/null +++ b/src/authorization/PermissionBasedAuthorizer.ts @@ -0,0 +1,92 @@ +import type { CredentialSet } from '../authentication/Credentials'; +import type { AccessMode, PermissionSet } from '../ldp/permissions/Permissions'; + +import { getLoggerFor } from '../logging/LogUtil'; +import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; +import { UnauthorizedHttpError } from '../util/errors/UnauthorizedHttpError'; +import type { Authorization } from './Authorization'; +import type { AuthorizerInput } from './Authorizer'; +import { Authorizer } from './Authorizer'; +import type { PermissionReader } from './PermissionReader'; +import { WebAclAuthorization } from './WebAclAuthorization'; + +/** + * Authorizer that bases its decision on the output it gets from its PermissionReader. + * For each permission it checks if the reader allows that for at least one credential type, + * if yes authorization is granted. + * `undefined` values for reader results are interpreted as `false`. + */ +export class PermissionBasedAuthorizer extends Authorizer { + protected readonly logger = getLoggerFor(this); + + private readonly reader: PermissionReader; + + public constructor(reader: PermissionReader) { + super(); + this.reader = reader; + } + + public async canHandle(input: AuthorizerInput): Promise { + return this.reader.canHandle(input); + } + + public async handle(input: AuthorizerInput): Promise { + const { credentials, modes, identifier } = input; + + // Read out the permissions + const permissions = await this.reader.handle(input); + const authorization = new WebAclAuthorization(permissions.agent ?? {}, permissions.public ?? {}); + + const modeString = [ ...modes ].join(','); + this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`); + + for (const mode of modes) { + this.requireModePermission(credentials, permissions, mode); + } + this.logger.debug(`${JSON.stringify(credentials)} has ${modeString} permissions for ${identifier.path}`); + return authorization; + } + + /** + * Ensures that at least one of the credentials provides permissions for the given mode. + * Throws a {@link ForbiddenHttpError} or {@link UnauthorizedHttpError} depending on the credentials + * if access is not allowed. + * @param credentials - Credentials that require access. + * @param permissionSet - PermissionSet describing the available permissions of the credentials. + * @param mode - Which mode is requested. + */ + private requireModePermission(credentials: CredentialSet, permissionSet: PermissionSet, mode: AccessMode): void { + if (!this.hasModePermission(permissionSet, mode)) { + if (this.isAuthenticated(credentials)) { + this.logger.warn(`Agent ${credentials.agent!.webId} has no ${mode} permissions`); + throw new ForbiddenHttpError(); + } else { + // Solid, §2.1: "When a client does not provide valid credentials when requesting a resource that requires it, + // the data pod MUST send a response with a 401 status code (unless 404 is preferred for security reasons)." + // https://solid.github.io/specification/protocol#http-server + this.logger.warn(`Unauthenticated agent has no ${mode} permissions`); + throw new UnauthorizedHttpError(); + } + } + } + + /** + * Checks if one of the Permissions in the PermissionSet grants permission to use the given mode. + */ + private hasModePermission(permissionSet: PermissionSet, mode: AccessMode): boolean { + for (const permissions of Object.values(permissionSet)) { + if (permissions[mode]) { + return true; + } + } + return false; + } + + /** + * Checks whether the agent is authenticated (logged in) or not (public/anonymous). + * @param credentials - Credentials to check. + */ + private isAuthenticated(credentials: CredentialSet): boolean { + return typeof credentials.agent?.webId === 'string'; + } +} diff --git a/src/authorization/PermissionReader.ts b/src/authorization/PermissionReader.ts new file mode 100644 index 0000000000..3c90ba5d64 --- /dev/null +++ b/src/authorization/PermissionReader.ts @@ -0,0 +1,20 @@ +import type { CredentialSet } from '../authentication/Credentials'; +import type { PermissionSet } from '../ldp/permissions/Permissions'; +import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; +import { AsyncHandler } from '../util/handlers/AsyncHandler'; + +export interface PermissionReaderInput { + /** + * Credentials of the entity that wants to use the resource. + */ + credentials: CredentialSet; + /** + * Identifier of the resource that will be read/modified. + */ + identifier: ResourceIdentifier; +} + +/** + * Discovers the permissions of the given credentials on the given identifier. + */ +export abstract class PermissionReader extends AsyncHandler {} diff --git a/src/authorization/UnionPermissionReader.ts b/src/authorization/UnionPermissionReader.ts new file mode 100644 index 0000000000..71c4e87fae --- /dev/null +++ b/src/authorization/UnionPermissionReader.ts @@ -0,0 +1,40 @@ +import type { CredentialGroup } from '../authentication/Credentials'; +import type { Permission, PermissionSet } from '../ldp/permissions/Permissions'; +import { UnionHandler } from '../util/handlers/UnionHandler'; +import type { PermissionReader } from './PermissionReader'; + +/** + * Combines the results of multiple PermissionReaders. + * Every permission in every credential type is handled according to the rule `false` \> `true` \> `undefined`. + */ +export class UnionPermissionReader extends UnionHandler { + public constructor(readers: PermissionReader[]) { + super(readers); + } + + protected async combine(results: PermissionSet[]): Promise { + 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]); + } + } + return result; + } + + /** + * Adds the given permissions to the result object according to the combination rules of the class. + */ + private applyPermissions(permissions?: Permission, result: Permission = {}): Permission { + if (!permissions) { + return result; + } + + for (const [ key, value ] of Object.entries(permissions) as [ keyof Permission, boolean | undefined ][]) { + if (typeof value !== 'undefined' && result[key] !== false) { + result[key] = value; + } + } + return result; + } +} diff --git a/src/authorization/WebAclAuthorization.ts b/src/authorization/WebAclAuthorization.ts index 6d8be837b7..29eeceb36c 100644 --- a/src/authorization/WebAclAuthorization.ts +++ b/src/authorization/WebAclAuthorization.ts @@ -1,4 +1,4 @@ -import type { PermissionSet } from '../ldp/permissions/PermissionSet'; +import type { Permission } from '../ldp/permissions/Permissions'; import type { RepresentationMetadata } from '../ldp/representation/RepresentationMetadata'; import { ACL, AUTH } from '../util/Vocabularies'; import type { Authorization } from './Authorization'; @@ -10,19 +10,20 @@ export class WebAclAuthorization implements Authorization { /** * Permissions granted to the agent requesting the resource. */ - public user: PermissionSet; + public user: Permission; /** * Permissions granted to the public. */ - public everyone: PermissionSet; + public everyone: Permission; - public constructor(user: PermissionSet, everyone: PermissionSet) { + public constructor(user: Permission, everyone: Permission) { this.user = user; this.everyone = everyone; } public addMetadata(metadata: RepresentationMetadata): void { - for (const mode of (Object.keys(this.user) as (keyof PermissionSet)[])) { + const modes = new Set([ ...Object.keys(this.user), ...Object.keys(this.everyone) ] as (keyof Permission)[]); + for (const mode of modes) { const capitalizedMode = mode.charAt(0).toUpperCase() + mode.slice(1) as 'Read' | 'Write' | 'Append' | 'Control'; if (this.user[mode]) { metadata.add(AUTH.terms.userMode, ACL.terms[capitalizedMode]); diff --git a/src/authorization/WebAclAuthorizer.ts b/src/authorization/WebAclReader.ts similarity index 68% rename from src/authorization/WebAclAuthorizer.ts rename to src/authorization/WebAclReader.ts index d6093004cc..1853d38746 100644 --- a/src/authorization/WebAclAuthorizer.ts +++ b/src/authorization/WebAclReader.ts @@ -1,9 +1,10 @@ import type { Quad, Term } from 'n3'; import { Store } from 'n3'; +import { CredentialGroup } from '../authentication/Credentials'; import type { Credential, CredentialSet } from '../authentication/Credentials'; import type { AuxiliaryIdentifierStrategy } from '../ldp/auxiliary/AuxiliaryIdentifierStrategy'; -import type { PermissionSet } from '../ldp/permissions/PermissionSet'; -import { AccessMode } from '../ldp/permissions/PermissionSet'; +import type { Permission, PermissionSet } from '../ldp/permissions/Permissions'; +import { AccessMode } from '../ldp/permissions/Permissions'; import type { Representation } from '../ldp/representation/Representation'; import type { ResourceIdentifier } from '../ldp/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; @@ -14,14 +15,12 @@ import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; import { InternalServerError } from '../util/errors/InternalServerError'; import { NotFoundHttpError } from '../util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; -import { UnauthorizedHttpError } from '../util/errors/UnauthorizedHttpError'; import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; import { readableToQuads } from '../util/StreamUtil'; import { ACL, RDF } from '../util/Vocabularies'; import type { AccessChecker } from './access-checkers/AccessChecker'; -import type { AuthorizerInput } from './Authorizer'; -import { Authorizer } from './Authorizer'; -import { WebAclAuthorization } from './WebAclAuthorization'; +import type { PermissionReaderInput } from './PermissionReader'; +import { PermissionReader } from './PermissionReader'; const modesMap: Record = { [ACL.Read]: AccessMode.read, @@ -31,10 +30,10 @@ const modesMap: Record = { } as const; /** - * Handles authorization according to the WAC specification. + * Handles permissions according to the WAC specification. * Specific access checks are done by the provided {@link AccessChecker}. */ -export class WebAclAuthorizer extends Authorizer { +export class WebAclReader extends PermissionReader { protected readonly logger = getLoggerFor(this); private readonly aclStrategy: AuxiliaryIdentifierStrategy; @@ -51,7 +50,7 @@ export class WebAclAuthorizer extends Authorizer { this.accessChecker = accessChecker; } - public async canHandle({ identifier }: AuthorizerInput): Promise { + public async canHandle({ identifier }: PermissionReaderInput): Promise { if (this.aclStrategy.isAuxiliaryIdentifier(identifier)) { throw new NotImplementedHttpError('WebAclAuthorizer does not support permissions on auxiliary resources.'); } @@ -62,30 +61,14 @@ export class WebAclAuthorizer extends Authorizer { * Will throw an error if this is not the case. * @param input - Relevant data needed to check if access can be granted. */ - public async handle({ identifier, modes, credentials }: AuthorizerInput): - Promise { - const modeString = [ ...modes ].join(','); - this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`); + public async handle({ identifier, credentials }: PermissionReaderInput): + Promise { + // Determine the required access modes + this.logger.debug(`Retrieving permissions of ${credentials.agent?.webId} for ${identifier.path}`); // Determine the full authorization for the agent granted by the applicable ACL const acl = await this.getAclRecursive(identifier); - const authorization = await this.createAuthorization(credentials, acl); - - // Verify that the authorization allows all required modes - const agent = credentials.agent ?? credentials.public ?? {}; - for (const mode of modes) { - this.requirePermission(agent, authorization, mode); - } - this.logger.debug(`${agent.webId} has ${modeString} permissions for ${identifier.path}`); - return authorization; - } - - /** - * Checks whether the agent is authenticated (logged in) or not (public/anonymous). - * @param agent - Agent whose credentials will be checked. - */ - private isAuthenticated(agent: Credential): agent is ({ webId: string }) { - return typeof agent.webId === 'string'; + return this.createPermissions(credentials, acl); } /** @@ -93,18 +76,15 @@ export class WebAclAuthorizer extends Authorizer { * @param credentials - Credentials to check permissions for. * @param acl - Store containing all relevant authorization triples. */ - private async createAuthorization(credentials: CredentialSet, acl: Store): - Promise { + private async createPermissions(credentials: CredentialSet, acl: Store): + Promise { const publicPermissions = await this.determinePermissions(acl, credentials.public); const agentPermissions = await this.determinePermissions(acl, credentials.agent); - // Agent at least has the public permissions - // This can be relevant when no agent is provided - for (const [ key, value ] of Object.entries(agentPermissions) as [keyof PermissionSet, boolean][]) { - agentPermissions[key] = value || publicPermissions[key]; - } - - return new WebAclAuthorization(agentPermissions, publicPermissions); + return { + [CredentialGroup.agent]: agentPermissions, + [CredentialGroup.public]: publicPermissions, + }; } /** @@ -113,13 +93,8 @@ export class WebAclAuthorizer extends Authorizer { * @param acl - Store containing all relevant authorization triples. * @param credentials - Credentials to find the permissions for. */ - private async determinePermissions(acl: Store, credentials?: Credential): Promise { - const permissions = { - read: false, - write: false, - append: false, - control: false, - }; + private async determinePermissions(acl: Store, credentials?: Credential): Promise { + const permissions: Permission = {}; if (!credentials) { return permissions; } @@ -127,7 +102,7 @@ export class WebAclAuthorizer extends Authorizer { // Apply all ACL rules const aclRules = acl.getSubjects(RDF.type, ACL.Authorization, null); for (const rule of aclRules) { - const hasAccess = await this.accessChecker.handleSafe({ acl, rule, credentials }); + const hasAccess = await this.accessChecker.handleSafe({ acl, rule, credential: credentials }); if (hasAccess) { // Set all allowed modes to true const modes = acl.getObjects(rule, ACL.mode, null); @@ -147,29 +122,6 @@ export class WebAclAuthorizer extends Authorizer { return permissions; } - /** - * Checks if the authorization grants the agent permission to use the given mode. - * Throws a {@link ForbiddenHttpError} or {@link UnauthorizedHttpError} depending on the credentials - * if access is not allowed. - * @param agent - Agent that wants access. - * @param authorization - An Authorization containing the permissions the agent has on the resource. - * @param mode - Which mode is requested. - */ - private requirePermission(agent: Credential, authorization: WebAclAuthorization, mode: keyof PermissionSet): void { - if (!authorization.user[mode]) { - if (this.isAuthenticated(agent)) { - this.logger.warn(`Agent ${agent.webId} has no ${mode} permissions`); - throw new ForbiddenHttpError(); - } else { - // Solid, §2.1: "When a client does not provide valid credentials when requesting a resource that requires it, - // the data pod MUST send a response with a 401 status code (unless 404 is preferred for security reasons)." - // https://solid.github.io/specification/protocol#http-server - this.logger.warn(`Unauthenticated agent has no ${mode} permissions`); - throw new UnauthorizedHttpError(); - } - } - } - /** * Returns the ACL triples that are relevant for the given identifier. * These can either be from a corresponding ACL document or an ACL document higher up with defaults. diff --git a/src/authorization/access-checkers/AccessChecker.ts b/src/authorization/access-checkers/AccessChecker.ts index f9a5a314fa..dfbc7b66ca 100644 --- a/src/authorization/access-checkers/AccessChecker.ts +++ b/src/authorization/access-checkers/AccessChecker.ts @@ -19,7 +19,7 @@ export interface AccessCheckerArgs { rule: Term; /** - * Credentials of the entity that wants to use the resource. + * Credential of the entity that wants to use the resource. */ - credentials: Credential; + credential: Credential; } diff --git a/src/authorization/access-checkers/AgentAccessChecker.ts b/src/authorization/access-checkers/AgentAccessChecker.ts index c5d9a6c43c..fa58db53c4 100644 --- a/src/authorization/access-checkers/AgentAccessChecker.ts +++ b/src/authorization/access-checkers/AgentAccessChecker.ts @@ -6,9 +6,9 @@ import { AccessChecker } from './AccessChecker'; * Checks if the given WebID has been given access. */ export class AgentAccessChecker extends AccessChecker { - public async handle({ acl, rule, credentials }: AccessCheckerArgs): Promise { - if (typeof credentials.webId === 'string') { - return acl.countQuads(rule, ACL.terms.agent, credentials.webId, null) !== 0; + public async handle({ acl, rule, credential }: AccessCheckerArgs): Promise { + if (typeof credential.webId === 'string') { + return acl.countQuads(rule, ACL.terms.agent, credential.webId, null) !== 0; } return false; } diff --git a/src/authorization/access-checkers/AgentClassAccessChecker.ts b/src/authorization/access-checkers/AgentClassAccessChecker.ts index 2866b663d6..2d2a7483ac 100644 --- a/src/authorization/access-checkers/AgentClassAccessChecker.ts +++ b/src/authorization/access-checkers/AgentClassAccessChecker.ts @@ -6,13 +6,13 @@ import { AccessChecker } from './AccessChecker'; * Checks access based on the agent class. */ export class AgentClassAccessChecker extends AccessChecker { - public async handle({ acl, rule, credentials }: AccessCheckerArgs): Promise { + public async handle({ acl, rule, credential }: AccessCheckerArgs): Promise { // Check if unauthenticated agents have access if (acl.countQuads(rule, ACL.terms.agentClass, FOAF.terms.Agent, null) !== 0) { return true; } // Check if the agent is authenticated and if authenticated agents have access - if (typeof credentials.webId === 'string') { + if (typeof credential.webId === 'string') { return acl.countQuads(rule, ACL.terms.agentClass, ACL.terms.AuthenticatedAgent, null) !== 0; } return false; diff --git a/src/authorization/access-checkers/AgentGroupAccessChecker.ts b/src/authorization/access-checkers/AgentGroupAccessChecker.ts index cff0ec73f6..47baae7dc7 100644 --- a/src/authorization/access-checkers/AgentGroupAccessChecker.ts +++ b/src/authorization/access-checkers/AgentGroupAccessChecker.ts @@ -32,9 +32,9 @@ export class AgentGroupAccessChecker extends AccessChecker { this.expiration = expiration * 1000; } - public async handle({ acl, rule, credentials }: AccessCheckerArgs): Promise { - if (typeof credentials.webId === 'string') { - const { webId } = credentials; + public async handle({ acl, rule, credential }: AccessCheckerArgs): Promise { + if (typeof credential.webId === 'string') { + const { webId } = credential; const groups = acl.getObjects(rule, ACL.terms.agentGroup, null); return await promiseSome(groups.map(async(group: Term): Promise => diff --git a/src/index.ts b/src/index.ts index c79d967973..9b2c9b17e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,14 +9,16 @@ export * from './authentication/UnsecureConstantCredentialsExtractor'; export * from './authentication/UnsecureWebIdExtractor'; // Authorization -export * from './authorization/AllowAllAuthorizer'; +export * from './authorization/AllStaticReader'; export * from './authorization/Authorization'; export * from './authorization/Authorizer'; -export * from './authorization/AuxiliaryAuthorizer'; -export * from './authorization/DenyAllAuthorizer'; -export * from './authorization/PathBasedAuthorizer'; +export * from './authorization/AuxiliaryReader'; +export * from './authorization/PathBasedReader'; +export * from './authorization/PermissionBasedAuthorizer'; +export * from './authorization/PermissionReader'; +export * from './authorization/UnionPermissionReader'; export * from './authorization/WebAclAuthorization'; -export * from './authorization/WebAclAuthorizer'; +export * from './authorization/WebAclReader'; // Authorization/access-checkers export * from './authorization/access-checkers/AccessChecker'; @@ -151,7 +153,7 @@ export * from './ldp/operations/PutOperationHandler'; // LDP/Permissions export * from './ldp/permissions/AclModesExtractor'; -export * from './ldp/permissions/PermissionSet'; +export * from './ldp/permissions/Permissions'; export * from './ldp/permissions/ModesExtractor'; export * from './ldp/permissions/MethodModesExtractor'; export * from './ldp/permissions/SparqlPatchModesExtractor'; diff --git a/src/ldp/http/metadata/WacAllowMetadataWriter.ts b/src/ldp/http/metadata/WacAllowMetadataWriter.ts index 92c20e306e..a02a89ca14 100644 --- a/src/ldp/http/metadata/WacAllowMetadataWriter.ts +++ b/src/ldp/http/metadata/WacAllowMetadataWriter.ts @@ -13,14 +13,17 @@ import { MetadataWriter } from './MetadataWriter'; */ export class WacAllowMetadataWriter extends MetadataWriter { public async handle(input: { response: HttpResponse; metadata: RepresentationMetadata }): Promise { - const userModes = input.metadata.getAll(AUTH.terms.userMode).map(this.aclToPermission); - const publicModes = input.metadata.getAll(AUTH.terms.publicMode).map(this.aclToPermission); + let userModes = new Set(input.metadata.getAll(AUTH.terms.userMode).map(this.aclToPermission)); + const publicModes = new Set(input.metadata.getAll(AUTH.terms.publicMode).map(this.aclToPermission)); + + // Public access implies user access + userModes = new Set([ ...userModes, ...publicModes ]); const headerStrings: string[] = []; - if (userModes.length > 0) { + if (userModes.size > 0) { headerStrings.push(this.createAccessParam('user', userModes)); } - if (publicModes.length > 0) { + if (publicModes.size > 0) { headerStrings.push(this.createAccessParam('public', publicModes)); } @@ -34,7 +37,8 @@ export class WacAllowMetadataWriter extends MetadataWriter { return aclTerm.value.slice(ACL.namespace.length).toLowerCase(); } - private createAccessParam(name: string, modes: string[]): string { - return `${name}="${modes.join(' ')}"`; + private createAccessParam(name: string, modes: Set): string { + // Sort entries to have consistent output + return `${name}="${[ ...modes ].sort((left, right): number => left.localeCompare(right)).join(' ')}"`; } } diff --git a/src/ldp/permissions/AclModesExtractor.ts b/src/ldp/permissions/AclModesExtractor.ts index 6b4cee9560..fc9c8497ba 100644 --- a/src/ldp/permissions/AclModesExtractor.ts +++ b/src/ldp/permissions/AclModesExtractor.ts @@ -2,7 +2,7 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpErr import type { AuxiliaryIdentifierStrategy } from '../auxiliary/AuxiliaryIdentifierStrategy'; import type { Operation } from '../operations/Operation'; import { ModesExtractor } from './ModesExtractor'; -import { AccessMode } from './PermissionSet'; +import { AccessMode } from './Permissions'; export class AclModesExtractor extends ModesExtractor { private readonly aclStrategy: AuxiliaryIdentifierStrategy; diff --git a/src/ldp/permissions/MethodModesExtractor.ts b/src/ldp/permissions/MethodModesExtractor.ts index c21c75bb61..bf18fd0db4 100644 --- a/src/ldp/permissions/MethodModesExtractor.ts +++ b/src/ldp/permissions/MethodModesExtractor.ts @@ -1,7 +1,7 @@ import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; import type { Operation } from '../operations/Operation'; import { ModesExtractor } from './ModesExtractor'; -import { AccessMode } from './PermissionSet'; +import { AccessMode } from './Permissions'; const READ_METHODS = new Set([ 'GET', 'HEAD' ]); const WRITE_METHODS = new Set([ 'PUT', 'DELETE' ]); diff --git a/src/ldp/permissions/ModesExtractor.ts b/src/ldp/permissions/ModesExtractor.ts index f039b93313..f1a9d8517a 100644 --- a/src/ldp/permissions/ModesExtractor.ts +++ b/src/ldp/permissions/ModesExtractor.ts @@ -1,5 +1,5 @@ import { AsyncHandler } from '../../util/handlers/AsyncHandler'; import type { Operation } from '../operations/Operation'; -import type { AccessMode } from './PermissionSet'; +import type { AccessMode } from './Permissions'; export abstract class ModesExtractor extends AsyncHandler> {} diff --git a/src/ldp/permissions/PermissionSet.ts b/src/ldp/permissions/Permissions.ts similarity index 54% rename from src/ldp/permissions/PermissionSet.ts rename to src/ldp/permissions/Permissions.ts index 7140f4c942..ff574130c4 100644 --- a/src/ldp/permissions/PermissionSet.ts +++ b/src/ldp/permissions/Permissions.ts @@ -1,3 +1,5 @@ +import type { CredentialGroup } from '../../authentication/Credentials'; + /** * Different modes that require permission. */ @@ -11,4 +13,6 @@ export enum AccessMode { /** * A data interface indicating which permissions are required (based on the context). */ -export type PermissionSet = Record; +export type Permission = Partial>; + +export type PermissionSet = Partial>; diff --git a/src/ldp/permissions/SparqlPatchModesExtractor.ts b/src/ldp/permissions/SparqlPatchModesExtractor.ts index 121028928f..6c891160d8 100644 --- a/src/ldp/permissions/SparqlPatchModesExtractor.ts +++ b/src/ldp/permissions/SparqlPatchModesExtractor.ts @@ -4,7 +4,7 @@ import type { SparqlUpdatePatch } from '../http/SparqlUpdatePatch'; import type { Operation } from '../operations/Operation'; import type { Representation } from '../representation/Representation'; import { ModesExtractor } from './ModesExtractor'; -import { AccessMode } from './PermissionSet'; +import { AccessMode } from './Permissions'; export class SparqlPatchModesExtractor extends ModesExtractor { public async canHandle({ method, body }: Operation): Promise { diff --git a/test/integration/LdpHandlerWithAuth.test.ts b/test/integration/LdpHandlerWithAuth.test.ts index 14740d2f8a..17e8f19ee0 100644 --- a/test/integration/LdpHandlerWithAuth.test.ts +++ b/test/integration/LdpHandlerWithAuth.test.ts @@ -89,7 +89,7 @@ describe.each(stores)('An LDP handler with auth using %s', (name, { storeConfig, // GET const response = await getResource(document); await expect(response.text()).resolves.toBe('TESTDATA'); - expect(response.headers.get('wac-allow')).toBe('user="read write append",public="read write append"'); + expect(response.headers.get('wac-allow')).toBe('user="append read write",public="append read write"'); // DELETE await deleteResource(document); diff --git a/test/unit/authorization/AllStaticReader.test.ts b/test/unit/authorization/AllStaticReader.test.ts new file mode 100644 index 0000000000..6767aff1b4 --- /dev/null +++ b/test/unit/authorization/AllStaticReader.test.ts @@ -0,0 +1,34 @@ +import { CredentialGroup } from '../../../src/authentication/Credentials'; +import { AllStaticReader } from '../../../src/authorization/AllStaticReader'; +import type { Permission } from '../../../src/ldp/permissions/Permissions'; + +function getPermissions(allow: boolean): Permission { + return { + read: allow, + write: allow, + append: allow, + control: allow, + }; +} + +describe('An AllStaticReader', (): void => { + const credentials = { [CredentialGroup.agent]: {}, [CredentialGroup.public]: undefined }; + const identifier = { path: 'http://test.com/resource' }; + + it('can handle everything.', async(): Promise => { + const authorizer = new AllStaticReader(true); + await expect(authorizer.canHandle({} as any)).resolves.toBeUndefined(); + }); + + it('always returns permissions matching the given allow parameter.', async(): Promise => { + let authorizer = new AllStaticReader(true); + await expect(authorizer.handle({ credentials, identifier })).resolves.toEqual({ + [CredentialGroup.agent]: getPermissions(true), + }); + + authorizer = new AllStaticReader(false); + await expect(authorizer.handle({ credentials, identifier })).resolves.toEqual({ + [CredentialGroup.agent]: getPermissions(false), + }); + }); +}); diff --git a/test/unit/authorization/AllowAllAuthorizer.test.ts b/test/unit/authorization/AllowAllAuthorizer.test.ts deleted file mode 100644 index e55366d900..0000000000 --- a/test/unit/authorization/AllowAllAuthorizer.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AllowAllAuthorizer } from '../../../src/authorization/AllowAllAuthorizer'; -import type { PermissionSet } from '../../../src/ldp/permissions/PermissionSet'; - -describe('An AllowAllAuthorizer', (): void => { - const authorizer = new AllowAllAuthorizer(); - const allowAll: PermissionSet = { - read: true, - write: true, - append: true, - control: true, - }; - - it('can handle everything.', async(): Promise => { - await expect(authorizer.canHandle({} as any)).resolves.toBeUndefined(); - }); - - it('always returns a full access Authorization.', async(): Promise => { - await expect(authorizer.handle()).resolves.toEqual({ - user: allowAll, - everyone: allowAll, - }); - }); -}); diff --git a/test/unit/authorization/AuxiliaryAuthorizer.test.ts b/test/unit/authorization/AuxiliaryReader.test.ts similarity index 54% rename from test/unit/authorization/AuxiliaryAuthorizer.test.ts rename to test/unit/authorization/AuxiliaryReader.test.ts index a0db827374..f2c607475a 100644 --- a/test/unit/authorization/AuxiliaryAuthorizer.test.ts +++ b/test/unit/authorization/AuxiliaryReader.test.ts @@ -1,27 +1,26 @@ -import type { Authorizer } from '../../../src/authorization/Authorizer'; -import { AuxiliaryAuthorizer } from '../../../src/authorization/AuxiliaryAuthorizer'; +import { CredentialGroup } from '../../../src/authentication/Credentials'; +import { AuxiliaryReader } from '../../../src/authorization/AuxiliaryReader'; +import type { PermissionReader } from '../../../src/authorization/PermissionReader'; import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy'; -import { AccessMode } from '../../../src/ldp/permissions/PermissionSet'; +import type { PermissionSet } from '../../../src/ldp/permissions/Permissions'; import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; -describe('An AuxiliaryAuthorizer', (): void => { +describe('An AuxiliaryReader', (): void => { const suffix = '.dummy'; const credentials = {}; const associatedIdentifier = { path: 'http://test.com/foo' }; const auxiliaryIdentifier = { path: 'http://test.com/foo.dummy' }; - let modes: Set; - let source: Authorizer; + const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }}; + let source: PermissionReader; let strategy: AuxiliaryIdentifierStrategy; - let authorizer: AuxiliaryAuthorizer; + let reader: AuxiliaryReader; beforeEach(async(): Promise => { - modes = new Set([ AccessMode.read, AccessMode.write, AccessMode.append ]); - source = { canHandle: jest.fn(), - handle: jest.fn(), - handleSafe: jest.fn(), + handle: jest.fn().mockResolvedValue(permissionSet), + handleSafe: jest.fn().mockResolvedValue(permissionSet), }; strategy = { @@ -29,43 +28,43 @@ describe('An AuxiliaryAuthorizer', (): void => { getAssociatedIdentifier: jest.fn((identifier: ResourceIdentifier): ResourceIdentifier => ({ path: identifier.path.slice(0, -suffix.length) })), } as any; - authorizer = new AuxiliaryAuthorizer(source, strategy); + reader = new AuxiliaryReader(source, strategy); }); it('can handle auxiliary resources if the source supports the associated resource.', async(): Promise => { - await expect(authorizer.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) + await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials })) .resolves.toBeUndefined(); expect(source.canHandle).toHaveBeenLastCalledWith( - { identifier: associatedIdentifier, credentials, modes }, + { identifier: associatedIdentifier, credentials }, ); - await expect(authorizer.canHandle({ identifier: associatedIdentifier, credentials, modes })) + await expect(reader.canHandle({ identifier: associatedIdentifier, credentials })) .rejects.toThrow(NotImplementedHttpError); source.canHandle = jest.fn().mockRejectedValue(new Error('no source support')); - await expect(authorizer.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) + await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials })) .rejects.toThrow('no source support'); }); it('handles resources by sending the updated parameters to the source.', async(): Promise => { - await expect(authorizer.handle({ identifier: auxiliaryIdentifier, credentials, modes })) - .resolves.toBeUndefined(); + await expect(reader.handle({ identifier: auxiliaryIdentifier, credentials })) + .resolves.toBe(permissionSet); expect(source.handle).toHaveBeenLastCalledWith( - { identifier: associatedIdentifier, credentials, modes }, + { identifier: associatedIdentifier, credentials }, ); // Safety checks are not present when calling `handle` - await expect(authorizer.handle({ identifier: associatedIdentifier, credentials, modes })) + await expect(reader.handle({ identifier: associatedIdentifier, credentials })) .rejects.toThrow(NotImplementedHttpError); }); it('combines both checking and handling when calling handleSafe.', async(): Promise => { - await expect(authorizer.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes })) - .resolves.toBeUndefined(); + await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials })) + .resolves.toBe(permissionSet); expect(source.handleSafe).toHaveBeenLastCalledWith( - { identifier: associatedIdentifier, credentials, modes }, + { identifier: associatedIdentifier, credentials }, ); - await expect(authorizer.handleSafe({ identifier: associatedIdentifier, credentials, modes })) + await expect(reader.handleSafe({ identifier: associatedIdentifier, credentials })) .rejects.toThrow(NotImplementedHttpError); source.handleSafe = jest.fn().mockRejectedValue(new Error('no source support')); - await expect(authorizer.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes })) + await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials })) .rejects.toThrow('no source support'); }); }); diff --git a/test/unit/authorization/DenyAllAuthorizer.test.ts b/test/unit/authorization/DenyAllAuthorizer.test.ts deleted file mode 100644 index 730d7095e3..0000000000 --- a/test/unit/authorization/DenyAllAuthorizer.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DenyAllAuthorizer } from '../../../src/authorization/DenyAllAuthorizer'; -import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; - -describe('A DenyAllAuthorizer', (): void => { - const authorizer = new DenyAllAuthorizer(); - - it('can handle all requests.', async(): Promise => { - await expect(authorizer.canHandle({} as any)).resolves.toBeUndefined(); - }); - - it('rejects all requests.', async(): Promise => { - await expect(authorizer.handle()).rejects.toThrow(ForbiddenHttpError); - }); -}); diff --git a/test/unit/authorization/PathBasedAuthorizer.test.ts b/test/unit/authorization/PathBasedAuthorizer.test.ts deleted file mode 100644 index c62f875e59..0000000000 --- a/test/unit/authorization/PathBasedAuthorizer.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Authorizer, AuthorizerInput } from '../../../src/authorization/Authorizer'; -import { PathBasedAuthorizer } from '../../../src/authorization/PathBasedAuthorizer'; -import { AccessMode } from '../../../src/ldp/permissions/PermissionSet'; -import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; - -describe('A PathBasedAuthorizer', (): void => { - const baseUrl = 'http://test.com/foo/'; - let input: AuthorizerInput; - let authorizers: jest.Mocked[]; - let authorizer: PathBasedAuthorizer; - - beforeEach(async(): Promise => { - input = { - identifier: { path: `${baseUrl}first` }, - modes: new Set([ AccessMode.read ]), - credentials: {}, - }; - - authorizers = [ - { canHandle: jest.fn(), handle: jest.fn() }, - { canHandle: jest.fn(), handle: jest.fn() }, - ] as any; - const paths = { - '/first': authorizers[0], - '/second': authorizers[1], - }; - authorizer = new PathBasedAuthorizer(baseUrl, paths); - }); - - it('can only handle requests with a matching path.', async(): Promise => { - input.identifier.path = 'http://wrongsite/'; - await expect(authorizer.canHandle(input)).rejects.toThrow(NotImplementedHttpError); - input.identifier.path = `${baseUrl}third`; - await expect(authorizer.canHandle(input)).rejects.toThrow(NotImplementedHttpError); - input.identifier.path = `${baseUrl}first`; - await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); - input.identifier.path = `${baseUrl}second`; - await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); - }); - - it('can only handle requests supported by the stored authorizers.', async(): Promise => { - await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); - authorizers[0].canHandle.mockRejectedValueOnce(new Error('not supported')); - await expect(authorizer.canHandle(input)).rejects.toThrow('not supported'); - }); - - it('passes the handle requests to the matching authorizer.', async(): Promise => { - await expect(authorizer.handle(input)).resolves.toBeUndefined(); - expect(authorizers[0].handle).toHaveBeenCalledTimes(1); - expect(authorizers[0].handle).toHaveBeenLastCalledWith(input); - }); -}); diff --git a/test/unit/authorization/PathBasedReader.test.ts b/test/unit/authorization/PathBasedReader.test.ts new file mode 100644 index 0000000000..f21853f990 --- /dev/null +++ b/test/unit/authorization/PathBasedReader.test.ts @@ -0,0 +1,53 @@ +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/ldp/permissions/Permissions'; +import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; + +describe('A PathBasedReader', (): void => { + const baseUrl = 'http://test.com/foo/'; + const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }}; + let input: PermissionReaderInput; + let readers: jest.Mocked[]; + let reader: PathBasedReader; + + beforeEach(async(): Promise => { + input = { + identifier: { path: `${baseUrl}first` }, + credentials: {}, + }; + + readers = [ + { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) }, + { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) }, + ] as any; + const paths = { + '/first': readers[0], + '/second': readers[1], + }; + reader = new PathBasedReader(baseUrl, paths); + }); + + it('can only handle requests with a matching path.', async(): Promise => { + 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 => { + 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 => { + await expect(reader.handle(input)).resolves.toBe(permissionSet); + expect(readers[0].handle).toHaveBeenCalledTimes(1); + expect(readers[0].handle).toHaveBeenLastCalledWith(input); + }); +}); diff --git a/test/unit/authorization/PermissionBasedAuthorizer.test.ts b/test/unit/authorization/PermissionBasedAuthorizer.test.ts new file mode 100644 index 0000000000..9381daaf6c --- /dev/null +++ b/test/unit/authorization/PermissionBasedAuthorizer.test.ts @@ -0,0 +1,71 @@ +import { CredentialGroup } from '../../../src/authentication/Credentials'; +import type { AuthorizerInput } from '../../../src/authorization/Authorizer'; +import { PermissionBasedAuthorizer } from '../../../src/authorization/PermissionBasedAuthorizer'; +import type { PermissionReader } from '../../../src/authorization/PermissionReader'; +import { WebAclAuthorization } from '../../../src/authorization/WebAclAuthorization'; +import { AccessMode } from '../../../src/ldp/permissions/Permissions'; +import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; +import { UnauthorizedHttpError } from '../../../src/util/errors/UnauthorizedHttpError'; + +describe('A PermissionBasedAuthorizer', (): void => { + let input: AuthorizerInput; + let authorization: WebAclAuthorization; + let reader: jest.Mocked; + let authorizer: PermissionBasedAuthorizer; + + beforeEach(async(): Promise => { + input = { + identifier: { path: 'http://test.com/foo' }, + modes: new Set(), + credentials: {}, + }; + + authorization = new WebAclAuthorization({}, {}); + + reader = { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue({}), + } as any; + + authorizer = new PermissionBasedAuthorizer(reader); + }); + + it('can handle any input supported by its reader.', async(): Promise => { + await expect(authorizer.canHandle(input)).resolves.toBeUndefined(); + + reader.canHandle.mockRejectedValue(new Error('bad request')); + await expect(authorizer.canHandle(input)).rejects.toThrow('bad request'); + }); + + it('allows access if the permissions are matched by the reader output.', async(): Promise => { + input.modes = new Set([ AccessMode.read, AccessMode.write ]); + reader.handle.mockResolvedValueOnce({ + [CredentialGroup.public]: { read: true, write: false }, + [CredentialGroup.agent]: { write: true }, + }); + Object.assign(authorization.everyone, { read: true, write: false }); + Object.assign(authorization.user, { write: true }); + await expect(authorizer.handle(input)).resolves.toEqual(authorization); + }); + + it('throws an UnauthorizedHttpError when an unauthenticated request has no access.', async(): Promise => { + input.modes = new Set([ AccessMode.read, AccessMode.write ]); + reader.handle.mockResolvedValueOnce({ + [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 => { + input.credentials = { agent: { webId: 'http://test.com/#me' }}; + input.modes = new Set([ AccessMode.read, AccessMode.write ]); + reader.handle.mockResolvedValueOnce({ + [CredentialGroup.public]: { read: true, write: false }, + }); + await expect(authorizer.handle(input)).rejects.toThrow(ForbiddenHttpError); + }); + + it('defaults to empty permissions for the Authorization.', async(): Promise => { + await expect(authorizer.handle(input)).resolves.toEqual(authorization); + }); +}); diff --git a/test/unit/authorization/UnionPermissionReader.test.ts b/test/unit/authorization/UnionPermissionReader.test.ts new file mode 100644 index 0000000000..1d52fc269d --- /dev/null +++ b/test/unit/authorization/UnionPermissionReader.test.ts @@ -0,0 +1,56 @@ +import { CredentialGroup } from '../../../src/authentication/Credentials'; +import type { PermissionReader, PermissionReaderInput } from '../../../src/authorization/PermissionReader'; +import { UnionPermissionReader } from '../../../src/authorization/UnionPermissionReader'; + +describe('A UnionPermissionReader', (): void => { + const input: PermissionReaderInput = { credentials: {}, identifier: { path: 'http://test.com/foo' }}; + let readers: jest.Mocked[]; + let unionReader: UnionPermissionReader; + + beforeEach(async(): Promise => { + readers = [ + { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue({}), + } as any, + { + canHandle: jest.fn(), + handle: jest.fn().mockResolvedValue({}), + } as any, + ]; + + unionReader = new UnionPermissionReader(readers); + }); + + it('only uses the results of readers that can handle the input.', async(): Promise => { + 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 }}); + }); + + it('combines results.', async(): Promise => { + 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 }, + }); + }); + + it('merges same fields using false > true > undefined.', async(): Promise => { + readers[0].handle.mockResolvedValue( + { [CredentialGroup.agent]: { read: true, write: false, append: undefined, control: true }}, + ); + readers[1].handle.mockResolvedValue( + { [CredentialGroup.agent]: { read: false, write: true, append: true, control: true }}, + ); + await expect(unionReader.handle(input)).resolves.toEqual({ + [CredentialGroup.agent]: { read: false, write: false, append: true, control: true }, + }); + }); +}); diff --git a/test/unit/authorization/WebAclAuthorizer.test.ts b/test/unit/authorization/WebAclReader.test.ts similarity index 53% rename from test/unit/authorization/WebAclAuthorizer.test.ts rename to test/unit/authorization/WebAclReader.test.ts index a0bedf7aea..da951f15b0 100644 --- a/test/unit/authorization/WebAclAuthorizer.test.ts +++ b/test/unit/authorization/WebAclReader.test.ts @@ -2,18 +2,17 @@ import { namedNode, quad } from '@rdfjs/data-model'; import { CredentialGroup } from '../../../src/authentication/Credentials'; import type { CredentialSet } from '../../../src/authentication/Credentials'; import type { AccessChecker } from '../../../src/authorization/access-checkers/AccessChecker'; -import { WebAclAuthorization } from '../../../src/authorization/WebAclAuthorization'; -import { WebAclAuthorizer } from '../../../src/authorization/WebAclAuthorizer'; +import { WebAclReader } from '../../../src/authorization/WebAclReader'; import type { AuxiliaryIdentifierStrategy } from '../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy'; -import { AccessMode } from '../../../src/ldp/permissions/PermissionSet'; +import { BasicRepresentation } from '../../../src/ldp/representation/BasicRepresentation'; import type { Representation } from '../../../src/ldp/representation/Representation'; import type { ResourceIdentifier } from '../../../src/ldp/representation/ResourceIdentifier'; import type { ResourceStore } from '../../../src/storage/ResourceStore'; +import { INTERNAL_QUADS } from '../../../src/util/ContentTypes'; import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; import { InternalServerError } from '../../../src/util/errors/InternalServerError'; import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; -import { UnauthorizedHttpError } from '../../../src/util/errors/UnauthorizedHttpError'; import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; import { guardedStreamFrom } from '../../../src/util/StreamUtil'; @@ -22,8 +21,8 @@ const nn = namedNode; const acl = 'http://www.w3.org/ns/auth/acl#'; const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; -describe('A WebAclAuthorizer', (): void => { - let authorizer: WebAclAuthorizer; +describe('A WebAclReader', (): void => { + let reader: WebAclReader; const aclStrategy: AuxiliaryIdentifierStrategy = { getAuxiliaryIdentifier: (id: ResourceIdentifier): ResourceIdentifier => ({ path: `${id.path}.acl` }), isAuxiliaryIdentifier: (id: ResourceIdentifier): boolean => id.path.endsWith('.acl'), @@ -31,138 +30,143 @@ describe('A WebAclAuthorizer', (): void => { } as any; let store: jest.Mocked; const identifierStrategy = new SingleRootIdentifierStrategy('http://test.com/'); - let modes: Set; let credentials: CredentialSet; let identifier: ResourceIdentifier; - let authorization: WebAclAuthorization; let accessChecker: jest.Mocked; beforeEach(async(): Promise => { - modes = new Set([ AccessMode.read, AccessMode.write ]); credentials = { [CredentialGroup.public]: {}, [CredentialGroup.agent]: {}}; identifier = { path: 'http://test.com/foo' }; - authorization = new WebAclAuthorization( - { - read: false, - append: false, - write: false, - control: false, - }, - { - read: false, - append: false, - write: false, - control: false, - }, - ); store = { - getRepresentation: jest.fn(), + getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + ], INTERNAL_QUADS)), } as any; accessChecker = { handleSafe: jest.fn().mockResolvedValue(true), } as any; - authorizer = new WebAclAuthorizer(aclStrategy, store, identifierStrategy, accessChecker); + reader = new WebAclReader(aclStrategy, store, identifierStrategy, accessChecker); }); it('handles all non-acl inputs.', async(): Promise => { - await expect(authorizer.canHandle({ identifier, credentials, modes })).resolves.toBeUndefined(); - await expect(authorizer.canHandle({ identifier: aclStrategy.getAuxiliaryIdentifier(identifier) } as any)) + await expect(reader.canHandle({ identifier, credentials })).resolves.toBeUndefined(); + await expect(reader.canHandle({ identifier: aclStrategy.getAuxiliaryIdentifier(identifier) } as any)) .rejects.toThrow(NotImplementedHttpError); }); - it('handles all valid modes and ignores other ones.', async(): Promise => { + it('returns undefined permissions for undefined credentials.', async(): Promise => { + credentials = {}; + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: {}, + [CredentialGroup.agent]: {}, + }); + }); + + it('reads the accessTo value of the acl resource.', async(): Promise => { credentials.agent = { webId: 'http://test.com/user' }; store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}fakeMode1`)), ]) } as Representation); - Object.assign(authorization.everyone, { read: true, write: true, append: true, control: false }); - Object.assign(authorization.user, { read: true, write: true, append: true, control: false }); - await expect(authorizer.handle({ identifier, modes, credentials })).resolves.toEqual(authorization); + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: { read: true }, + [CredentialGroup.agent]: { read: true }, + }); }); - it('allows access if the acl file allows all agents.', async(): Promise => { + it('ignores accessTo fields pointing to different resources.', async(): Promise => { + credentials.agent = { webId: 'http://test.com/user' }; store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), - quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), + quad(nn('auth'), nn(`${acl}accessTo`), nn('somewhereElse')), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), ]) } as Representation); - Object.assign(authorization.everyone, { read: true, write: true, append: true }); - Object.assign(authorization.user, { read: true, write: true, append: true }); - await expect(authorizer.handle({ identifier, modes, credentials })).resolves.toEqual(authorization); - }); - - it('allows access if there is a parent acl file allowing all agents.', async(): Promise => { - store.getRepresentation.mockImplementation(async(id: ResourceIdentifier): Promise => { - if (id.path.endsWith('foo.acl')) { - throw new NotFoundHttpError(); - } - return { - data: guardedStreamFrom([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), - quad(nn('auth'), nn(`${acl}default`), nn(identifierStrategy.getParentContainer(identifier).path)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), - quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), - ]), - } as Representation; + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: {}, + [CredentialGroup.agent]: {}, }); - Object.assign(authorization.everyone, { read: true, write: true, append: true }); - Object.assign(authorization.user, { read: true, write: true, append: true }); - await expect(authorizer.handle({ identifier, modes, credentials })).resolves.toEqual(authorization); }); - it('throws a ForbiddenHttpError if access is not granted and credentials have a WebID.', async(): Promise => { - accessChecker.handleSafe.mockResolvedValue(false); + it('handles all valid modes and ignores other ones.', async(): Promise => { + credentials.agent = { webId: 'http://test.com/user' }; store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}fakeMode1`)), ]) } as Representation); - credentials.agent = { webId: 'http://test.com/alice/profile/card#me' }; - await expect(authorizer.handle({ identifier, modes, credentials })).rejects.toThrow(ForbiddenHttpError); + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: { read: true }, + [CredentialGroup.agent]: { read: true }, + }); }); - it('throws an UnauthorizedHttpError if access is not granted there are no credentials.', async(): Promise => { - credentials = {}; - accessChecker.handleSafe.mockResolvedValue(false); - store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ - quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), - quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), - ]) } as Representation); - await expect(authorizer.handle({ identifier, modes, credentials })).rejects.toThrow(UnauthorizedHttpError); + it('reads the default value of a parent if there is no direct acl resource.', async(): Promise => { + store.getRepresentation.mockImplementation(async(id: ResourceIdentifier): Promise => { + if (id.path.endsWith('foo.acl')) { + throw new NotFoundHttpError(); + } + return new BasicRepresentation([ + quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')), + quad(nn('auth'), nn(`${acl}default`), nn(identifierStrategy.getParentContainer(identifier).path)), + quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)), + ], INTERNAL_QUADS); + }); + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: { read: true }, + [CredentialGroup.agent]: { read: true }, + }); }); it('re-throws ResourceStore errors as internal errors.', async(): Promise => { store.getRepresentation.mockRejectedValue(new Error('TEST!')); - const promise = authorizer.handle({ identifier, modes, credentials }); + const promise = reader.handle({ identifier, credentials }); await expect(promise).rejects.toThrow(`Error reading ACL for ${identifier.path}: TEST!`); await expect(promise).rejects.toThrow(InternalServerError); }); it('errors if the root container has no corresponding acl document.', async(): Promise => { store.getRepresentation.mockRejectedValue(new NotFoundHttpError()); - const promise = authorizer.handle({ identifier, modes, credentials }); + const promise = reader.handle({ identifier, credentials }); await expect(promise).rejects.toThrow('No ACL document found for root container'); await expect(promise).rejects.toThrow(ForbiddenHttpError); }); it('allows an agent to append if they have write access.', async(): Promise => { - modes = new Set([ AccessMode.append ]); store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)), quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)), quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Write`)), ]) } as Representation); - Object.assign(authorization.everyone, { write: true, append: true }); - Object.assign(authorization.user, { write: true, append: true }); - await expect(authorizer.handle({ identifier, modes, credentials })).resolves.toEqual(authorization); + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: { write: true, append: true }, + [CredentialGroup.agent]: { write: true, append: true }, + }); + }); + + it('ignores rules where no access is granted.', async(): Promise => { + credentials.agent = { webId: 'http://test.com/user' }; + // CredentialGroup.public gets true on auth1, CredentialGroup.agent on auth2 + accessChecker.handleSafe.mockImplementation(async({ rule, credential: cred }): Promise => + (rule.value === 'auth1') === !cred.webId); + + store.getRepresentation.mockResolvedValue({ data: guardedStreamFrom([ + quad(nn('auth1'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth1'), nn(`${acl}accessTo`), nn(identifier.path)), + quad(nn('auth1'), nn(`${acl}mode`), nn(`${acl}Read`)), + quad(nn('auth2'), nn(`${rdf}type`), nn(`${acl}Authorization`)), + quad(nn('auth2'), nn(`${acl}accessTo`), nn(identifier.path)), + quad(nn('auth2'), nn(`${acl}mode`), nn(`${acl}Control`)), + ]) } as Representation); + + await expect(reader.handle({ identifier, credentials })).resolves.toEqual({ + [CredentialGroup.public]: { read: true }, + [CredentialGroup.agent]: { control: true }, + }); }); }); diff --git a/test/unit/authorization/access-checkers/AgentAccessChecker.test.ts b/test/unit/authorization/access-checkers/AgentAccessChecker.test.ts index a1b02ad8c6..08a028e604 100644 --- a/test/unit/authorization/access-checkers/AgentAccessChecker.test.ts +++ b/test/unit/authorization/access-checkers/AgentAccessChecker.test.ts @@ -16,17 +16,17 @@ describe('A AgentAccessChecker', (): void => { }); it('returns true if a match is found for the given WebID.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('match'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('match'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(true); }); it('returns false if no match is found.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(false); }); it('returns false if the credentials contain no WebID.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('match'), credentials: {}}; + const input: AccessCheckerArgs = { acl, rule: namedNode('match'), credential: {}}; await expect(checker.handle(input)).resolves.toBe(false); }); }); diff --git a/test/unit/authorization/access-checkers/AgentClassAccessChecker.test.ts b/test/unit/authorization/access-checkers/AgentClassAccessChecker.test.ts index b9e6ef285b..2323de911d 100644 --- a/test/unit/authorization/access-checkers/AgentClassAccessChecker.test.ts +++ b/test/unit/authorization/access-checkers/AgentClassAccessChecker.test.ts @@ -16,22 +16,22 @@ describe('An AgentClassAccessChecker', (): void => { }); it('returns true if the rule contains foaf:agent as supported class.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('agentMatch'), credentials: {}}; + const input: AccessCheckerArgs = { acl, rule: namedNode('agentMatch'), credential: {}}; await expect(checker.handle(input)).resolves.toBe(true); }); it('returns true for authenticated users with an acl:AuthenticatedAgent rule.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('authenticatedMatch'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('authenticatedMatch'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(true); }); it('returns false for unauthenticated users with an acl:AuthenticatedAgent rule.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('authenticatedMatch'), credentials: {}}; + const input: AccessCheckerArgs = { acl, rule: namedNode('authenticatedMatch'), credential: {}}; await expect(checker.handle(input)).resolves.toBe(false); }); it('returns false if no class rule is found.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credentials: {}}; + const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credential: {}}; await expect(checker.handle(input)).resolves.toBe(false); }); }); diff --git a/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts b/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts index 363a96fc0d..40f711053a 100644 --- a/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts +++ b/test/unit/authorization/access-checkers/AgentGroupAccessChecker.test.ts @@ -39,22 +39,22 @@ describe('An AgentGroupAccessChecker', (): void => { }); it('returns true if the WebID is a valid group member.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(true); }); it('returns false if the WebID is not a valid group member.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('noMatch'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(false); }); it('returns false if there are no WebID credentials.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credentials: {}}; + const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credential: {}}; await expect(checker.handle(input)).resolves.toBe(false); }); it('caches fetched results.', async(): Promise => { - const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credentials: { webId }}; + const input: AccessCheckerArgs = { acl, rule: namedNode('groupMatch'), credential: { webId }}; await expect(checker.handle(input)).resolves.toBe(true); await expect(checker.handle(input)).resolves.toBe(true); expect(fetchMock).toHaveBeenCalledTimes(1); diff --git a/test/unit/ldp/AuthenticatedLdpHandler.test.ts b/test/unit/ldp/AuthenticatedLdpHandler.test.ts index 56fb5a14fd..c4f57c5ecf 100644 --- a/test/unit/ldp/AuthenticatedLdpHandler.test.ts +++ b/test/unit/ldp/AuthenticatedLdpHandler.test.ts @@ -5,7 +5,7 @@ import { AuthenticatedLdpHandler } from '../../../src/ldp/AuthenticatedLdpHandle import { ResetResponseDescription } from '../../../src/ldp/http/response/ResetResponseDescription'; import type { ResponseDescription } from '../../../src/ldp/http/response/ResponseDescription'; import type { Operation } from '../../../src/ldp/operations/Operation'; -import { AccessMode } from '../../../src/ldp/permissions/PermissionSet'; +import { AccessMode } from '../../../src/ldp/permissions/Permissions'; import type { RepresentationPreferences } from '../../../src/ldp/representation/RepresentationPreferences'; import * as LogUtil from '../../../src/logging/LogUtil'; import type { HttpRequest } from '../../../src/server/HttpRequest'; diff --git a/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts index 55d120a6ca..e8ab30a613 100644 --- a/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts +++ b/test/unit/ldp/http/metadata/WacAllowMetadataWriter.test.ts @@ -40,4 +40,15 @@ describe('A WacAllowMetadataWriter', (): void => { 'wac-allow': 'user="read write"', }); }); + + it('applies public modes to user modes.', async(): Promise => { + const metadata = new RepresentationMetadata({ + [AUTH.publicMode]: [ ACL.terms.Read, ACL.terms.Write ], + }); + await expect(writer.handle({ response, metadata })).resolves.toBeUndefined(); + + expect(response.getHeaders()).toEqual({ + 'wac-allow': 'user="read write",public="read write"', + }); + }); }); diff --git a/test/unit/ldp/permissions/AclModesExtractor.test.ts b/test/unit/ldp/permissions/AclModesExtractor.test.ts index 2ea41e95f1..a65eb5236a 100644 --- a/test/unit/ldp/permissions/AclModesExtractor.test.ts +++ b/test/unit/ldp/permissions/AclModesExtractor.test.ts @@ -1,6 +1,6 @@ import type { AuxiliaryIdentifierStrategy } from '../../../../src/ldp/auxiliary/AuxiliaryIdentifierStrategy'; import { AclModesExtractor } from '../../../../src/ldp/permissions/AclModesExtractor'; -import { AccessMode } from '../../../../src/ldp/permissions/PermissionSet'; +import { AccessMode } from '../../../../src/ldp/permissions/Permissions'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('An AclModesExtractor', (): void => { diff --git a/test/unit/ldp/permissions/MethodModesExtractor.test.ts b/test/unit/ldp/permissions/MethodModesExtractor.test.ts index 02ed933643..7489acadc2 100644 --- a/test/unit/ldp/permissions/MethodModesExtractor.test.ts +++ b/test/unit/ldp/permissions/MethodModesExtractor.test.ts @@ -1,6 +1,6 @@ import type { Operation } from '../../../../src/ldp/operations/Operation'; import { MethodModesExtractor } from '../../../../src/ldp/permissions/MethodModesExtractor'; -import { AccessMode } from '../../../../src/ldp/permissions/PermissionSet'; +import { AccessMode } from '../../../../src/ldp/permissions/Permissions'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; describe('A MethodModesExtractor', (): void => { diff --git a/test/unit/ldp/permissions/SparqlPatchModesExtractor.test.ts b/test/unit/ldp/permissions/SparqlPatchModesExtractor.test.ts index d7373f5e5b..619345f8e4 100644 --- a/test/unit/ldp/permissions/SparqlPatchModesExtractor.test.ts +++ b/test/unit/ldp/permissions/SparqlPatchModesExtractor.test.ts @@ -1,7 +1,7 @@ import { Factory } from 'sparqlalgebrajs'; import type { SparqlUpdatePatch } from '../../../../src/ldp/http/SparqlUpdatePatch'; import type { Operation } from '../../../../src/ldp/operations/Operation'; -import { AccessMode } from '../../../../src/ldp/permissions/PermissionSet'; +import { AccessMode } from '../../../../src/ldp/permissions/Permissions'; import { SparqlPatchModesExtractor } from '../../../../src/ldp/permissions/SparqlPatchModesExtractor'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; diff --git a/test/util/AclHelper.ts b/test/util/AclHelper.ts index b717e18f20..557cc414ba 100644 --- a/test/util/AclHelper.ts +++ b/test/util/AclHelper.ts @@ -1,4 +1,4 @@ -import type { ResourceStore, PermissionSet } from '../../src/'; +import type { ResourceStore, Permission } from '../../src/'; import { BasicRepresentation } from '../../src/'; export class AclHelper { @@ -11,7 +11,7 @@ export class AclHelper { public async setSimpleAcl( resource: string, options: { - permissions: Partial; + permissions: Partial; agentClass?: 'agent' | 'authenticated'; agent?: string; accessTo?: boolean; @@ -32,7 +32,7 @@ export class AclHelper { ]; for (const perm of [ 'Read', 'Append', 'Write', 'Control' ]) { - if (options.permissions[perm.toLowerCase() as keyof PermissionSet]) { + if (options.permissions[perm.toLowerCase() as keyof Permission]) { acl.push(`;\n acl:mode acl:${perm}`); } }