Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions src/authorization/WebAclReader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { PermissionReaderInput } from './PermissionReader';
import { PermissionReader } from './PermissionReader';
import type { AclPermissionSet } from './permissions/AclPermissionSet';
import { AclMode } from './permissions/AclPermissionSet';
import type { PermissionMap } from './permissions/Permissions';
import type { AccessMap, PermissionMap } from './permissions/Permissions';
import { AccessMode } from './permissions/Permissions';

// Maps WebACL-specific modes to generic access modes.
Expand All @@ -45,6 +45,15 @@ export class WebAclReader extends PermissionReader {
private readonly identifierStrategy: IdentifierStrategy;
private readonly accessChecker: AccessChecker;

/**
* Memoizes the credential-independent part of ACL resolution (existence walk + read + parse)
* for the duration of a single request, so the multiple credential-specific passes of one request
* (authorization decision and the WAC-Allow user/public passes) share a single ACL read.
* Keyed by the `requestedModes` object, which the cached `ModesExtractor` produces once per request:
* a different request always has a different key, so this `WeakMap` can never serve a stale ACL.
*/
private readonly statementCache: WeakMap<AccessMap, Promise<Map<Store, ResourceIdentifier[]>>>;

public constructor(
aclStrategy: AuxiliaryIdentifierStrategy,
resourceSet: ResourceSet,
Expand All @@ -58,6 +67,7 @@ export class WebAclReader extends PermissionReader {
this.aclStore = aclStore;
this.identifierStrategy = identifierStrategy;
this.accessChecker = accessChecker;
this.statementCache = new WeakMap();
}

/**
Expand All @@ -67,11 +77,37 @@ export class WebAclReader extends PermissionReader {
public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise<PermissionMap> {
// Determine the required access modes
this.logger.debug(`Retrieving permissions of ${credentials.agent?.webId ?? 'an unknown agent'}`);
const aclMap = await this.getAclMatches(requestedModes.distinctKeys());
const storeMap = await this.findAuthorizationStatements(aclMap);
const storeMap = await this.getAuthorizationStatements(requestedModes);
return this.findPermissions(storeMap, credentials);
}

/**
* Finds the relevant authorization statements for the targets in the given access map,
* reusing the result if it was already resolved for this `requestedModes` object.
* See {@link statementCache} for the request-scoping guarantee.
*
* @param requestedModes - The requested modes whose target resources need authorization statements.
*/
private async getAuthorizationStatements(requestedModes: AccessMap): Promise<Map<Store, ResourceIdentifier[]>> {
const cached = this.statementCache.get(requestedModes);
if (cached) {
return cached;
}
// Caching the promise lets concurrent passes share one in-flight read instead of racing.
const promise = (async(): Promise<Map<Store, ResourceIdentifier[]>> => {
const aclMap = await this.getAclMatches(requestedModes.distinctKeys());
return this.findAuthorizationStatements(aclMap);
})();
this.statementCache.set(requestedModes, promise);
try {
return await promise;
} catch (error: unknown) {
// Do not memoize failures so a transient read error is not served to a later pass.
this.statementCache.delete(requestedModes);
throw error;
}
}

/**
* Finds the permissions in the provided WebACL quads.
*
Expand Down
86 changes: 86 additions & 0 deletions test/unit/authorization/WebAclReader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,90 @@ describe('A WebAclReader', (): void => {
// http://example.com/.acl and http://example.com/bar/.acl
expect(store.getRepresentation).toHaveBeenCalledTimes(2);
});

describe('reusing the effective ACL within a single request', (): void => {
// The same effective ACL gets resolved several times during a single request:
// once for the authorization decision (and the WAC-Allow user-permission pass, which shares the
// same `(credentials, requestedModes)` object pair), and a separate time for the WAC-Allow
// public-permission pass (empty credentials). All these calls share the same `requestedModes`
// `AccessMap` object as it is produced once per request by the (cached) `ModesExtractor`.
it('resolves the effective ACL once when reused for different credentials.', async(): Promise<void> => {
// Return a fresh representation on each call as the underlying data stream is consumed once.
store.getRepresentation.mockImplementation(async(): Promise<Representation> => new BasicRepresentation([
quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)),
quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)),
quad(nn('auth'), nn(`${acl}agent`), nn('http://test.com/user')),
quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)),
quad(nn('pub'), nn(`${rdf}type`), nn(`${acl}Authorization`)),
quad(nn('pub'), nn(`${acl}accessTo`), nn(identifier.path)),
quad(nn('pub'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')),
quad(nn('pub'), nn(`${acl}mode`), nn(`${acl}Read`)),
], INTERNAL_QUADS));
// The access checker only grants the rule for the matching agent or for the public class.
accessChecker.handleSafe.mockImplementation(async({ acl: store, rule, credentials }): Promise<boolean> => {
const classes = store.getObjects(rule, `${acl}agentClass`, null).map((term): string => term.value);
if (classes.includes('http://xmlns.com/foaf/0.1/Agent')) {
return true;
}
const agents = store.getObjects(rule, `${acl}agent`, null).map((term): string => term.value);
const webId = credentials.agent?.webId;
return webId !== undefined && agents.includes(webId);
});

// Authorization decision + WAC-Allow user-permission pass: authenticated agent.
const userInput: PermissionReaderInput = {
credentials: { agent: { webId: 'http://test.com/user' }},
requestedModes: accessMap,
};
// WAC-Allow public-permission pass: same `requestedModes`, empty credentials object.
const publicInput: PermissionReaderInput = { credentials: {}, requestedModes: accessMap };

compareMaps(await reader.handle(userInput), new IdentifierMap([[ identifier, { read: true }]]));
compareMaps(await reader.handle(publicInput), new IdentifierMap([[ identifier, { read: true }]]));

// The effective ACL must be read and resolved exactly once for the whole request,
// even though the permissions are evaluated separately for each credential set.
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(resourceSet.hasResource).toHaveBeenCalledTimes(1);
});

it('re-resolves the effective ACL for a different request (no stale cache).', async(): Promise<void> => {
// Return a fresh representation on each call as the underlying data stream is consumed once.
store.getRepresentation.mockImplementation(async(): Promise<Representation> => new BasicRepresentation([
quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)),
quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)),
quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')),
quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)),
], INTERNAL_QUADS));

// A separate request always produces a new `requestedModes` `AccessMap` object,
// so it cannot hit the request-scoped cache of an earlier request.
const secondAccessMap = new IdentifierSetMultiMap<AccessMode>([[ identifier, AccessMode.read ]]);

await reader.handle({ credentials, requestedModes: accessMap });
await reader.handle({ credentials, requestedModes: secondAccessMap });

expect(store.getRepresentation).toHaveBeenCalledTimes(2);
expect(resourceSet.hasResource).toHaveBeenCalledTimes(2);
});

it('does not memoize a failed ACL read.', async(): Promise<void> => {
store.getRepresentation
.mockRejectedValueOnce(new Error('TEST!'))
.mockImplementation(async(): Promise<Representation> => new BasicRepresentation([
quad(nn('auth'), nn(`${rdf}type`), nn(`${acl}Authorization`)),
quad(nn('auth'), nn(`${acl}accessTo`), nn(identifier.path)),
quad(nn('auth'), nn(`${acl}agentClass`), nn('http://xmlns.com/foaf/0.1/Agent')),
quad(nn('auth'), nn(`${acl}mode`), nn(`${acl}Read`)),
], INTERNAL_QUADS));

// First call fails while reading the ACL; the failure must not be cached for the retry.
await expect(reader.handle({ credentials, requestedModes: accessMap })).rejects.toThrow(InternalServerError);
compareMaps(
await reader.handle({ credentials, requestedModes: accessMap }),
new IdentifierMap([[ identifier, { read: true }]]),
);
expect(store.getRepresentation).toHaveBeenCalledTimes(2);
});
});
});
Loading