Skip to content

refactor: Resolve the effective ACL once per request (cache-free alternative to #2182) - #2183

Closed
jeswr wants to merge 1 commit into
CommunitySolidServer:mainfrom
jeswr:refactor/wac-acl-resolve-once
Closed

refactor: Resolve the effective ACL once per request (cache-free alternative to #2182)#2183
jeswr wants to merge 1 commit into
CommunitySolidServer:mainfrom
jeswr:refactor/wac-acl-resolve-once

Conversation

@jeswr

@jeswr jeswr commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

🚧 DRAFT — not ready for review. Opened by @jeswr's AI coding agent, intentionally in draft for @jeswr to review first, before maintainer review. Please hold off reviewing until it's marked ready.


Alternative to #2182. This is the cache-free, structural ("resolve-once, evaluate-many") counterpart to the request-scoped WeakMap memoization in #2182, opened so the two can be compared side by side. Same base commit (upstream/main fb31e5775) so the diffs are directly comparable. The maintainer picks whichever fits the project better; this PR exists to make that an informed choice, and (per the honest-tradeoff section below) it is the more invasive of the two.

The problem (measured)

A single authenticated GET/HEAD resolves the target's effective .acl twice.

The permission-reader composite is wrapped by a CachedHandler keyed on [credentials, requestedModes] (config/ldp/authorization/readers/default.json). For an authenticated read the reader is invoked across three passes that ask genuinely-different permission questions but all resolve the same, credential-independent effective ACL:

Pass Where credentials Cache Resolves ACL?
Authorization decision AuthorizingHttpHandler user miss yes
WAC-Allow user="…" WacAllowHttpHandler user (same objects) hit no
WAC-Allow public="…" WacAllowHttpHandler {} (fresh literal) miss yes (redundant)

ModesExtractor and CredentialsExtractor are themselves cached on the operation/request, so the user passes share object identity and the WAC-Allow user pass is a cache hit. But the WAC-Allow public pass passes a fresh empty {} literal — a cache miss — so WebAclReader re-walks the container hierarchy and re-reads + re-parses the same .acl, even though the resolved ACL is identical and credential-independent.

Before / after read counts

Measured through the real AuthorizingHttpHandler → WacAllowHttpHandler → CachedHandler → AuxiliaryReader → UnionPermissionReader → WebAclReader chain (counting aclStore.getRepresentation calls for the .acl):

Request Before After
Authenticated GET 2 1
Unauthenticated GET 1 1
PUT 1 1

A regression test (test/unit/server/WacResolveOncePath.test.ts) drives that real chain and asserts a single resolution; it fails on main (2 reads) and passes here (1) — parity with #2182's measurement.

The structural approach (no cache, no memoization, stateless)

The redundant resolution is the public pass. Rather than memoizing the resolution, this makes the one invocation that resolves the ACL also evaluate the public credential set against it:

  1. PermissionReaderInput gains an optional credentialsToCompare?: Credentials[] — additional credential sets to evaluate against the same resolved ACL. Optional ⇒ non-breaking; readers that ignore it behave identically.
  2. WebAclReader resolves the effective ACL once (unchanged walk + read + parse) and runs the per-credential evaluation for the primary credentials and each comparison set against that single parsed store.
  3. The comparison results are carried on a non-enumerable Symbol key (ComparisonPermissions) on each PermissionSet. A Symbol is never returned by Object.keys/entries/for…in/JSON.stringify, so it is invisible to PermissionBasedAuthorizer (reads explicit AccessMode keys) and to WacAllowHttpHandler.addWacAllowMetadata (iterates Object.keys, filters on the four valid ACL modes). The primary PermissionMap is therefore unchanged.
  4. AuthorizingHttpHandler attaches the public ({}) comparison on its single (cache-populating) reader call for authenticated requests. This is the key to avoiding a cache-key trap: the cache is keyed on [credentials, requestedModes], not on credentialsToCompare — so if the comparison were requested only on the WAC-Allow user pass (a cache hit), the cached value would lack it. By enriching the authorization-decision pass (the request's only [user, modes] cache miss, which always runs first), the single cached value already carries the public comparison, and the WAC-Allow user-pass hit reads it.
  5. WacAllowHttpHandler reads the public permissions from that shared user result instead of re-invoking the reader, and falls back to the original separate {credentials: {}} call when no comparison is attached (e.g. a reader that ignores the field, such as the ACP strategy) — fail-safe, never wrong.
  6. The comparison sets are threaded and transformed identically to the primary through the union (UnionPermissionReader merges them index-aligned with the same false > true > undefined rule), parent-container create/delete derivation (ParentContainerReader), control→read/write interpretation on .acl (AuthAuxiliaryReader), and pod-owner grants (OwnerPermissionReader) — so the public value matches a full separate pass.

No persistent or cross-request state is introduced.

WAC is byte-identical (evidence)

  • test/integration/PermissionTable.test.ts — 408/408 (the exhaustive WAC permission/status-code matrix) pass unchanged → no authorization-decision change.
  • test/integration/LdpHandlerWithAuth.test.ts — 18/18 pass unchanged, including the assertions on the exact WAC-Allow header string (e.g. user="append read write",public="append read write", user="read",public="read", user="append control read write",public="append control read write") — now exercised through the resolve-once reuse path.
  • WacAllowHttpHandler / WebAclReader / AuthorizingHttpHandler unit assertions on the header (toEqualRdfTermArray) and the authorizer's availablePermissions are unchanged and passing.

Verification (all green, run on the branch HEAD)

  • npx tsc -p test --noEmit ✅ (the CI test-unit typecheck — not just build:ts)
  • npm run build:ts
  • unit: test/unit/authorization/**, WacAllowHttpHandler, AuthorizingHttpHandler, WacResolveOncePath123/123
  • integration: LdpHandlerWithAuth 18/18, PermissionTable 408/408 ✅ (run as separate jest processes; running both in one parallel invocation collides on the shared test port — a harness limitation, not a logic failure)
  • eslint on all changed files ✅

Honest tradeoff vs #2182 (the whole point of the side-by-side)

This cache-free refactor is correct, fully tested, and byte-identical — but it is genuinely more invasive and more coupled than #2182's WeakMap memoization. Stated plainly so the comparison is fair:

This PR (cache-free, structural) #2182 (request-scoped WeakMap)
Source files touched 10 (+1 new module) 1 (WebAclReader.ts)
Source diff ~265 lines ~25 lines
Public surface added credentialsToCompare on PermissionReaderInput + a Symbol carrier on PermissionSet (cross-cutting, every reader must respect it) none (private impl detail of one class)
State none (stateless; nothing to invalidate) a WeakMap<AccessMap, …> request-scoped cache
Main fragility a distributed correctness invariant: a future reader added to the union that forgets to thread/transform the Symbol carrier could yield a wrong (or, via the fail-safe fallback, merely re-resolved) public WAC-Allow value the WeakMap lifetime/request-scoping argument (mitigated by keying on the per-request AccessMap object)
Cache-key subtlety yes — needs AuthorizingHttpHandler to pre-seed the comparison so the WAC-Allow hit carries it (explained above) sidestepped entirely

My honest recommendation: if the only goal is "stop resolving twice with the least risk and smallest diff," #2182's WeakMap is cleaner — single-file, ~1/10th the diff, no new public contract, no distributed invariant. This PR is the better choice only if a cache-free / stateless implementation is a hard requirement (e.g. to keep the core fully stateless and avoid any in-reader memoization), in which case it delivers exactly that, correctly and with full WAC parity, at the cost of a larger, more coupled change. Both are gated to the same standard; they differ in shape, not in observable behaviour.


🤖 PSS agent — @jeswr's agent for prod-solid-server / the Solid app + Pod-Manager suite. Opened on @jeswr's behalf; draft, for @jeswr to review first.

…th + WAC-Allow passes

A single authenticated GET/HEAD resolves the target's effective .acl twice. The
permission reader composite is wrapped by a CachedHandler keyed on
[credentials, requestedModes]. The authorization decision and the WAC-Allow
user-permission pass share the same (credentials, requestedModes) objects, so
the second is a cache hit; but the WAC-Allow public-permission pass uses a fresh
empty credentials literal and therefore misses the cache, re-walking the
container hierarchy and re-reading and re-parsing the same effective .acl, even
though the resolved ACL is credential-independent and identical for all passes.

Eliminate the redundant resolution structurally, without any cache or
memoization. PermissionReaderInput gains an optional `credentialsToCompare`
list: when set, a reader evaluates those additional credential sets against the
SAME resolved ACL and attaches the results to each primary permission set under
a non-enumerable Symbol key (ComparisonPermissions), which is invisible to the
authorizer (it reads explicit AccessMode keys) and to the WAC-Allow header logic
(it iterates Object.keys and filters on the valid ACL modes), so the granted
PermissionMap and the WAC-Allow header are byte-identical. The
AuthorizingHttpHandler requests the public ({}) comparison on its single
(cache-populating) reader call for authenticated requests, and the
WacAllowHttpHandler reads the public permissions from that shared result instead
of invoking the reader a second time, falling back to the original separate call
when no comparison is attached (e.g. a reader that ignores the field). The
comparison sets are threaded and transformed identically to the primary through
the union, parent-container, control-interpretation, and owner readers, so the
public value matches a full separate pass. No persistent or cross-request state
is introduced.

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. A regression
test drives the real auth + WAC-Allow chain over the real cached reader stack
and asserts a single resolution.

Cache-free, stateless alternative to the request-scoped WeakMap memoization in
PR CommunitySolidServer#2182, opened so the two approaches can be compared side by side.

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>
@jeswr jeswr closed this Jun 25, 2026
@jeswr
jeswr deleted the refactor/wac-acl-resolve-once branch June 25, 2026 22:47
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