From b76880483c0b186ea3bdb1ced9228fcd38d7c22b Mon Sep 17 00:00:00 2001 From: mrkvon Date: Fri, 29 May 2026 16:07:31 +0200 Subject: [PATCH] Fix local vs default WAC rule resolution in resource.getWac() Separate authorizations by `acl:accessTo` vs `acl:default`. Track whether actual .acl belongs to the resource or to its ancestor. Choose appropriate authorizations accordingly. Fixes #112 --- .../src/resources/SolidResource.ts | 37 ++++-- .../connected-solid/src/wac/getWacRule.ts | 24 +++- .../connected-solid/test/Integration.test.ts | 110 +++++++++++++++++- 3 files changed, 156 insertions(+), 15 deletions(-) diff --git a/packages/connected-solid/src/resources/SolidResource.ts b/packages/connected-solid/src/resources/SolidResource.ts index 40c6bc66..32320e43 100644 --- a/packages/connected-solid/src/resources/SolidResource.ts +++ b/packages/connected-solid/src/resources/SolidResource.ts @@ -122,6 +122,12 @@ export abstract class SolidResource */ protected wacRule?: WacRule; + /** + * @internal + * If an acl:default wac rule was fetched, it is cached here + */ + protected inheritableWacRule?: WacRule; + /** * @internal * Handles notification subscriptions @@ -743,7 +749,7 @@ export abstract class SolidResource * @returns WAC Rules results */ protected async getWacUri(options?: { - ignoreCache: boolean; + ignoreCache?: boolean; }): Promise> { const thisAsLeafOrContainer = this as unknown as SolidLeaf | SolidContainer; // Get the wacUri if not already present @@ -789,8 +795,13 @@ export abstract class SolidResource * } * ``` */ - async getWac(options?: { - ignoreCache: boolean; + async getWac(options?: { ignoreCache?: boolean }) { + return await this._getWac(options); + } + + private async _getWac(options?: { + ignoreCache?: boolean; + inheritable?: boolean; }): Promise< | GetWacUriError | GetWacRuleError @@ -798,8 +809,11 @@ export abstract class SolidResource > { const thisAsLeafOrContainer = this as unknown as SolidLeaf | SolidContainer; // Return the wac rule if it's already cached - if (!options?.ignoreCache && this.wacRule) { - return new GetWacRuleSuccess(thisAsLeafOrContainer, this.wacRule); + const cachedRule = options?.inheritable + ? this.inheritableWacRule + : this.wacRule; + if (!options?.ignoreCache && cachedRule) { + return new GetWacRuleSuccess(thisAsLeafOrContainer, cachedRule); } // Get the wac uri @@ -812,12 +826,17 @@ export abstract class SolidResource thisAsLeafOrContainer, { fetch: this.context.solid.fetch, + inheritable: options?.inheritable, }, ); if (wacResult.isError) return wacResult; // If the wac rules was successfully found if (wacResult.type === "getWacRuleSuccess") { - this.wacRule = wacResult.wacRule; + if (options?.inheritable) { + this.inheritableWacRule = wacResult.wacRule; + } else { + this.wacRule = wacResult.wacRule; + } return wacResult; } @@ -830,7 +849,7 @@ export abstract class SolidResource `Resource "${this.uri}" has no Effective ACL resource`, ); } - return parentResource.getWac(); + return parentResource._getWac({ ...options, inheritable: true }); } /** @@ -888,7 +907,11 @@ export abstract class SolidResource this.emit("update"); return result; } + // update cache this.wacRule = result.wacRule; + // clear default rule cache + // to simplify logic + this.inheritableWacRule = undefined; return result; } diff --git a/packages/connected-solid/src/wac/getWacRule.ts b/packages/connected-solid/src/wac/getWacRule.ts index 258dd37e..acaf5407 100644 --- a/packages/connected-solid/src/wac/getWacRule.ts +++ b/packages/connected-solid/src/wac/getWacRule.ts @@ -25,6 +25,9 @@ export type GetWacRuleResult = | GetWacRuleError | WacRuleAbsent; +type GetWacRuleOptions = + ResourceType extends SolidContainer ? { inheritable?: boolean } : never; + /** * Given the URI of an ACL document, return the Web Access Control (WAC) rules * @param aclUri: The URI for the ACL document @@ -34,22 +37,22 @@ export type GetWacRuleResult = export async function getWacRuleWithAclUri( aclUri: string, resource: SolidContainer, - options?: BasicRequestOptions, + options?: BasicRequestOptions & GetWacRuleOptions, ): Promise>; export async function getWacRuleWithAclUri( aclUri: string, resource: SolidLeaf, - options?: BasicRequestOptions, + options?: BasicRequestOptions & GetWacRuleOptions, ): Promise>; export async function getWacRuleWithAclUri( aclUri: string, resource: SolidLeaf | SolidContainer, - options?: BasicRequestOptions, + options?: BasicRequestOptions & GetWacRuleOptions, ): Promise>; export async function getWacRuleWithAclUri( aclUri: string, resource: SolidLeaf | SolidContainer, - options?: BasicRequestOptions, + options?: BasicRequestOptions & GetWacRuleOptions, ): Promise> { const fetch = guaranteeFetch(options?.fetch); const response = await fetch(aclUri); @@ -73,6 +76,13 @@ export async function getWacRuleWithAclUri( "http://www.w3.org/ns/auth/acl#Authorization", ); + const explicitAuthorizations = authorizations.filter( + (a) => a.accessTo?.["@id"] === resource.uri, + ); + const inheritableAuthorizations = authorizations.filter( + (a) => a.default?.["@id"] === resource.uri, + ); + const wacRule: WacRule = { public: { read: false, @@ -98,7 +108,11 @@ export async function getWacRuleWithAclUri( }); } - authorizations.forEach((authorization) => { + const effectiveAuthorizations = options?.inheritable + ? inheritableAuthorizations + : explicitAuthorizations; + + effectiveAuthorizations.forEach((authorization) => { if ( authorization.agentClass?.some( (agentClass) => agentClass["@id"] === "Agent", diff --git a/packages/connected-solid/test/Integration.test.ts b/packages/connected-solid/test/Integration.test.ts index 6d5b98c3..3af51168 100644 --- a/packages/connected-solid/test/Integration.test.ts +++ b/packages/connected-solid/test/Integration.test.ts @@ -48,6 +48,7 @@ import { getStorageFromWebId } from "../src/getStorageFromWebId"; import type { ResourceInfo } from "@ldo/test-solid-server"; import { createApp, setupServer } from "@ldo/test-solid-server"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import assert from "node:assert/strict"; const ROOT_CONTAINER = "http://localhost:3001/"; const WEB_ID = "http://localhost:3001/example/profile/card#me"; @@ -111,6 +112,34 @@ const SAMPLE_PROFILE_TTL = ` <${SAMPLE_PROFILE_URI}> pim:storage , . `; +const OTHER_CONTAINER_SLUG = "other_container/"; +const OTHER_CONTAINER_URI = + `${TEST_CONTAINER_URI}${OTHER_CONTAINER_SLUG}` as SolidContainerUri; +const OTHER_CONTAINER_ACL = ` + @prefix acl: . + @prefix foaf: . + + <#owner> a acl:Authorization; + acl:accessTo <./>; + acl:default <./>; + acl:agent <${WEB_ID}>; + acl:mode acl:Read, acl:Write, acl:Append, acl:Control. + + <#public> a acl:Authorization; + acl:accessTo <./>; + acl:agentClass foaf:Agent; + acl:mode acl:Read, acl:Write. + + <#public-inherited> a acl:Authorization; + acl:default <./>; + acl:agentClass foaf:Agent; + acl:mode acl:Read, acl:Control. +`; + +const OTHER_RESOURCE_SLUG = "resource.ttl"; +const OTHER_RESOURCE_URI = + `${OTHER_CONTAINER_URI}${OTHER_RESOURCE_SLUG}` as SolidLeafUri; + const resourceInfo: ResourceInfo = { slug: TEST_CONTAINER_SLUG, isContainer: true, @@ -159,6 +188,24 @@ const resourceInfo: ResourceInfo = { mimeType: "text/plain", data: "", }, + { + slug: OTHER_CONTAINER_SLUG, + isContainer: true, + contains: [ + { + slug: ".acl", + isContainer: false, + mimeType: "text/turtle", + data: OTHER_CONTAINER_ACL, + }, + { + slug: OTHER_RESOURCE_SLUG, + isContainer: false, + mimeType: "text/turtle", + data: "<#this> a <#Test>.", + }, + ], + }, ], }; @@ -281,7 +328,7 @@ describe("Integration", () => { isDoingInitialFetch: true, }); expect(result.type).toBe("containerReadSuccess"); - expect(resource.children().length).toBe(3); + expect(resource.children().length).toBe(4); }); it("Reads a binary leaf", async () => { @@ -495,7 +542,7 @@ describe("Integration", () => { }, ); expect(result.type).toBe("containerReadSuccess"); - expect(resource.children().length).toBe(3); + expect(resource.children().length).toBe(4); }); it("reads an unfetched leaf", async () => { @@ -526,7 +573,7 @@ describe("Integration", () => { const result = await resource.readIfUnfetched(); expect(s.fetchMock).not.toHaveBeenCalled(); expect(result.type).toBe("containerReadSuccess"); - expect(resource.children().length).toBe(3); + expect(resource.children().length).toBe(4); }); it("returns a cached existing data leaf", async () => { @@ -1850,6 +1897,63 @@ describe("Integration", () => { }); }); + [true, false].forEach((ignoreCache) => { + it(`[${ + ignoreCache ? "not cached" : "cached" + }] Handle rule inheritance correctly, distinguish acl:accessTo (direct) and acl:default (inherited)`, async () => { + const container = solidLdoDataset.getResource(OTHER_CONTAINER_URI); + + const containerWacResult = await container.getWac({ ignoreCache }); + expect(containerWacResult.isError).toBe(false); + // node:assert narrows types + assert.ok(!containerWacResult.isError); + + expect(containerWacResult.wacRule.agent[WEB_ID]).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + + expect(containerWacResult.wacRule.public).toEqual({ + read: true, + write: true, + append: false, + control: false, + }); + + const resource = solidLdoDataset.getResource(OTHER_RESOURCE_URI); + const result = await resource.read(); + assert.ok(!result.isError); + + const resourceWacResult = await resource.getWac({ ignoreCache }); + assert.ok(!resourceWacResult.isError); + + expect(resourceWacResult.wacRule.agent[WEB_ID]).toEqual({ + read: true, + write: true, + append: true, + control: true, + }); + + expect(resourceWacResult.wacRule.public).toEqual({ + read: true, + write: false, + append: false, + control: true, + }); + + const finalContainerWacResult = await container.getWac({ ignoreCache }); + assert.ok(!finalContainerWacResult.isError); + expect(finalContainerWacResult.wacRule.public).toEqual({ + read: true, + write: true, + append: false, + control: false, + }); + }); + }); + it("uses cached values for a retrieved resource", async () => { const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); await resource.getWac();