Skip to content

fix(wac): advertise the ACL resource — Link rel="acl" on every resource response - #159

Merged
josepharuja merged 3 commits into
mainfrom
fix/wac-acl-link-discovery
Aug 27, 2026
Merged

fix(wac): advertise the ACL resource — Link rel="acl" on every resource response#159
josepharuja merged 3 commits into
mainfrom
fix/wac-acl-link-discovery

Conversation

@josepharuja

Copy link
Copy Markdown
Contributor

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 the AclResource convention, 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.acl names nothing in the model).
  • AuthorizationFilter writes it up front — that value is what refusals (401/403) and error-mapped responses carry.
  • The four handlers that emit their own Link values (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.
  • The storage description stays exempt, deliberately: a virtual resource has no ACL to name.
  • LinkRelation.ACL — the enum row the class javadoc already anticipated.

Tests

  • Four new cases in AuthorizationHttpTest: document, container, ACL-self, and refusal (discovery does not require access).
  • Twelve exact-list Link assertions across the existing HTTP tests gain the new trailing value.
  • Full mvn verify green.

Evidence from the harness (provisional, NOT baseline)

With this fix and a locally-patched harness client (RFC 9449 ath added 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:

MustFeatures passed: 24, failed: 11 · MustScenarios passed: 613, failed: 28

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.md is 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

…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>
Copilot AI lite review requested due to automatic review settings August 27, 2026 23:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 in AuthorizationFilter so it’s present on 401/403 and error-mapped responses.
  • Restates the ACL Link in handlers that emit their own Link values (Read, Options, Patch, Create) to avoid losing the filter-emitted header.
  • Updates and extends HTTP tests to assert the new rel="acl" link alongside existing Link values.

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 /.acl in @BeforeEach, so the GET should succeed. Adding an explicit expectStatus().isOk() ensures the test fails if the ACL resource becomes unreadable (or starts erroring) while still emitting a Link 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.

Comment on lines +218 to +224
@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>
Copilot AI review requested due to automatic review settings August 27, 2026 23:38
@josepharuja

Copy link
Copy Markdown
Contributor Author

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 Link values; the first commit patched four and missed PUT — the exact request a permissions editor makes — plus the storage description, and a slice test pinned the omission. Commit e49abc3 replaces the restatement contract with a single writer: a beforeCommit hook in AuthorizationFilter, the OriginVaryFilter shape (copy-then-put — add() mutates a list that a functional ServerResponse makes immutable), covering every handler current and future, refusals, and error-mapped responses uniformly.

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. AclResource.aclFor() (idempotent — an ACL is its own ACL) moves the mapping into cistern-wac; webflux only renders.

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 LinkHeader's own parser rather than containment, so duplicates and wrong targets fail too.

Flagged, pre-existing, not fixed here: asterisk-form OPTIONS * 400s in this filter before ResourceOptionsHandler's asterisk branch can answer — needs its own ticket.

Post-redesign verification: full mvn verify green; live server rebuilt; patched-harness re-run in flight to confirm the provisional 24/11 holds.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment on lines +6 to +9
import com.enrichmeai.cistern.wac.AclResource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
Comment on lines +273 to +278
@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>
Copilot AI review requested due to automatic review settings August 27, 2026 23:44
@josepharuja
josepharuja merged commit 75c0281 into main Aug 27, 2026
4 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 no rel="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 assertTrue causes 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 (and AuthorizationFilter's current behavior) asserts the opposite. Please reconcile the intended behavior: either exempt /.well-known/solid in 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);
    }

@josepharuja
josepharuja deleted the fix/wac-acl-link-discovery branch August 27, 2026 23:48
josepharuja added a commit that referenced this pull request Aug 28, 2026
… 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>
josepharuja added a commit that referenced this pull request Aug 28, 2026
… 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>
josepharuja added a commit that referenced this pull request Sep 6, 2026
…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>
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.

2 participants