Skip to content

Commit

Permalink
Fix #2339: getResourcePolicyAll returns unnamed policies
Browse files Browse the repository at this point in the history
- Discover policies from access control: This goes toward supporting blank nodes policies, but fails because `getThing` doesn't support blank nodes.
- Fix blank nodes ids used for matching
- Align option name with getThingAll
  • Loading branch information
NSeydoux committed Apr 15, 2024
1 parent 5d35c2e commit 1174009
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 47 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ The following changes are pending, and will be applied on the next major release
## Unreleased changes

### Patch changes

- Fixed #2339: Unnamed policies are now returned by `getResourcePolicyAll` if an optional argument
`{ acceptBlankNodes: true }` is specified. This additional argument makes this a non-breaking change,
as the current type signature isn't changed.
- `getThing` now supports Blank Node identifiers in addition to IRIs and skolems to refer to a subject.
- `getThingAll(dataset, { allowacceptBlankNodes: true })` now returns all Blank Nodes
subjects in the Dataset, in particular including those part of a single chain of
Expand Down
18 changes: 16 additions & 2 deletions src/acp/control.internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ import { addIri } from "../thing/add";
import { getIriAll } from "../thing/get";
import { removeAll, removeIri } from "../thing/remove";
import { setIri } from "../thing/set";
import { createThing, getThing, getThingAll, setThing } from "../thing/thing";
import {
asIri,
createThing,
getThing,
getThingAll,
setThing,
} from "../thing/thing";
import type { WithAccessibleAcr, WithAcp } from "./acp";
import { hasAccessibleAcr } from "./acp";
import type { AccessControlResource, Control } from "./control";
Expand Down Expand Up @@ -156,7 +162,15 @@ export function internal_setControl<ResourceExt extends WithAccessibleAcr>(
control: Control,
): ResourceExt {
const acr = internal_getAcr(withAccessControlResource);
const updatedAcr = setThing(acr, control);
let updatedAcr = setThing(acr, control);
const acrSubj = getThing(acr, getSourceUrl(acr));
// Th the ACR has an anchor node, link the Access Control.
if (acrSubj !== null) {
updatedAcr = setThing(
updatedAcr,
addIri(acrSubj, acp.accessControl, asIri(control, getSourceUrl(acr))),
);
}
const updatedResource = internal_setAcr(
withAccessControlResource,
updatedAcr,
Expand Down
14 changes: 11 additions & 3 deletions src/acp/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { UrlString, WithResourceInfo } from "../interfaces";
import { mockSolidDatasetFrom } from "../resource/mock";
import { getSourceUrl } from "../resource/resource";
import { internal_cloneResource } from "../resource/resource.internal";
import { createThing, setThing } from "../thing/thing";
import type { WithAccessibleAcr } from "./acp";
import type { AccessControlResource } from "./control";

Expand All @@ -40,10 +41,17 @@ import type { AccessControlResource } from "./control";
* @returns The mocked empty Access Control Resource for the given Resource.
* @since 1.6.0
*/
export function mockAcrFor(resourceUrl: UrlString): AccessControlResource {
const acrUrl = new URL("access-control-resource", resourceUrl).href;
export function mockAcrFor(
resourceUrl: UrlString,
acrUrl?: UrlString,
): AccessControlResource {
const finalAcrUrl =
acrUrl ?? new URL("access-control-resource", resourceUrl).href;
const acr: AccessControlResource = {
...mockSolidDatasetFrom(acrUrl),
...setThing(
mockSolidDatasetFrom(finalAcrUrl),
createThing({ url: finalAcrUrl }),
),
accessTo: resourceUrl,
};

Expand Down
96 changes: 68 additions & 28 deletions src/acp/policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
//

import { jest, describe, it, expect } from "@jest/globals";
import { DataFactory } from "n3";
import { DataFactory, Parser, Store } from "n3";
import type DatasetCore from "@rdfjs/dataset/DatasetCore";
import { internal_accessModeIriStrings } from "../acl/acl.internal";
import { rdf, acp } from "../constants";
import { mockSolidDatasetFrom } from "../resource/mock";
Expand Down Expand Up @@ -66,6 +67,7 @@ import {
setResourceAcrPolicy,
setResourcePolicy,
} from "./policy";
import { fromRdfJsDataset } from "../rdfjs";

jest.spyOn(globalThis, "fetch").mockImplementation(
async () =>
Expand Down Expand Up @@ -449,36 +451,74 @@ describe("getResourcePolicyAll", () => {
expect(getResourcePolicyAll(mockedResourceWithAcr)).toHaveLength(1);
});

it("returns only those Things whose type is of acp:AccessPolicy", () => {
let mockedAcr = mockAcrFor("https://some.pod/resource");
let mockedPolicy = createThing({
url: `${getSourceUrl(mockedAcr)}#policy`,
});
mockedPolicy = setUrl(mockedPolicy, rdf.type, acp.Policy);
let notAPolicy = createThing({
url: "https://some.pod/policy-resource#not-a-policy",
});
notAPolicy = setUrl(
notAPolicy,
rdf.type,
"https://arbitrary.vocab/not-a-policy",
);
mockedAcr = setThing(mockedAcr, mockedPolicy);
mockedAcr = setThing(mockedAcr, notAPolicy);
let mockedResourceWithAcr = addMockAcrTo(
// Initially reported in https://github.com/inrupt/solid-client-js/issues/2339
it("supports Blank Nodes policies if the option is set", async () => {
const acrWithBlankNodes = `
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
@prefix acp: <http://www.w3.org/ns/solid/acp#>.
@base <https://example.org/> .
<https://MY-SITE/test/test-acr-1/res1.ttl.acr> a acp:AccessControlResource;
acp:resource <https://MY-SITE/test/test-acr-1/res1.ttl>;
acp:accessControl <#fullOwnerAccess>, <#publicReadAccess>, <#defaultAccessControl>.
<#fullOwnerAccess> a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read, acl:Write, acl:Control;
acp:anyOf [
a acp:Matcher;
acp:agent <https://MY-SITE/profile/card#me>
]
].
<#publicReadAccess> a acp:AccessControl;
acp:apply [
a acp:Policy;
acp:allow acl:Read;
acp:anyOf [
a acp:Matcher;
acp:agent acp:PublicAgent
]
].
<#match-app-friends> a acp:Matcher;
acp:agent <https://id.example.com/chattycarl>, <https://id.example.com/busybee>;
acp:client <https://myapp.example.net/appid>.
<#defaultAccessControl> acp:apply <#app-friends-policy>.
<#app-friends-policy> a acp:Policy;
acp:allow acl:Read, acl:Write;
acp:allOf <#match-app-friends>.
`;
const mockedAcr = await new Promise<DatasetCore>((resolve, reject) => {
const parser = new Parser();
const store = new Store();
parser.parse(acrWithBlankNodes, (error, quad) => {
if (error) {
reject(error);
}
if (quad) {
store.add(quad);
} else {
resolve(store);
}
});
})
.then(fromRdfJsDataset)
.then((dataset) =>
getThingAll(dataset, { acceptBlankNodes: true }).reduce(
setThing,
mockAcrFor(
"https://some.pod/resource",
"https://MY-SITE/test/test-acr-1/res1.ttl.acr",
),
),
);

const mockedResourceWithAcr = addMockAcrTo(
mockSolidDatasetFrom("https://some.pod/resource"),
mockedAcr,
);
mockedResourceWithAcr = addPolicyUrl(
mockedResourceWithAcr,
`${getSourceUrl(mockedAcr)}#policy`,
);
mockedResourceWithAcr = addPolicyUrl(
mockedResourceWithAcr,
"https://some.pod/policy-resource#not-a-policy",
);

expect(getResourcePolicyAll(mockedResourceWithAcr)).toHaveLength(1);
expect(
getResourcePolicyAll(mockedResourceWithAcr, { acceptBlankNodes: true }),
).toHaveLength(3);
});

it("returns only those Things that apply to the given Resource", () => {
Expand Down
82 changes: 69 additions & 13 deletions src/acp/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,22 @@
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//

import type { Quad_Object } from "@rdfjs/types";
import { internal_accessModeIriStrings } from "../acl/acl.internal";
import { acp, rdf } from "../constants";
import { internal_isValidUrl, isNamedNode } from "../datatypes";
import type {
SolidDataset,
ThingPersisted,
Url,
UrlString,
import {
SolidClientError,
type SolidDataset,
type Thing,
type ThingPersisted,
type Url,
type UrlString,
} from "../interfaces";
import { internal_toIriString } from "../interfaces.internal";
import { getSourceUrl } from "../resource/resource";
import { addIri } from "../thing/add";
import { getIriAll } from "../thing/get";
import { getIriAll, getTermAll } from "../thing/get";
import { removeAll } from "../thing/remove";
import { setUrl } from "../thing/set";
import {
Expand All @@ -53,6 +56,7 @@ import {
removePolicyUrl,
} from "./control";
import { internal_getAcr, internal_setAcr } from "./control.internal";
import type { BlankNodeId } from "../rdf.internal";

/**
* A Policy can be applied to Resources to grant or deny [[AccessModes]] to users who match the Policy's [[Rule]]s.
Expand All @@ -64,6 +68,11 @@ export type Policy = ThingPersisted;
* @since 1.6.0
*/
export type ResourcePolicy = ThingPersisted;
/**
* A Resource Policy is like a regular [[Policy]], but rather than being re-used for different Resources, it is used for a single Resource and is stored in that Resource's Access Control Resource.
* @since 1.6.0
*/
export type AnonymousResourcePolicy = Thing & { url: BlankNodeId };
/**
* The different Access Modes that a [[Policy]] can allow or deny for a Resource.
* @since 1.6.0
Expand Down Expand Up @@ -421,14 +430,61 @@ export function getResourceAcrPolicy(
*/
export function getResourcePolicyAll(
resourceWithAcr: WithAccessibleAcr,
): ResourcePolicy[] {
): ResourcePolicy[];
export function getResourcePolicyAll(
resourceWithAcr: WithAccessibleAcr,
options: { acceptBlankNodes: true },
): (ResourcePolicy | AnonymousResourcePolicy)[];
export function getResourcePolicyAll(
resourceWithAcr: WithAccessibleAcr,
options: { acceptBlankNodes: false },
): ResourcePolicy[];
export function getResourcePolicyAll(
resourceWithAcr: WithAccessibleAcr,
options: { acceptBlankNodes: boolean },
): (ResourcePolicy | AnonymousResourcePolicy)[];
export function getResourcePolicyAll(
resourceWithAcr: WithAccessibleAcr,
options: { acceptBlankNodes: boolean } = { acceptBlankNodes: false },
): (ResourcePolicy | AnonymousResourcePolicy)[] {
const acr = internal_getAcr(resourceWithAcr);
const policyUrls = getPolicyUrlAll(resourceWithAcr);
const foundThings = policyUrls.map((policyUrl) => getThing(acr, policyUrl));
const foundPolicies = foundThings.filter(
(thing) => thing !== null && isPolicy(thing),
) as ResourcePolicy[];
return foundPolicies;
const acrUrl = getSourceUrl(acr);
const acrSubj = getThing(acr, acrUrl);
if (acrSubj === null) {
throw new SolidClientError(
`The provided ACR graph does not have an anchor node matching its URL ${acrUrl}`,
);
}
// Follow the links from acr -acp:accessControl> Acccess Control -acp:apply> Policy.
return (
getTermAll(acrSubj, acp.accessControl)
.reduce((prev, accessControlId) => {
const accessControl = getThing(acr, accessControlId.value);
if (accessControl === null) {
// If the access control isn't found, there are no policies to add.
return prev;
}
const accessControlPolicies = getTermAll(
accessControl,
acp.apply,
).filter(
// If the option is set, all candidate policies are acceptable.
(policy) =>
options.acceptBlankNodes ? true : policy.termType === "NamedNode",
);
return [...prev, ...accessControlPolicies];
}, [] as Quad_Object[])
// Get all the triples for the found subjects ID.
.map((policyId) => {
if (policyId.termType === "BlankNode") {
// policyId.value removes the _: prefix,
// which we rely on for matching.
return getThing(acr, `_:${policyId.value}`);
}
return getThing(acr, policyId.value);
})
.filter((thing): thing is Thing => thing !== null)
);
}

/**
Expand Down

0 comments on commit 1174009

Please sign in to comment.