refactor: Resolve the effective ACL once per request (cache-free alternative to #2182) - #2183
Closed
jeswr wants to merge 1 commit into
Closed
refactor: Resolve the effective ACL once per request (cache-free alternative to #2182)#2183jeswr wants to merge 1 commit into
jeswr wants to merge 1 commit into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🚧 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/mainfb31e5775) 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/HEADresolves the target's effective.acltwice.The permission-reader composite is wrapped by a
CachedHandlerkeyed 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:credentialsAuthorizingHttpHandleruser="…"WacAllowHttpHandlerpublic="…"WacAllowHttpHandler{}(fresh literal)ModesExtractorandCredentialsExtractorare 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 — soWebAclReaderre-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 → WebAclReaderchain (countingaclStore.getRepresentationcalls for the.acl):A regression test (
test/unit/server/WacResolveOncePath.test.ts) drives that real chain and asserts a single resolution; it fails onmain(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:
PermissionReaderInputgains an optionalcredentialsToCompare?: Credentials[]— additional credential sets to evaluate against the same resolved ACL. Optional ⇒ non-breaking; readers that ignore it behave identically.WebAclReaderresolves 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.Symbolkey (ComparisonPermissions) on eachPermissionSet. ASymbolis never returned byObject.keys/entries/for…in/JSON.stringify, so it is invisible toPermissionBasedAuthorizer(reads explicitAccessModekeys) and toWacAllowHttpHandler.addWacAllowMetadata(iteratesObject.keys, filters on the four valid ACL modes). The primaryPermissionMapis therefore unchanged.AuthorizingHttpHandlerattaches 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 oncredentialsToCompare— 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.WacAllowHttpHandlerreads 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.UnionPermissionReadermerges them index-aligned with the samefalse > true > undefinedrule), 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 exactWAC-Allowheader 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/AuthorizingHttpHandlerunit assertions on the header (toEqualRdfTermArray) and the authorizer'savailablePermissionsare unchanged and passing.Verification (all green, run on the branch HEAD)
npx tsc -p test --noEmit✅ (the CItest-unittypecheck — not justbuild:ts)npm run build:ts✅test/unit/authorization/**,WacAllowHttpHandler,AuthorizingHttpHandler,WacResolveOncePath— 123/123 ✅LdpHandlerWithAuth18/18,PermissionTable408/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)eslinton 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:
WebAclReader.ts)credentialsToCompareonPermissionReaderInput+ aSymbolcarrier onPermissionSet(cross-cutting, every reader must respect it)WeakMap<AccessMap, …>request-scoped cacheSymbolcarrier could yield a wrong (or, via the fail-safe fallback, merely re-resolved) publicWAC-AllowvalueAccessMapobject)AuthorizingHttpHandlerto pre-seed the comparison so the WAC-Allow hit carries it (explained above)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.