Skip to content

perf: Reuse the resolved effective ACL across the passes of a single request#2182

Draft
jeswr wants to merge 2 commits into
CommunitySolidServer:mainfrom
jeswr:fix/wac-acl-reread-memoization
Draft

perf: Reuse the resolved effective ACL across the passes of a single request#2182
jeswr wants to merge 2 commits into
CommunitySolidServer:mainfrom
jeswr:fix/wac-acl-reread-memoization

Conversation

@jeswr

@jeswr jeswr commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

🚧 DRAFT — not ready for review. This PR was opened by @jeswr's AI coding agent and is intentionally in draft so that @jeswr can review it first, before it is submitted for CommunitySolidServer maintainer review. Please hold off reviewing until it's marked ready.


Problem

A single authenticated GET/HEAD resolves the target's effective .acl more than once, even though the effective ACL is identical for every resolution within the request. The resolution is expensive: an existence walk up the container hierarchy (WebAclReader.getAclRecursiveResourceSet.hasResource) plus a read and parse of the ACL document (ResourceStore.getRepresentationreadableToQuads).

The reader is invoked three times per authenticated GET:

  1. AuthorizingHttpHandler — the authorization decision, with (credentials, requestedModes).
  2. WacAllowHttpHandler — the WAC-Allow user-permission pass, with the same (credentials, requestedModes).
  3. WacAllowHttpHandler — the WAC-Allow public-permission pass, with (credentials: {}, requestedModes).

The CachedHandler in front of the PermissionReader is keyed by the [credentials, requestedModes] object pair. Because ModesExtractor is itself cached on the operation, passes (1) and (2) share the same (credentials, requestedModes) objects and already hit that cache. But pass (3) builds a fresh {} credentials literal on every request, so it is always a cache miss — and re-reads + re-parses the same ACL.

Measured .acl read counts on current upstream main (WAC enabled)

I instrumented ResourceStore.getRepresentation (the ACL read+parse) and ResourceSet.hasResource (the existence walk) and drove real requests through the full AuthorizingHttpHandler → WacAllowHttpHandler chain (with the real CachedHandler/WebAclReader/PermissionBasedAuthorizer):

Request ACL reads (read + parse) existence checks
Authenticated GET 2 2
Unauthenticated GET 1 1
PUT 1 1

So on current main the redundant work is one extra full ACL read + parse + existence walk per authenticated GET — the surviving duplicate is the WAC-Allow public pass. (Passes (1) and (2) are already deduplicated by the existing CachedHandler; the unauthenticated case reuses the user result and PUT skips WacAllowHttpHandler entirely.)

Fix

Memoize the credential-independent part of ACL resolution — the effective-ACL existence walk and the read + parse of the relevant authorization statements (getAclMatches + findAuthorizationStatements) — for the duration of a single request, inside WebAclReader. Each call still evaluates its own (credentials) question (findPermissions) against the shared parsed ACL, so the granted permissions are unchanged — only the redundant read/parse/walk is avoided.

Measured after the fix

Request ACL reads existence checks
Authenticated GET 1 (was 2) 1 (was 2)
Unauthenticated GET 1 1
PUT 1 1

The WAC-Allow public pass now reuses the parsed ACL from the authorization/user pass. Output WAC-Allow (user=…, public=…) is byte-identical before and after.

Why this is WAC-safe

This is security-critical, so the memoization is deliberately conservative:

  • Request-scoped, by construction. The cache is a WeakMap keyed by the requestedModes AccessMap object. ModesExtractor produces that object exactly once per request (it is itself a CachedHandler on the operation) and shares it across all three passes above. A different request always carries a different AccessMap object, so it cannot hit an earlier request's entry, and once a request's AccessMap is unreachable the WeakMap entry becomes eligible for GC. It can never serve a stale ACL to a later request. This mirrors the request-scoping already used by CachedResourceSet (a WeakMap keyed by the ResourceIdentifier object).
  • No authorization-decision change. Only the credential-independent resolution is shared; findPermissions/determinePermissions still run per call against the shared, read-only parsed quad store, so the computed PermissionMap (and hence the allow/deny decision and the WAC-Allow header) is identical. This is backed by the unchanged WebAclReader, WacAllowHttpHandler, AuthorizingHttpHandler, PermissionBasedAuthorizer unit suites and the full LdpHandlerWithAuth + PermissionTable integration suites (426 end-to-end WAC cases) all passing unchanged.
  • Failures are not memoized. If the ACL read throws, the in-flight entry is removed before rethrowing, so a transient error is never cached for a later pass or request.
  • No new cross-request state, no config change, no signature change — keeps the server's stateless-core property and is non-breaking.

Tests

Added a regression describe block to WebAclReader.test.ts:

  • resolves the effective ACL once when reused for different credentials — calls handle() with the user credentials and then the empty ({}, public) credentials, sharing the same requestedModes object (the within-request pattern), and asserts getRepresentation + hasResource are each called once while both permission results stay correct. This test fails on main (Received number of calls: 2).
  • re-resolves the effective ACL for a different request (no stale cache) — a second request with a fresh requestedModes object re-reads the ACL (asserts 2 reads), guarding the request-scoping / no-stale-serve property.
  • does not memoize a failed ACL read — a failing read is not cached; the retry re-reads.

./src stays at 100% coverage.


— 🤖 PSS agent — @jeswr's coding agent for prod-solid-server / the Solid app + Pod-Manager suite. Opened in draft for @jeswr's review.

jeswr and others added 2 commits June 25, 2026 12:03
…request

A single authenticated GET/HEAD invokes the WebAclReader several times with
the same requestedModes but different credentials: once for the authorization
decision (sharing its (credentials, requestedModes) object pair with the
WAC-Allow user-permission pass) and once more for the WAC-Allow public pass,
which uses a fresh empty credentials literal and therefore misses the
CachedHandler. Each missing pass re-walks the container hierarchy and re-reads
and re-parses the same effective .acl, even though the resolved ACL is
identical for all of them.

Memoize the credential-independent part of ACL resolution (the effective-ACL
existence walk plus the read and parse of the relevant authorization
statements) for the duration of a single request, keyed by the requestedModes
AccessMap object in a WeakMap. The per-credential permission evaluation still
runs for every call against the shared, read-only parsed ACL, so the granted
permissions and the resulting authorization decision are unchanged.

The cache is request-scoped by construction: the ModesExtractor produces the
requestedModes object exactly once per request and shares it across the passes,
so a different request always carries a different key and can never hit an
earlier request's entry, and the WeakMap entry is collected once the request's
AccessMap is unreachable. This mirrors the request-scoping already used by
CachedResourceSet. Failed reads are not memoized.

Measured effective-.acl read+parse counts (WAC enabled): an authenticated GET
drops from 2 to 1, while the unauthenticated GET and PUT stay at 1.

Model: claude-opus-4-8
Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gent webId)

The new regression tests had two TS errors only surfaced by tsc -p test (the CI
test-unit typecheck), not by build:ts (src-only): an AccessMap built with the
wrong generic (IdentifierSetMultiMap<ResourceIdentifier> -> <AccessMode>), and an
un-narrowed credentials.agent.webId access. Test-only; the fix is unchanged.

Model: claude-opus-4-8
Provenance: Opus 4.8 (Fable unavailable) -- re-review/upgrade candidate
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant