diff --git a/packages/solid/src/resource/Resource.ts b/packages/solid/src/resource/Resource.ts index abaa653..2c34788 100644 --- a/packages/solid/src/resource/Resource.ts +++ b/packages/solid/src/resource/Resource.ts @@ -30,6 +30,8 @@ import type { GetWacUriError, GetWacUriResult } from "./wac/getWacUri"; import { getWacUri } from "./wac/getWacUri"; import { getWacRuleWithAclUri, type GetWacRuleResult } from "./wac/getWacRule"; import { NoncompliantPodError } from "../requester/results/error/NoncompliantPodError"; +import { setWacRuleForAclUri, type SetWacRuleResult } from "./wac/setWacRule"; +import type { LeafUri } from "../util/uriTypes"; /** * Statuses shared between both Leaf and Container @@ -85,7 +87,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ * @internal * If a wac uri is fetched, it is cached here */ - protected wacUri?: string; + protected wacUri?: LeafUri; /** * @internal @@ -597,7 +599,12 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{ return parentResource.getWac(); } - // async setWac(wacRule: WacRule): Promise<> { - // throw new Error("Not Implemented"); - // } + async setWac(wacRule: WacRule): Promise { + const wacUriResult = await this.getWacUri(); + if (wacUriResult.isError) return wacUriResult; + + return setWacRuleForAclUri(wacUriResult.wacUri, wacRule, this.uri, { + fetch: this.context.fetch, + }); + } } diff --git a/packages/solid/src/resource/wac/WacRule.ts b/packages/solid/src/resource/wac/WacRule.ts index b1230b0..1edc67d 100644 --- a/packages/solid/src/resource/wac/WacRule.ts +++ b/packages/solid/src/resource/wac/WacRule.ts @@ -10,10 +10,3 @@ export interface WacRule { authenticated: AccessModeList; agent: Record; } - -// export interface SetWacRule { -// ruleFor: Resource; -// public?: AccessModeList; -// authenticated?: AccessModeList; -// agent?: Record; -// } diff --git a/packages/solid/src/resource/wac/getWacUri.ts b/packages/solid/src/resource/wac/getWacUri.ts index 1ff3105..e21668c 100644 --- a/packages/solid/src/resource/wac/getWacUri.ts +++ b/packages/solid/src/resource/wac/getWacUri.ts @@ -9,6 +9,7 @@ import { guaranteeFetch } from "../../util/guaranteeFetch"; import type { BasicRequestOptions } from "../../requester/requests/requestOptions"; import { NoncompliantPodError } from "../../requester/results/error/NoncompliantPodError"; import { parse as parseLinkHeader } from "http-link-header"; +import type { LeafUri } from "../../util/uriTypes"; export type GetWacUriError = | HttpErrorResultType @@ -55,7 +56,7 @@ export async function getWacUri( type: "getWacUriSuccess", isError: false, uri: resourceUri, - wacUri: aclUris[0].uri, + wacUri: aclUris[0].uri as LeafUri, }; } catch (err: unknown) { return UnexpectedResourceError.fromThrown(resourceUri, err); diff --git a/packages/solid/src/resource/wac/results/GetWacUriSuccess.ts b/packages/solid/src/resource/wac/results/GetWacUriSuccess.ts index f7c98e3..b819946 100644 --- a/packages/solid/src/resource/wac/results/GetWacUriSuccess.ts +++ b/packages/solid/src/resource/wac/results/GetWacUriSuccess.ts @@ -1,6 +1,7 @@ import type { ResourceSuccess } from "../../../requester/results/success/SuccessResult"; +import type { LeafUri } from "../../../util/uriTypes"; export interface GetWacUriSuccess extends ResourceSuccess { type: "getWacUriSuccess"; - wacUri: string; + wacUri: LeafUri; } diff --git a/packages/solid/src/resource/wac/setWacRule.ts b/packages/solid/src/resource/wac/setWacRule.ts index e69de29..2495eb3 100644 --- a/packages/solid/src/resource/wac/setWacRule.ts +++ b/packages/solid/src/resource/wac/setWacRule.ts @@ -0,0 +1,100 @@ +import { createLdoDataset } from "@ldo/ldo"; +import type { BasicRequestOptions } from "../../requester/requests/requestOptions"; +import type { UnexpectedResourceError } from "../../requester/results/error/ErrorResult"; +import { + HttpErrorResult, + type HttpErrorResultType, +} from "../../requester/results/error/HttpErrorResult"; +import { isContainerUri, type LeafUri } from "../../util/uriTypes"; +import type { AccessModeList, WacRule } from "./WacRule"; +import type { SetWacRuleSuccess } from "./results/SetWacRuleSuccess"; +import type { Authorization } from "../../.ldo/wac.typings"; +import { AuthorizationShapeType } from "../../.ldo/wac.shapeTypes"; +import { v4 } from "uuid"; +import { guaranteeFetch } from "../../util/guaranteeFetch"; + +export type SetWacRuleError = HttpErrorResultType | UnexpectedResourceError; +export type SetWacRuleResult = SetWacRuleSuccess | SetWacRuleError; + +export async function setWacRuleForAclUri( + aclUri: LeafUri, + newRule: WacRule, + accessTo: string, + options?: BasicRequestOptions, +): Promise { + const fetch = guaranteeFetch(options?.fetch); + // The rule map keeps track of all the rules that are currently being used + // so that similar rules can be grouped together + const ruleMap: Record = {}; + // The dataset that will eventually be sent to the Pod + const dataset = createLdoDataset(); + + // Helper function to add rules to the dataset by grouping them in the ruleMap + function addRuleToDataset( + type: "public" | "authenticated" | "agent", + accessModeList: AccessModeList, + agentId?: string, + ) { + const accessModeListHash = hashAccessModeList(accessModeList); + // No need to add if all access is false + if (accessModeListHash === "") return; + if (!ruleMap[accessModeListHash]) { + const authorization = dataset + .usingType(AuthorizationShapeType) + .fromSubject(`${aclUri}#${v4()}`); + authorization.type = { "@id": "Authorization" }; + if (accessModeList.read) authorization.mode?.push({ "@id": "Read" }); + if (accessModeList.write) authorization.mode?.push({ "@id": "Write" }); + if (accessModeList.append) authorization.mode?.push({ "@id": "Append" }); + if (accessModeList.control) + authorization.mode?.push({ "@id": "Control" }); + authorization.accessTo = { "@id": accessTo }; + if (isContainerUri(accessTo)) { + authorization.default = { "@id": accessTo }; + } + ruleMap[accessModeListHash] = authorization; + } + const authorization = ruleMap[accessModeListHash]; + // Add agents to the rule + if (type === "public") { + authorization.agentClass?.push({ "@id": "Agent" }); + } else if (type === "authenticated") { + authorization.agentClass?.push({ "@id": "AuthenticatedAgent" }); + } else if (type === "agent" && agentId) { + authorization.agent?.push({ "@id": agentId }); + } + } + + // Add each rule to the dataset + addRuleToDataset("public", newRule.public); + addRuleToDataset("authenticated", newRule.authenticated); + Object.entries(newRule.agent).forEach(([agentUri, accessModeList]) => { + addRuleToDataset("agent", accessModeList, agentUri); + }); + + // Save to Pod + const response = await fetch(aclUri, { + method: "PUT", + headers: { + "content-type": "text/turtle", + }, + body: dataset.toString(), + }); + const errorResult = HttpErrorResult.checkResponse(aclUri, response); + if (errorResult) return errorResult; + + return { + type: "setWacRuleSuccess", + uri: aclUri, + isError: false, + wacRule: newRule, + }; +} + +// Hashes the access mode list for use in the rule map +function hashAccessModeList(list: AccessModeList): string { + return Object.entries(list).reduce( + (agg, [key, isPresent]) => (isPresent ? agg + key : agg), + "", + ); +} diff --git a/packages/solid/test/Integration.test.ts b/packages/solid/test/Integration.test.ts index d91ccb6..1b3b625 100644 --- a/packages/solid/test/Integration.test.ts +++ b/packages/solid/test/Integration.test.ts @@ -44,6 +44,7 @@ import type { } from "../src/requester/results/error/HttpErrorResult"; import type { NoncompliantPodError } from "../src/requester/results/error/NoncompliantPodError"; import type { GetWacRuleSuccess } from "../src/resource/wac/results/GetWacRuleSuccess"; +import type { WacRule } from "../src/resource/wac/WacRule"; const TEST_CONTAINER_SLUG = "test_ldo/"; const TEST_CONTAINER_URI = @@ -211,6 +212,9 @@ describe("Integration", () => { method: "DELETE", }), ]); + await authFetch(TEST_CONTAINER_URI, { + method: "DELETE", + }); }); /** @@ -1735,7 +1739,7 @@ describe("Integration", () => { expect(wacResult.isError).toBe(true); expect(wacResult.type).toBe("noncompliantPodError"); expect((wacResult as NoncompliantPodError).message).toBe( - "Response from http://localhost:3001/test_ldo/sample.ttl is not compliant with the Solid Specification: No link header present in request.", + `Response from ${SAMPLE_DATA_URI} is not compliant with the Solid Specification: No link header present in request.`, ); }); @@ -1751,7 +1755,7 @@ describe("Integration", () => { expect(wacResult.isError).toBe(true); expect(wacResult.type).toBe("noncompliantPodError"); expect((wacResult as NoncompliantPodError).message).toBe( - `Response from http://localhost:3001/test_ldo/sample.ttl is not compliant with the Solid Specification: There must be one link with a rel="acl"`, + `Response from ${SAMPLE_DATA_URI} is not compliant with the Solid Specification: There must be one link with a rel="acl"`, ); }); @@ -1814,22 +1818,99 @@ describe("Integration", () => { expect(wacResult.isError).toBe(true); expect(wacResult.type).toBe("serverError"); }); + + it("returns a NonCompliantPodError when this is the root resource and it doesn't have an ACL", async () => { + const resource = solidLdoDataset.getResource(ROOT_CONTAINER); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("", { status: 404 })); + const wacResult = await resource.getWac(); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("noncompliantPodError"); + expect((wacResult as NoncompliantPodError).message).toBe( + `Response from ${ROOT_CONTAINER} is not compliant with the Solid Specification: Resource "${ROOT_CONTAINER}" has no Effective ACL resource`, + ); + }); }); - it("returns a NonCompliantPodError when this is the root resource and it doesn't have an ACL", async () => { - const resource = solidLdoDataset.getResource(ROOT_CONTAINER); - fetchMock.mockResolvedValueOnce( - new Response("", { - status: 200, - headers: { link: `; rel="acl"` }, - }), - ); - fetchMock.mockResolvedValueOnce(new Response("", { status: 404 })); - const wacResult = await resource.getWac(); - expect(wacResult.isError).toBe(true); - expect(wacResult.type).toBe("noncompliantPodError"); - expect((wacResult as NoncompliantPodError).message).toBe( - `Response from http://localhost:3001/ is not compliant with the Solid Specification: Resource "${ROOT_CONTAINER}" has no Effective ACL resource`, - ); + describe("setWacRule", () => { + const newRules: WacRule = { + public: { read: true, write: false, append: false, control: false }, + authenticated: { + read: true, + write: false, + append: true, + control: false, + }, + agent: { + [WEB_ID]: { read: true, write: true, append: true, control: true }, + }, + }; + + it("sets wac rules for a resource that didn't have one before", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.setWac(newRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(newRules); + }); + + it("overwrites an existing access control rule", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + const result = await resource.setWac(newRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(newRules); + }); + + it("Does not write a rule when access is not granted to an agent", async () => { + const moreRules = { + ...newRules, + public: { read: false, write: false, append: false, control: false }, + }; + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + const result = await resource.setWac(moreRules); + expect(result.isError).toBe(false); + expect(result.type).toBe("setWacRuleSuccess"); + const readResult = await resource.getWac({ ignoreCache: true }); + expect(readResult.isError).toBe(false); + expect(readResult.type).toBe("getWacRuleSuccess"); + const rules = (readResult as GetWacRuleSuccess).wacRule; + expect(rules).toEqual(moreRules); + }); + + it("returns an error when an error is encountered fetching the aclUri", async () => { + const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI); + fetchMock.mockResolvedValueOnce(new Response("Error", { status: 500 })); + const wacResult = await resource.setWac(newRules); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); + + it("Returns an error when the request to write the access rules throws an error", async () => { + const resource = solidLdoDataset.getResource(TEST_CONTAINER_URI); + fetchMock.mockResolvedValueOnce( + new Response("", { + status: 200, + headers: { link: `; rel="acl"` }, + }), + ); + fetchMock.mockResolvedValueOnce(new Response("", { status: 500 })); + const wacResult = await resource.setWac(newRules); + expect(wacResult.isError).toBe(true); + expect(wacResult.type).toBe("serverError"); + }); }); });