fix(wac): advertise the ACL resource — Link rel="acl" on every resource response - #159
Conversation
…ed and T5.3 never picked up WAC's ACL Resource Discovery is a MUST: every response to a request targeting a resource names where that resource's ACL lives. Cistern emitted WAC-Allow but never this link — T5.1's note deferred it to T5.3, T5.3's DoD listed only WAC-Allow, and the seam swallowed it. Found the honest way: the first harness run to clear client registration stopped dead at PREPARE SERVER because it could not learn the test container's ACL URL. One rendering (AclLink): the target's own ACL by the AclResource convention, whether or not it has a representation yet; an ACL resource advertises itself, since editing it is governed by Control on what it protects and a .acl.acl names nothing in the model. Emitted twice by design: the AuthorizationFilter writes it up front, which is what refusals and error-mapped responses carry; the four handlers that emit their own Link values restate it, because a router response's headers replace that field wholesale on write. The storage description stays exempt — a virtual resource has no ACL to name. Exact-list Link assertions across the HTTP tests gain the new value; four new cases pin document, container, self, and refusal behaviour. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: josepharuja <joseph.a.aruja@gmail.com>
There was a problem hiding this comment.
Pull request overview
Implements WAC “ACL Resource Discovery” by ensuring every resource-targeting HTTP response advertises the target’s ACL via Link: <…>; rel="acl" (including refusals), unblocking CTH discovery of container ACL URLs.
Changes:
- Introduces a single renderer for the ACL discovery link (
AclLink) and adds it early inAuthorizationFilterso it’s present on 401/403 and error-mapped responses. - Restates the ACL
Linkin handlers that emit their ownLinkvalues (Read,Options,Patch,Create) to avoid losing the filter-emitted header. - Updates and extends HTTP tests to assert the new
rel="acl"link alongside existingLinkvalues.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/AclLink.java | Centralizes rendering of the WAC Link rel="acl" value (including ACL-self behavior). |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/AuthorizationFilter.java | Adds the ACL discovery Link early so refusals/error-mapped responses carry it. |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/ResourceReadHandler.java | Restates the ACL Link when building ServerResponse headers for reads. |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/ResourcePatchHandler.java | Restates the ACL Link on PATCH responses (including create-via-patch). |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/ResourceOptionsHandler.java | Passes request target through and restates the ACL Link for OPTIONS responses. |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/ResourceCreateHandler.java | Advertises the POSTed-to container’s ACL in create responses (per PR description). |
| cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/LinkRelation.java | Adds LinkRelation.ACL enum entry with WAC discovery documentation. |
| cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/AuthorizationHttpTest.java | Adds focused ACL discovery tests (document, container, ACL-self, refusal). |
| cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/ResourceReadHttpTest.java | Updates exact Link header assertions to include the trailing ACL discovery link. |
| cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/ResourcePatchHttpTest.java | Updates create-via-patch Link expectations to include rel="acl". |
| cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/ResourceOptionsHttpTest.java | Updates OPTIONS Link list assertions to include the ACL discovery link. |
| cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/ResourceCreateHttpTest.java | Updates create tests to include the new rel="acl" link in Link assertions. |
Suppressed comments (1)
cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/AuthorizationHttpTest.java:230
- This test seeds
/.aclin@BeforeEach, so the GET should succeed. Adding an explicitexpectStatus().isOk()ensures the test fails if the ACL resource becomes unreadable (or starts erroring) while still emitting aLink rel="acl"header.
@DisplayName("an ACL resource advertises itself, not a .acl.acl outside the model")
void aclResourceAdvertisesItself() {
assertAclLink(asOwner(client.get().uri("/.acl")).exchange(), "/.acl");
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @Test | ||
| @DisplayName("a container response advertises the ACL inside it") | ||
| void containerAdvertisesItsAcl() { | ||
| put("/notes/hello", "<#a> <#b> \"c\" ."); | ||
|
|
||
| assertAclLink(asOwner(client.get().uri("/notes/")).exchange(), "/notes/.acl"); | ||
| } |
…OriginVaryFilter shape The advisor review refuted the restatement design with this PR's own diff: five handlers emit their own Link values, the PR patched four, and the fifth (PUT — the request a permissions editor makes) shipped without the link, silently, because a slice test pinned its absence. A contract every future handler must remember is not a contract; OriginVaryFilter next door already solved the identical builder-replaces-field problem at beforeCommit, copy-then-put because add() mutates a list a functional response makes immutable. So: one writer. The filter registers the hook; the handler restatements are reverted wholesale; the storage description — which IS WAC-governed, its effective-ACL walk is real — now advertises its ACL on the 200 and not only on refusals; a 304 stays free of discovery links, per the conditional-request contract, and a test pins that. AclResource gains the idempotent aclFor() (an ACL is its own ACL) so the wac module owns the mapping and webflux only renders it. Coverage is per response class in the one context the invariant holds — handler-written success, PUT, refusal, error-mapped, storage description, 304 — asserting exactly one acl link via LinkHeader's own parser, not containment. Slice-test assertion churn from the first commit is reverted; acl discovery exists exactly when enforcement does. Known, flagged, untouched: asterisk-form OPTIONS * 400s in this filter before the handler's asterisk branch can answer — pre-existing, raised for its own ticket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: josepharuja <joseph.a.aruja@gmail.com>
|
Review disposition (advisor review at level high — seven finder angles, all findings verified against the diff — plus Copilot): Refuted the original design with its own diff. Five handlers emit their own Behavioural decisions the review forced, now explicit and tested: the storage description IS WAC-governed (its effective-ACL walk is real), so its 200 now advertises the ACL rather than only its 401; a 304 stays free of discovery links per the conditional-request contract; CORS preflights and unparseable-target 400s are documented exemptions. Copilot's comment (missing status assertions on two tests): fixed — every discovery test now asserts status, and the helper asserts exactly one acl link via Flagged, pre-existing, not fixed here: asterisk-form Post-redesign verification: full |
| import com.enrichmeai.cistern.wac.AclResource; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; |
| @Test | ||
| @DisplayName("the storage description is WAC-governed and advertises its ACL like anything else") | ||
| void storageDescriptionAdvertisesAcl() { | ||
| assertAclLink(asOwner(client.get().uri("/.well-known/solid")).exchange() | ||
| .expectStatus().isOk(), "/.well-known/solid" + AclResource.SUFFIX); | ||
| } |
The note promised the header to T5.3 and named EffectiveAcl.aclResource() as the URI supplier; neither survived contact with the implementation (PR #159): the emitter is AuthorizationFilter's beforeCommit hook and the URI is the target's own ACL via AclResource.aclFor, never the effective source. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: josepharuja <joseph.a.aruja@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
cistern-webflux/src/main/java/com/enrichmeai/cistern/webflux/AuthorizationFilter.java:152
- This comment says the 400 for a malformed target is produced "just below", but
paths.identifierFor(...)is invoked before this block and can throw. Rewording will avoid confusing the reader about why those 400s have norel="acl"link.
// wrote. Covers allowed, refused, and error-mapped responses alike. Two responses
// deliberately go without: a CORS preflight (returned above — it answers "may this
// origin ask?", not a request targeting the resource), and a 400 for a target
// identifierFor refuses just below, which has no well-formed ACL to name.
cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/AuthorizationHttpTest.java:9
- Unused static import
assertTruecauses compilation failure (javac rejects unused imports). Remove it or use the static import consistently.
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
cistern-webflux/src/test/java/com/enrichmeai/cistern/webflux/AuthorizationHttpTest.java:278
- PR description says "The storage description stays exempt" from
Link rel="acl", but this new test (andAuthorizationFilter's current behavior) asserts the opposite. Please reconcile the intended behavior: either exempt/.well-known/solidin the filter and update this test, or update the PR description to match the new behavior.
@Test
@DisplayName("the storage description is WAC-governed and advertises its ACL like anything else")
void storageDescriptionAdvertisesAcl() {
assertAclLink(asOwner(client.get().uri("/.well-known/solid")).exchange()
.expectStatus().isOk(), "/.well-known/solid" + AclResource.SUFFIX);
}
… the provisional numbers beside it Official row (2026-08-28, unmodified harness, main 75c0281): still 0/0/41, verified by a fresh run — but for the first time the halt is not ours. REGISTER CLIENTS passes; the run stops one step later because the harness client's DPoP proofs carry no ath and RFC 9449 §4.3 obliges us to reject exactly that (one ATH_MISSING in the server log per run). Upstream fix offered: solid-contrib/conformance-test-harness#789, per the owner's ruling to contribute rather than route around. Beside it, clearly fenced as NOT the baseline: the patched-client figures (24/11 MustFeatures, 613/28 MustScenarios) — the measured cost of the one absent claim, and the itemised work queue (#165–#170). The official row moves only on an unmodified-harness run; that is the ratchet's whole value. Rebuilt from a clean worktree: the first cut of this branch was committed from a stale shared checkout and silently carried reverts of #159, #160, #162 and #163 — caught by the pre-merge review, discarded, and the shared checkout is being retired from write duty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: josepharuja <joseph.a.aruja@gmail.com>
… the provisional numbers beside it (#171) Official row (2026-08-28, unmodified harness, main 75c0281): still 0/0/41, verified by a fresh run — but for the first time the halt is not ours. REGISTER CLIENTS passes; the run stops one step later because the harness client's DPoP proofs carry no ath and RFC 9449 §4.3 obliges us to reject exactly that (one ATH_MISSING in the server log per run). Upstream fix offered: solid-contrib/conformance-test-harness#789, per the owner's ruling to contribute rather than route around. Beside it, clearly fenced as NOT the baseline: the patched-client figures (24/11 MustFeatures, 613/28 MustScenarios) — the measured cost of the one absent claim, and the itemised work queue (#165–#170). The official row moves only on an unmodified-harness run; that is the ratchet's whole value. Rebuilt from a clean worktree: the first cut of this branch was committed from a stale shared checkout and silently carried reverts of #159, #160, #162 and #163 — caught by the pre-merge review, discarded, and the shared checkout is being retired from write duty. Signed-off-by: josepharuja <joseph.a.aruja@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…cted (#196) AccessControl.aclResourceFor had zero callers, and its javadoc said it supplied the Link: rel="acl" header. It did not. The shipped emitter (#159) uses AclResource.aclFor(target) — the target's OWN ACL — while this method returned discovery.findFor(target).map(EffectiveAcl::aclResource), the effective and possibly inherited one. That is not a harmless duplicate. The #159 review ruled the inherited form out because advertising the ancestor that happens to govern discloses the shape of the tree above the target and points a client editing permissions at the wrong resource. Leaving the method there, with a javadoc claiming it feeds the header, steers the next implementer straight into the rejected behaviour. Two pieces of prose said the same wrong thing and are corrected rather than deleted: EffectiveAcl.aclResource() now says what it is (the source authority was read from, possibly an ancestor) and explicitly what it is not, and AclDiscoveryTest's display name no longer claims the walk advertises the header. Closes #172. Signed-off-by: josepharuja <joseph.a.aruja@googlemail.com> Co-authored-by: josepharuja <joseph.a.aruja@googlemail.com>
The header WAC's "ACL Resource Discovery" section makes a MUST, found missing the honest way: the first harness run in the project's history to clear REGISTER CLIENTS stopped at PREPARE SERVER because it could not learn the test container's ACL URL. It fell through a ticket seam — T5.1's note deferred the emission to T5.3, and T5.3's DoD listed only
WAC-Allow.The fix
AclLink(new, package-private): the one rendering of the link — the target's own ACL by theAclResourceconvention, whether or not it has a representation yet; an ACL resource advertises itself (editing it is governed by Control on what it protects; a.acl.aclnames nothing in the model).AuthorizationFilterwrites it up front — that value is what refusals (401/403) and error-mapped responses carry.Linkvalues (Read,Options,Patch,Create) restate it, because a router response's headers replace that field wholesale on write — found empirically: the filter-only version passed on 401s and vanished on 200s. For POST the advertised ACL is the container posted to (the request target), not the created resource.LinkRelation.ACL— the enum row the class javadoc already anticipated.Tests
AuthorizationHttpTest: document, container, ACL-self, and refusal (discovery does not require access).Linkassertions across the existing HTTP tests gain the new trailing value.mvn verifygreen.Evidence from the harness (provisional, NOT baseline)
With this fix and a locally-patched harness client (RFC 9449
athadded to its DPoP proofs — upstream bug, solid-contrib/conformance-test-harness#767/#786), the CTH executed its 41 test cases against Cistern for the first time:These numbers are evidence for this PR, not a recordable baseline — they were produced by a patched harness and the ruling on how to treat that is the architect's (ground rule 1).
cth/BASELINE.mdis deliberately untouched here. Failure clusters, for the record: CORS ×5 features (Vary: Origin, ACAO echo), WAC write-access ×3, method-not-allowed, content-type-reject, containment, named-graph negotiation.🤖 Generated with Claude Code