From d71e3c1b8ef23cfe0801ec30c537692687e50c4a Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Thu, 25 Sep 2025 14:55:00 -0500 Subject: [PATCH 1/7] JCL-479: Add acp module --- acp/pom.xml | 115 ++++++++++ .../com/inrupt/client/acp/AccessControl.java | 60 ++++++ .../client/acp/AccessControlResource.java | 128 +++++++++++ .../java/com/inrupt/client/acp/Matcher.java | 81 +++++++ .../java/com/inrupt/client/acp/Policy.java | 78 +++++++ .../client/acp/AccessControlResourceTest.java | 199 ++++++++++++++++++ .../inrupt/client/acp/AcpMockHttpService.java | 75 +++++++ acp/src/test/resources/__files/acr-1.ttl | 31 +++ acp/src/test/resources/__files/acr-2.ttl | 42 ++++ .../test/resources/simplelogger.properties | 1 + bom/pom.xml | 5 + pom.xml | 1 + reports/pom.xml | 10 + runtime/pom.xml | 5 + vocabulary/pom.xml | 4 + .../com/inrupt/client/vocabulary/ACP.java | 25 +++ 16 files changed, 860 insertions(+) create mode 100644 acp/pom.xml create mode 100644 acp/src/main/java/com/inrupt/client/acp/AccessControl.java create mode 100644 acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java create mode 100644 acp/src/main/java/com/inrupt/client/acp/Matcher.java create mode 100644 acp/src/main/java/com/inrupt/client/acp/Policy.java create mode 100644 acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java create mode 100644 acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java create mode 100644 acp/src/test/resources/__files/acr-1.ttl create mode 100644 acp/src/test/resources/__files/acr-2.ttl create mode 100644 acp/src/test/resources/simplelogger.properties diff --git a/acp/pom.xml b/acp/pom.xml new file mode 100644 index 00000000000..6843c21dcf5 --- /dev/null +++ b/acp/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + com.inrupt.client + inrupt-client + 2.0.0-SNAPSHOT + + + inrupt-client-acp + Inrupt Java Client Libraries - Access Control Policies + + Access Control Policy support for the Inrupt Client Libraries. + + + + + com.inrupt.client + inrupt-client-api + ${project.version} + + + com.inrupt.client + inrupt-client-vocabulary + ${project.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-params + ${junit.version} + test + + + org.wiremock + wiremock + ${wiremock.version} + test + + + com.inrupt.client + inrupt-client-jackson + ${project.version} + test + + + com.inrupt.client + inrupt-client-guava + ${project.version} + test + + + com.inrupt.client + inrupt-client-core + ${project.version} + test + + + com.inrupt.client + inrupt-client-httpclient + ${project.version} + test + + + com.inrupt.client + inrupt-client-jena + ${project.version} + test + + + com.inrupt.client + inrupt-client-solid + ${project.version} + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + + + org.jacoco + jacoco-maven-plugin + + + + diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControl.java b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java new file mode 100644 index 00000000000..f02bd2444ac --- /dev/null +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java @@ -0,0 +1,60 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import static com.inrupt.client.vocabulary.RDF.type; + +import com.inrupt.client.spi.RDFFactory; +import com.inrupt.client.vocabulary.ACP; +import com.inrupt.rdf.wrapping.commons.ValueMappings; +import com.inrupt.rdf.wrapping.commons.WrapperIRI; + +import java.util.Set; + +import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.IRI; +import org.apache.commons.rdf.api.RDF; +import org.apache.commons.rdf.api.RDFTerm; + +public class AccessControl extends WrapperIRI { + + static final RDF rdf = RDFFactory.getInstance(); + + public AccessControl(final RDFTerm original, final Graph graph) { + super(original, graph); + graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.AccessControl.toString())); + } + + public static IRI asResource(final AccessControl accessControl, final Graph graph) { + graph.add(accessControl, rdf.createIRI(type.toString()), rdf.createIRI(ACP.AccessControl.toString())); + accessControl.apply().forEach(policy -> { + graph.add(accessControl, rdf.createIRI(ACP.apply.toString()), policy); + Policy.asResource(policy, graph); + }); + return accessControl; + } + + public Set apply() { + return objects(rdf.createIRI(ACP.apply.toString()), + Policy::asResource, ValueMappings.as(Policy.class)); + } +} + diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java new file mode 100644 index 00000000000..9b6f16b2825 --- /dev/null +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -0,0 +1,128 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import com.inrupt.client.RDFSource; +import com.inrupt.client.vocabulary.ACP; +import com.inrupt.client.vocabulary.RDF; +import com.inrupt.rdf.wrapping.commons.ValueMappings; +import com.inrupt.rdf.wrapping.commons.WrapperIRI; + +import java.net.URI; +import java.util.Set; +import java.util.UUID; +import java.util.function.Consumer; + +import org.apache.commons.rdf.api.Dataset; +import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.RDFTerm; + +/** + * An Access Control Resource type. + */ +public class AccessControlResource extends RDFSource { + + private static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); + + public AccessControlResource(final URI identifier, final Dataset dataset) { + super(identifier, dataset); + dataset.add(null, rdf.createIRI(identifier.toString()), rdf.createIRI(RDF.type.toString()), + rdf.createIRI(ACP.AccessControlResource.toString())); + } + + public Set accessControl() { + return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).accessControl(); + } + + public Set memberAccessControl() { + return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).memberAccessControl(); + } + + static class ACPNode extends WrapperIRI { + public ACPNode(final RDFTerm original, final Graph graph) { + super(original, graph); + } + + public Set memberAccessControl() { + return objects(rdf.createIRI(ACP.memberAccessControl.toString()), + AccessControl::asResource, ValueMappings.as(AccessControl.class)); + } + + public Set accessControl() { + return objects(rdf.createIRI(ACP.accessControl.toString()), + AccessControl::asResource, ValueMappings.as(AccessControl.class)); + } + } + + public AccessControl accessControl(final Policy... policies) { + final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); + final var ac = new AccessControl(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + for (final var policy : policies) { + ac.apply().add(policy); + } + return ac; + } + + public Policy authenticatedAgentPolicy(final URI... access) { + return agentPolicy(ACP.AuthenticatedAgent, access); + } + + public Policy anyAgentPolicy(final URI... access) { + return agentPolicy(ACP.PublicAgent, access); + } + + public Policy anyClientPolicy(final URI... access) { + return clientPolicy(ACP.PublicClient, access); + } + + public Policy anyIssuerPolicy(final URI... access) { + return issuerPolicy(ACP.PublicIssuer, access); + } + + public Policy accessGrantsPolicy(final URI... access) { + return simplePolicy(matcher -> matcher.vc().add(SOLID_ACCESS_GRANT), access); + } + + public Policy agentPolicy(final URI agent, final URI... access) { + return simplePolicy(matcher -> matcher.agent().add(agent), access); + } + + public Policy clientPolicy(final URI client, final URI... access) { + return simplePolicy(matcher -> matcher.client().add(client), access); + } + + public Policy issuerPolicy(final URI issuer, final URI... access) { + return simplePolicy(matcher -> matcher.issuer().add(issuer), access); + } + + Policy simplePolicy(final Consumer handler, final URI... access) { + final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); + final var matcher = new Matcher(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + handler.accept(matcher); + + final var policy = new Policy(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + for (final var item : access) { + policy.allow().add(item); + } + policy.allOf().add(matcher); + return policy; + } +} diff --git a/acp/src/main/java/com/inrupt/client/acp/Matcher.java b/acp/src/main/java/com/inrupt/client/acp/Matcher.java new file mode 100644 index 00000000000..95c5f73b89a --- /dev/null +++ b/acp/src/main/java/com/inrupt/client/acp/Matcher.java @@ -0,0 +1,81 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import static com.inrupt.client.vocabulary.RDF.type; + +import com.inrupt.client.spi.RDFFactory; +import com.inrupt.client.vocabulary.ACP; +import com.inrupt.rdf.wrapping.commons.TermMappings; +import com.inrupt.rdf.wrapping.commons.ValueMappings; +import com.inrupt.rdf.wrapping.commons.WrapperIRI; + +import java.net.URI; +import java.util.Set; + +import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.IRI; +import org.apache.commons.rdf.api.RDF; +import org.apache.commons.rdf.api.RDFTerm; + +public class Matcher extends WrapperIRI { + + static final RDF rdf = RDFFactory.getInstance(); + + public Matcher(final RDFTerm original, final Graph graph) { + super(original, graph); + graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Matcher.toString())); + } + + static RDFTerm asResource(final Matcher matcher, final Graph graph) { + graph.add(matcher, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Matcher.toString())); + matcher.vc().forEach(vc -> + graph.add(matcher, rdf.createIRI(ACP.vc.toString()), rdf.createIRI(vc.toString()))); + matcher.agent().forEach(agent -> + graph.add(matcher, rdf.createIRI(ACP.agent.toString()), rdf.createIRI(agent.toString()))); + matcher.client().forEach(client -> + graph.add(matcher, rdf.createIRI(ACP.client.toString()), rdf.createIRI(client.toString()))); + matcher.issuer().forEach(issuer -> + graph.add(matcher, rdf.createIRI(ACP.issuer.toString()), rdf.createIRI(issuer.toString()))); + return matcher; + } + + public Set vc() { + return objects(rdf.createIRI(ACP.vc.toString()), + TermMappings::asIri, ValueMappings::iriAsUri); + } + + public Set agent() { + return objects(rdf.createIRI(ACP.agent.toString()), + TermMappings::asIri, ValueMappings::iriAsUri); + } + + public Set client() { + return objects(rdf.createIRI(ACP.client.toString()), + TermMappings::asIri, ValueMappings::iriAsUri); + } + + public Set issuer() { + return objects(rdf.createIRI(ACP.issuer.toString()), + TermMappings::asIri, ValueMappings::iriAsUri); + } +} + diff --git a/acp/src/main/java/com/inrupt/client/acp/Policy.java b/acp/src/main/java/com/inrupt/client/acp/Policy.java new file mode 100644 index 00000000000..26133fb8ab1 --- /dev/null +++ b/acp/src/main/java/com/inrupt/client/acp/Policy.java @@ -0,0 +1,78 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import static com.inrupt.client.vocabulary.RDF.type; + +import com.inrupt.client.spi.RDFFactory; +import com.inrupt.client.vocabulary.ACP; +import com.inrupt.rdf.wrapping.commons.TermMappings; +import com.inrupt.rdf.wrapping.commons.ValueMappings; +import com.inrupt.rdf.wrapping.commons.WrapperIRI; + +import java.net.URI; +import java.util.Set; + +import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.IRI; +import org.apache.commons.rdf.api.RDF; +import org.apache.commons.rdf.api.RDFTerm; + +public class Policy extends WrapperIRI { + + static final RDF rdf = RDFFactory.getInstance(); + + public Policy(final RDFTerm original, final Graph graph) { + super(original, graph); + graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Policy.toString())); + } + + static IRI asResource(final Policy policy, final Graph graph) { + graph.add(policy, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Policy.toString())); + policy.allOf().forEach(matcher -> { + graph.add(policy, rdf.createIRI(ACP.allOf.toString()), matcher); + Matcher.asResource(matcher, graph); + }); + policy.anyOf().forEach(matcher -> { + graph.add(policy, rdf.createIRI(ACP.anyOf.toString()), matcher); + Matcher.asResource(matcher, graph); + }); + policy.allow().forEach(allow -> + graph.add(policy, rdf.createIRI(ACP.allow.toString()), rdf.createIRI(allow.toString()))); + return policy; + } + + public Set allOf() { + return objects(rdf.createIRI(ACP.allOf.toString()), + Matcher::asResource, ValueMappings.as(Matcher.class)); + } + + public Set anyOf() { + return objects(rdf.createIRI(ACP.anyOf.toString()), + Matcher::asResource, ValueMappings.as(Matcher.class)); + } + + public Set allow() { + return objects(rdf.createIRI(ACP.allow.toString()), + TermMappings::asIri, ValueMappings::iriAsUri); + } +} + diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java new file mode 100644 index 00000000000..d0d0973298e --- /dev/null +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -0,0 +1,199 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.*; + +import com.inrupt.client.solid.SolidSyncClient; +import com.inrupt.client.spi.RDFFactory; +import com.inrupt.client.vocabulary.ACL; + +import java.io.IOException; +import java.net.URI; + +import org.apache.commons.rdf.api.RDF; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class AccessControlResourceTest { + + static final AcpMockHttpService mockHttpServer = new AcpMockHttpService(); + static final SolidSyncClient client = SolidSyncClient.getClient(); + static final RDF rdf = RDFFactory.getInstance(); + + @BeforeAll + static void setup() { + mockHttpServer.start(); + } + + @AfterAll + static void teardown() { + mockHttpServer.stop(); + } + + @Test + void testAcr1CheckValues() { + final var uri = mockHttpServer.acr1(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(2, acr.memberAccessControl().size()); + + final var matchers1 = acr.accessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + policy.allow().contains(ACL.Write) && policy.allow().size() == 2) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers1.size()); + assertTrue(matchers1.get(0).agent().contains(URI.create("https://id.example/user"))); + + final var matchers2 = acr.accessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + !policy.allow().contains(ACL.Write) && policy.allow().size() == 1) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers2.size()); + assertTrue(matchers2.get(0).vc().contains(URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"))); + + final var matchers3 = acr.memberAccessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + policy.allow().contains(ACL.Write) && policy.allow().size() == 2) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers3.size()); + assertTrue(matchers3.get(0).agent().contains(URI.create("https://id.example/user"))); + + final var matchers4 = acr.memberAccessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + !policy.allow().contains(ACL.Write) && policy.allow().size() == 1) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers4.size()); + assertTrue(matchers4.get(0).client().contains(URI.create("https://app.example/id"))); + assertTrue(matchers4.get(0).vc().contains(URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"))); + } + } + + @Test + void testAcr1Mutation() throws IOException { + final var uri = mockHttpServer.acr1(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + final var quads = acr.stream().toList(); + + acr.accessControl().forEach(ac -> + ac.apply().forEach(policy -> policy.allow().add(ACL.Append))); + assertEquals(quads.size(), acr.size() - 2); + + try (var entity = acr.getEntity()) { + assertTrue(new String(entity.readAllBytes(), UTF_8).contains(ACL.Append.toString())); + } + + acr.accessControl().forEach(ac -> + ac.apply().forEach(policy -> policy.allow().remove(ACL.Write))); + assertEquals(quads.size(), acr.size() - 1); + } + } + + @Test + void testAcr2CheckValues() { + final var uri = mockHttpServer.acr2(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + final var matchers1 = acr.accessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + policy.allow().contains(ACL.Write) && policy.allow().size() == 2) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers1.size()); + assertTrue(matchers1.get(0).agent().contains(URI.create("https://id.example/user2"))); + + final var matchers2 = acr.accessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + !policy.allow().contains(ACL.Write) && policy.allow().size() == 1) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(1, matchers2.size()); + assertTrue(matchers2.get(0).agent().contains(URI.create("https://bot.example/id"))); + + final var matchers3 = acr.memberAccessControl().stream() + .flatMap(ac -> ac.apply().stream()) + .filter(policy -> + !policy.allow().contains(ACL.Write) && policy.allow().size() == 1) + .flatMap(policy -> policy.allOf().stream()) + .toList(); + assertEquals(2, matchers3.size()); + assertTrue(matchers3.stream().anyMatch(matcher -> + matcher.vc().contains(URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant")))); + assertTrue(matchers3.stream().anyMatch(matcher -> + matcher.agent().contains(URI.create("https://bot.example/id")))); + } + } + + @Test + void buildAcr() { + final var identifier = "https://data.example/resource"; + final var dataset = rdf.createDataset(); + final var uri = URI.create(identifier); + + final var matcher = new Matcher(rdf.createIRI(identifier + "#matcher"), rdf.createGraph()); + matcher.agent().add(URI.create("https://id.example/agent")); + + final var policy = new Policy(rdf.createIRI(identifier + "#policy"), rdf.createGraph()); + policy.allOf().add(matcher); + policy.allow().add(ACL.Read); + policy.allow().add(ACL.Write); + + final var accessControl = new AccessControl(rdf.createIRI(identifier + "#access-control"), rdf.createGraph()); + accessControl.apply().add(policy); + + final var acr = new AccessControlResource(uri, dataset); + acr.accessControl().add(accessControl); + + assertEquals(10, acr.size()); + } + + @Test + void buildAcrWithExistingPolicies() { + final var identifier = "https://data.example/resource"; + final var dataset = rdf.createDataset(); + final var uri = URI.create(identifier); + + final var acr = new AccessControlResource(uri, dataset); + acr.accessControl().add(acr.accessControl( + acr.authenticatedAgentPolicy(ACL.Read, ACL.Write), + acr.anyAgentPolicy(ACL.Read), + acr.anyClientPolicy(ACL.Read, ACL.Write))); + acr.memberAccessControl().add(acr.accessControl( + acr.anyIssuerPolicy(ACL.Read), + acr.accessGrantsPolicy(ACL.Read, ACL.Write))); + + assertEquals(38, acr.size()); + } +} diff --git a/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java b/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java new file mode 100644 index 00000000000..548374d5a2d --- /dev/null +++ b/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java @@ -0,0 +1,75 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.inrupt.client.acp; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +import java.net.URI; + +public class AcpMockHttpService { + + private final WireMockServer wireMockServer; + + public AcpMockHttpService() { + wireMockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort()); + } + + public URI acr1() { + return URI.create(wireMockServer.baseUrl() + "/acr-1"); + } + + public URI acr2() { + return URI.create(wireMockServer.baseUrl() + "/acr-2"); + } + + private void setupMocks() { + wireMockServer.stubFor(get(urlEqualTo("/acr-1")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withBodyFile("acr-1.ttl") + ) + ); + wireMockServer.stubFor(get(urlEqualTo("/acr-2")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withBodyFile("acr-2.ttl") + ) + ); + } + + public String start() { + wireMockServer.start(); + + setupMocks(); + + return wireMockServer.baseUrl(); + } + + public void stop() { + wireMockServer.stop(); + } +} + diff --git a/acp/src/test/resources/__files/acr-1.ttl b/acp/src/test/resources/__files/acr-1.ttl new file mode 100644 index 00000000000..4750ad5fd5f --- /dev/null +++ b/acp/src/test/resources/__files/acr-1.ttl @@ -0,0 +1,31 @@ +@prefix acp: . +@prefix acl: . +@prefix vc: . + +<> + a acp:AccessControlResource ; + acp:accessControl <#owner-access-control> , <#access-grants-access-control> ; + acp:memberAccessControl <#owner-access-control> , <#access-grants-access-control> . + +<#owner-access-control> + a acp:AccessControl ; + acp:apply <#owner-policy> . +<#owner-policy> + a acp:Policy ; + acp:allOf <#owner-matcher> ; + acp:allow acl:Read , acl:Write . +<#owner-matcher> + a acp:Matcher ; + acp:agent . + +<#access-grants-access-control> + a acp:AccessControl ; + acp:apply <#access-grants-policy> . +<#access-grants-policy> + a acp:Policy ; + acp:allOf <#access-grants-matcher> ; + acp:allow acl:Read . +<#access-grants-matcher> + a acp:Matcher ; + acp:client ; + acp:vc vc:SolidAccessGrant . diff --git a/acp/src/test/resources/__files/acr-2.ttl b/acp/src/test/resources/__files/acr-2.ttl new file mode 100644 index 00000000000..fb9497c424b --- /dev/null +++ b/acp/src/test/resources/__files/acr-2.ttl @@ -0,0 +1,42 @@ +@prefix acp: . +@prefix acl: . +@prefix vc: . + +<> + a acp:AccessControlResource ; + acp:accessControl <#owner-access-control> , <#indexer-access-control> ; + acp:memberAccessControl <#owner-access-control> , <#indexer-access-control> , <#vc-access-control> . + +<#owner-access-control> + a acp:AccessControl ; + acp:apply <#owner-policy> . +<#owner-policy> + a acp:Policy ; + acp:allOf <#owner-matcher> ; + acp:allow acl:Read , acl:Write . +<#owner-matcher> + a acp:Matcher ; + acp:agent . + +<#indexer-access-control> + a acp:AccessControl ; + acp:apply <#indexer-policy> . +<#indexer-policy> + a acp:Policy ; + acp:allOf <#indexer-matcher> ; + acp:allow acl:Read . +<#indexer-matcher> + a acp:Matcher ; + acp:issuer ; + acp:agent . + +<#vc-access-control> + a acp:AccessControl ; + acp:apply <#vc-policy> . +<#vc-policy> + a acp:Policy ; + acp:allOf <#vc-matcher> ; + acp:allow acl:Read . +<#vc-matcher> + a acp:Matcher ; + acp:vc vc:SolidAccessGrant . diff --git a/acp/src/test/resources/simplelogger.properties b/acp/src/test/resources/simplelogger.properties new file mode 100644 index 00000000000..682ae67e438 --- /dev/null +++ b/acp/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.log.org.eclipse.jetty=warn diff --git a/bom/pom.xml b/bom/pom.xml index d9bd3a59387..108f9aa59b1 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -28,6 +28,11 @@ inrupt-client-accessgrant ${project.version} + + com.inrupt.client + inrupt-client-acp + ${project.version} + com.inrupt.client inrupt-client-caffeine diff --git a/pom.xml b/pom.xml index 1e815dfee88..b5c1589672a 100644 --- a/pom.xml +++ b/pom.xml @@ -97,6 +97,7 @@ access-grant + acp api bom caffeine diff --git a/reports/pom.xml b/reports/pom.xml index e6a36955ca5..5e399c21159 100644 --- a/reports/pom.xml +++ b/reports/pom.xml @@ -22,6 +22,11 @@ inrupt-client-accessgrant ${project.version} + + com.inrupt.client + inrupt-client-acp + ${project.version} + com.inrupt.client inrupt-client-caffeine @@ -107,6 +112,11 @@ inrupt-client-webid ${project.version} + + com.inrupt.client + inrupt-client-vocabulary + ${project.version} + diff --git a/runtime/pom.xml b/runtime/pom.xml index 2461d401b2c..734b6972825 100644 --- a/runtime/pom.xml +++ b/runtime/pom.xml @@ -26,6 +26,11 @@ inrupt-client-accessgrant ${project.version} + + com.inrupt.client + inrupt-client-acp + ${project.version} + com.inrupt.client inrupt-client-caffeine diff --git a/vocabulary/pom.xml b/vocabulary/pom.xml index bf413e47185..11e56732a32 100644 --- a/vocabulary/pom.xml +++ b/vocabulary/pom.xml @@ -46,6 +46,10 @@ + + org.jacoco + jacoco-maven-plugin + diff --git a/vocabulary/src/main/java/com/inrupt/client/vocabulary/ACP.java b/vocabulary/src/main/java/com/inrupt/client/vocabulary/ACP.java index 60b01bac0b1..c5e5dbad9de 100644 --- a/vocabulary/src/main/java/com/inrupt/client/vocabulary/ACP.java +++ b/vocabulary/src/main/java/com/inrupt/client/vocabulary/ACP.java @@ -31,6 +31,19 @@ public final class ACP { private static String namespace = "http://www.w3.org/ns/solid/acp#"; + // Named Individuals + /** The acp:AuthenticatedAgent URI. */ + public static final URI AuthenticatedAgent = URI.create(namespace + "AuthenticatedAgent"); + + /** The acp:PublicAgent URI. */ + public static final URI PublicAgent = URI.create(namespace + "PublicAgent"); + + /** The acp:PublicClient URI. */ + public static final URI PublicClient = URI.create(namespace + "PublicClient"); + + /** The acp:PublicIssuer URI. */ + public static final URI PublicIssuer = URI.create(namespace + "PublicIssuer"); + // Properties /** * The acp:resource URI. @@ -76,6 +89,18 @@ public final class ACP { * The acp:vc URI. */ public static final URI vc = URI.create(namespace + "vc"); + /** + * The acp:client URI. + */ + public static final URI client = URI.create(namespace + "client"); + /** + * The acp:agent URI. + */ + public static final URI agent = URI.create(namespace + "agent"); + /** + * The acp:issuer URI. + */ + public static final URI issuer = URI.create(namespace + "issuer"); // Classes /** From 9986d48f15817f319b1d269ce757eab3c405748f Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Tue, 30 Sep 2025 13:26:36 -0500 Subject: [PATCH 2/7] Add compact method --- .../client/acp/AccessControlResource.java | 113 ++++++++++++++++++ .../client/acp/AccessControlResourceTest.java | 45 +++++++ 2 files changed, 158 insertions(+) diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java index 9b6f16b2825..998a2d57c75 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -33,6 +33,7 @@ import org.apache.commons.rdf.api.Dataset; import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.Quad; import org.apache.commons.rdf.api.RDFTerm; /** @@ -42,20 +43,75 @@ public class AccessControlResource extends RDFSource { private static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); + /** + * Create a new Access Control Resource. + * + * @param identifier the resource identifier + * @param dataset the underlying dataset for the resource + */ public AccessControlResource(final URI identifier, final Dataset dataset) { super(identifier, dataset); dataset.add(null, rdf.createIRI(identifier.toString()), rdf.createIRI(RDF.type.toString()), rdf.createIRI(ACP.AccessControlResource.toString())); } + /** + * Retrieve the acp:accessControl structures. + * + *

accessControl resources are applied (non-recursively) to a container or resource. + * + * @return a collection of {@link AccessControl} objects + */ public Set accessControl() { return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).accessControl(); } + /** + * Retrieve the acp:memberAccessControl structures. + * + *

memberAccessControl resources are applied recursively to a containment hierarchy. + * + * @return a collection of {@link AccessControl} objects + */ public Set memberAccessControl() { return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).memberAccessControl(); } + /** + * Compact the internal data. + */ + public void compact() { + final var accessControls = stream(null, null, rdf.createIRI(RDF.type.toString()), + rdf.createIRI(ACP.AccessControl.toString())).map(Quad::getSubject).toList(); + for (final var accessControl : accessControls) { + if (!contains(null, null, null, accessControl)) { + for (final var quad : stream(null, accessControl, null, null).toList()) { + remove(quad); + } + } + } + + final var policies = stream(null, null, rdf.createIRI(RDF.type.toString()), + rdf.createIRI(ACP.Policy.toString())).map(Quad::getSubject).toList(); + for (final var policy : policies) { + if (!contains(null, null, null, policy)) { + for (final var quad : stream(null, policy, null, null).toList()) { + remove(quad); + } + } + } + + final var matchers = stream(null, null, rdf.createIRI(RDF.type.toString()), + rdf.createIRI(ACP.Matcher.toString())).map(Quad::getSubject).toList(); + for (final var matcher : matchers) { + if (!contains(null, null, null, matcher)) { + for (final var quad : stream(null, matcher, null, null).toList()) { + remove(quad); + } + } + } + } + static class ACPNode extends WrapperIRI { public ACPNode(final RDFTerm original, final Graph graph) { super(original, graph); @@ -72,6 +128,12 @@ public Set accessControl() { } } + /** + * Add policies to the access control resource. + * + * @param policies the policies to add + * @return the access control structure + */ public AccessControl accessControl(final Policy... policies) { final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); final var ac = new AccessControl(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); @@ -81,34 +143,85 @@ public AccessControl accessControl(final Policy... policies) { return ac; } + /** + * Create a policy that matches authenticated agents. + * + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy authenticatedAgentPolicy(final URI... access) { return agentPolicy(ACP.AuthenticatedAgent, access); } + /** + * Create a policy that matches all agents. + * + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy anyAgentPolicy(final URI... access) { return agentPolicy(ACP.PublicAgent, access); } + /** + * Create a policy that matches all clients. + * + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy anyClientPolicy(final URI... access) { return clientPolicy(ACP.PublicClient, access); } + /** + * Create a policy that matches all issuers. + * + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy anyIssuerPolicy(final URI... access) { return issuerPolicy(ACP.PublicIssuer, access); } + /** + * Create a policy that matches access grants. + * + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy accessGrantsPolicy(final URI... access) { return simplePolicy(matcher -> matcher.vc().add(SOLID_ACCESS_GRANT), access); } + /** + * Create a policy that matches a particular agent. + * + * @param agent the agent identifier + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy agentPolicy(final URI agent, final URI... access) { return simplePolicy(matcher -> matcher.agent().add(agent), access); } + /** + * Create a policy that matches a particular client. + * + * @param client the client identifier + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy clientPolicy(final URI client, final URI... access) { return simplePolicy(matcher -> matcher.client().add(client), access); } + /** + * Create a policy that matches a particular issuer. + * + * @param issuer the issuer identifier + * @param access the access levels, such as Read or Write + * @return the new policy + */ public Policy issuerPolicy(final URI issuer, final URI... access) { return simplePolicy(matcher -> matcher.issuer().add(issuer), access); } diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java index d0d0973298e..acdd941230b 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -179,6 +179,51 @@ void buildAcr() { assertEquals(10, acr.size()); } + @Test + void testAcr1RemoveValues() { + final var uri = mockHttpServer.acr2(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(29, acr.size()); + + // Remove single matcher and compact + for (final var accessControl : acr.accessControl()) { + for (final var policy : accessControl.apply()) { + if (policy.allow().contains(ACL.Write) && policy.allow().size() == 2) { + for (final var matcher : policy.allOf()) { + policy.allOf().remove(matcher); + } + } + } + } + assertEquals(28, acr.size()); + acr.compact(); + assertEquals(26, acr.size()); + + // Remove single policy and compact + for (final var accessControl : acr.accessControl()) { + for (final var policy : accessControl.apply()) { + if (policy.allow().contains(ACL.Write) && policy.allow().size() == 2) { + accessControl.apply().remove(policy); + } + } + } + assertEquals(25, acr.size()); + acr.compact(); + assertEquals(22, acr.size()); + + // Remove single access control and compact - no compaction involved + final var ac = acr.accessControl().stream().findFirst().get(); + acr.accessControl().remove(ac); + assertEquals(21, acr.size()); + acr.compact(); + assertEquals(21, acr.size()); + } + } + @Test void buildAcrWithExistingPolicies() { final var identifier = "https://data.example/resource"; From 30c422720ec7b37f19d22683e1617e7b841a4b2b Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Tue, 30 Sep 2025 14:38:44 -0500 Subject: [PATCH 3/7] Additional high-level API --- .../client/acp/AccessControlResource.java | 93 ++++++++++++++++--- .../java/com/inrupt/client/acp/Policy.java | 5 + 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java index 998a2d57c75..2e2ec8d1688 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -30,9 +30,11 @@ import java.util.Set; import java.util.UUID; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.apache.commons.rdf.api.Dataset; import org.apache.commons.rdf.api.Graph; +import org.apache.commons.rdf.api.IRI; import org.apache.commons.rdf.api.Quad; import org.apache.commons.rdf.api.RDFTerm; @@ -51,8 +53,7 @@ public class AccessControlResource extends RDFSource { */ public AccessControlResource(final URI identifier, final Dataset dataset) { super(identifier, dataset); - dataset.add(null, rdf.createIRI(identifier.toString()), rdf.createIRI(RDF.type.toString()), - rdf.createIRI(ACP.AccessControlResource.toString())); + dataset.add(null, asIRI(identifier), asIRI(RDF.type), asIRI(ACP.AccessControlResource)); } /** @@ -63,7 +64,7 @@ public AccessControlResource(final URI identifier, final Dataset dataset) { * @return a collection of {@link AccessControl} objects */ public Set accessControl() { - return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).accessControl(); + return new ACPNode(asIRI(getIdentifier()), getGraph()).accessControl(); } /** @@ -74,15 +75,15 @@ public Set accessControl() { * @return a collection of {@link AccessControl} objects */ public Set memberAccessControl() { - return new ACPNode(rdf.createIRI(getIdentifier().toString()), getGraph()).memberAccessControl(); + return new ACPNode(asIRI(getIdentifier()), getGraph()).memberAccessControl(); } /** * Compact the internal data. */ public void compact() { - final var accessControls = stream(null, null, rdf.createIRI(RDF.type.toString()), - rdf.createIRI(ACP.AccessControl.toString())).map(Quad::getSubject).toList(); + final var accessControls = stream(null, null, asIRI(RDF.type), asIRI(ACP.AccessControl)) + .map(Quad::getSubject).toList(); for (final var accessControl : accessControls) { if (!contains(null, null, null, accessControl)) { for (final var quad : stream(null, accessControl, null, null).toList()) { @@ -91,8 +92,7 @@ public void compact() { } } - final var policies = stream(null, null, rdf.createIRI(RDF.type.toString()), - rdf.createIRI(ACP.Policy.toString())).map(Quad::getSubject).toList(); + final var policies = stream(null, null, asIRI(RDF.type), asIRI(ACP.Policy)).map(Quad::getSubject).toList(); for (final var policy : policies) { if (!contains(null, null, null, policy)) { for (final var quad : stream(null, policy, null, null).toList()) { @@ -101,8 +101,7 @@ public void compact() { } } - final var matchers = stream(null, null, rdf.createIRI(RDF.type.toString()), - rdf.createIRI(ACP.Matcher.toString())).map(Quad::getSubject).toList(); + final var matchers = stream(null, null, asIRI(RDF.type), asIRI(ACP.Matcher)).map(Quad::getSubject).toList(); for (final var matcher : matchers) { if (!contains(null, null, null, matcher)) { for (final var quad : stream(null, matcher, null, null).toList()) { @@ -112,18 +111,74 @@ public void compact() { } } + /** + * Merge two or more policies into a single policies with combined matchers. + * + * @param policies the policies to merge + * @return the merged policy + */ + public Policy merge(final Policy... policies) { + final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); + final var policy = new Policy(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + for (final var p : policies) { + policy.allOf().addAll(p.allOf()); + policy.anyOf().addAll(p.anyOf()); + policy.noneOf().addAll(p.noneOf()); + } + return policy; + } + + public enum MatcherType { + AGENT(ACP.agent), CLIENT(ACP.client), ISSUER(ACP.issuer), VC(ACP.vc); + + private final URI predicate; + + MatcherType(final URI predicate) { + this.predicate = predicate; + } + + public IRI asIRI() { + return AccessControlResource.asIRI(predicate); + } + + public URI asURI() { + return predicate; + } + } + + /** + * Find a policy, given a type, value and set of modes. + * + * @param type the matcher type + * @param value the matcher value + * @param modes the expected modes of the enclosing policy + * @return the matched policies + */ + public Set find(final MatcherType type, final URI value, final Set modes) { + return stream(null, null, type.asIRI(), asIRI(value)) + .map(Quad::getSubject) + .flatMap(matcher -> stream(null, null, null, matcher)) + .map(Quad::getSubject) + .filter(policy -> contains(null, policy, asIRI(RDF.type), asIRI(ACP.Policy))) + .filter(policy -> stream(null, policy, asIRI(ACP.allow), null) + .map(Quad::getObject).filter(IRI.class::isInstance).map(IRI.class::cast) + .map(IRI::getIRIString).map(URI::create).toList().containsAll(modes)) + .map(policy -> new Policy(policy, getGraph())) + .collect(Collectors.toSet()); + } + static class ACPNode extends WrapperIRI { public ACPNode(final RDFTerm original, final Graph graph) { super(original, graph); } public Set memberAccessControl() { - return objects(rdf.createIRI(ACP.memberAccessControl.toString()), + return objects(asIRI(ACP.memberAccessControl), AccessControl::asResource, ValueMappings.as(AccessControl.class)); } public Set accessControl() { - return objects(rdf.createIRI(ACP.accessControl.toString()), + return objects(asIRI(ACP.accessControl), AccessControl::asResource, ValueMappings.as(AccessControl.class)); } } @@ -136,7 +191,7 @@ public Set accessControl() { */ public AccessControl accessControl(final Policy... policies) { final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); - final var ac = new AccessControl(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + final var ac = new AccessControl(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); for (final var policy : policies) { ac.apply().add(policy); } @@ -228,14 +283,22 @@ public Policy issuerPolicy(final URI issuer, final URI... access) { Policy simplePolicy(final Consumer handler, final URI... access) { final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); - final var matcher = new Matcher(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + final var matcher = new Matcher(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); handler.accept(matcher); - final var policy = new Policy(rdf.createIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); + final var policy = new Policy(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); for (final var item : access) { policy.allow().add(item); } policy.allOf().add(matcher); return policy; } + + private static IRI asIRI(final URI uri) { + return asIRI(uri.toString()); + } + + private static IRI asIRI(final String uri) { + return rdf.createIRI(uri); + } } diff --git a/acp/src/main/java/com/inrupt/client/acp/Policy.java b/acp/src/main/java/com/inrupt/client/acp/Policy.java index 26133fb8ab1..a8aabda59ae 100644 --- a/acp/src/main/java/com/inrupt/client/acp/Policy.java +++ b/acp/src/main/java/com/inrupt/client/acp/Policy.java @@ -70,6 +70,11 @@ public Set anyOf() { Matcher::asResource, ValueMappings.as(Matcher.class)); } + public Set noneOf() { + return objects(rdf.createIRI(ACP.noneOf.toString()), + Matcher::asResource, ValueMappings.as(Matcher.class)); + } + public Set allow() { return objects(rdf.createIRI(ACP.allow.toString()), TermMappings::asIri, ValueMappings::iriAsUri); From afd31bef5b21cc8f3c59c1c96ae989adc080967b Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Wed, 1 Oct 2025 12:28:02 -0500 Subject: [PATCH 4/7] Add tests for find and compaction --- .../client/acp/AccessControlResource.java | 27 ++++++------ .../client/acp/AccessControlResourceTest.java | 42 +++++++++++++++++++ 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java index 2e2ec8d1688..d28f201dd03 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -32,6 +32,7 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import org.apache.commons.rdf.api.BlankNodeOrIRI; import org.apache.commons.rdf.api.Dataset; import org.apache.commons.rdf.api.Graph; import org.apache.commons.rdf.api.IRI; @@ -43,7 +44,7 @@ */ public class AccessControlResource extends RDFSource { - private static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); + public static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); /** * Create a new Access Control Resource. @@ -85,28 +86,24 @@ public void compact() { final var accessControls = stream(null, null, asIRI(RDF.type), asIRI(ACP.AccessControl)) .map(Quad::getSubject).toList(); for (final var accessControl : accessControls) { - if (!contains(null, null, null, accessControl)) { - for (final var quad : stream(null, accessControl, null, null).toList()) { - remove(quad); - } - } + removeUnusedStatements(accessControl); } final var policies = stream(null, null, asIRI(RDF.type), asIRI(ACP.Policy)).map(Quad::getSubject).toList(); for (final var policy : policies) { - if (!contains(null, null, null, policy)) { - for (final var quad : stream(null, policy, null, null).toList()) { - remove(quad); - } - } + removeUnusedStatements(policy); } final var matchers = stream(null, null, asIRI(RDF.type), asIRI(ACP.Matcher)).map(Quad::getSubject).toList(); for (final var matcher : matchers) { - if (!contains(null, null, null, matcher)) { - for (final var quad : stream(null, matcher, null, null).toList()) { - remove(quad); - } + removeUnusedStatements(matcher); + } + } + + void removeUnusedStatements(final T resource) { + if (!contains(null, null, null, resource)) { + for (final var quad : stream(null, resource, null, null).toList()) { + remove(quad); } } } diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java index acdd941230b..6240b55e6fd 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -29,6 +29,7 @@ import java.io.IOException; import java.net.URI; +import java.util.Set; import org.apache.commons.rdf.api.RDF; import org.junit.jupiter.api.AfterAll; @@ -179,6 +180,47 @@ void buildAcr() { assertEquals(10, acr.size()); } + @Test + void testAcrFindPolicies() { + final var uri = mockHttpServer.acr2(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(1, acr.find(AccessControlResource.MatcherType.VC, + AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read)).size()); + + assertEquals(0, acr.find(AccessControlResource.MatcherType.VC, + AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read, ACL.Write)).size()); + + assertEquals(0, acr.find(AccessControlResource.MatcherType.AGENT, + URI.create("https://bot.example/id"), Set.of(ACL.Read, ACL.Write)).size()); + + assertEquals(1, acr.find(AccessControlResource.MatcherType.AGENT, + URI.create("https://bot.example/id"), Set.of(ACL.Read)).size()); + } + } + + @Test + void testAcr1RemoveAccessControl() { + final var uri = mockHttpServer.acr2(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(29, acr.size()); + + acr.accessControl().stream().findFirst().ifPresent(acr.accessControl()::remove); + + // Check dataset size + assertEquals(28, acr.size()); + acr.compact(); + assertEquals(28, acr.size()); + + assertEquals(1, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + } + } + @Test void testAcr1RemoveValues() { final var uri = mockHttpServer.acr2(); From f82fd98e97902ee4c54979f86b9b2b6e00d6bd93 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Wed, 1 Oct 2025 20:11:39 -0500 Subject: [PATCH 5/7] Add support for dataset expansion --- acp/pom.xml | 11 +- .../com/inrupt/client/acp/AccessControl.java | 1 + .../client/acp/AccessControlResource.java | 106 +++++++++++++++++- .../client/acp/AccessControlResourceTest.java | 52 +++++++++ .../inrupt/client/acp/AcpMockHttpService.java | 36 ++++++ acp/src/test/resources/__files/acr-3.ttl | 18 +++ acp/src/test/resources/__files/acr-4.ttl | 31 +++++ .../test/resources/__files/not-an-acr.json | 4 + acp/src/test/resources/__files/not-an-acr.ttl | 7 ++ 9 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 acp/src/test/resources/__files/acr-3.ttl create mode 100644 acp/src/test/resources/__files/acr-4.ttl create mode 100644 acp/src/test/resources/__files/not-an-acr.json create mode 100644 acp/src/test/resources/__files/not-an-acr.ttl diff --git a/acp/pom.xml b/acp/pom.xml index 6843c21dcf5..82fff84ebf7 100644 --- a/acp/pom.xml +++ b/acp/pom.xml @@ -24,6 +24,11 @@ inrupt-client-vocabulary ${project.version} + + com.inrupt.client + inrupt-client-solid + ${project.version} + org.slf4j slf4j-api @@ -79,12 +84,6 @@ ${project.version} test - - com.inrupt.client - inrupt-client-solid - ${project.version} - test - org.slf4j slf4j-simple diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControl.java b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java index f02bd2444ac..dfcb659e887 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControl.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java @@ -49,6 +49,7 @@ public static IRI asResource(final AccessControl accessControl, final Graph grap graph.add(accessControl, rdf.createIRI(ACP.apply.toString()), policy); Policy.asResource(policy, graph); }); + return accessControl; } diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java index d28f201dd03..2392f52a2fc 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -21,6 +21,8 @@ package com.inrupt.client.acp; import com.inrupt.client.RDFSource; +import com.inrupt.client.solid.SolidClient; +import com.inrupt.client.solid.SolidSyncClient; import com.inrupt.client.vocabulary.ACP; import com.inrupt.client.vocabulary.RDF; import com.inrupt.rdf.wrapping.commons.ValueMappings; @@ -38,12 +40,16 @@ import org.apache.commons.rdf.api.IRI; import org.apache.commons.rdf.api.Quad; import org.apache.commons.rdf.api.RDFTerm; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * An Access Control Resource type. */ public class AccessControlResource extends RDFSource { + private static final Logger LOGGER = LoggerFactory.getLogger(AccessControlResource.class); + public static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); /** @@ -79,6 +85,104 @@ public Set memberAccessControl() { return new ACPNode(asIRI(getIdentifier()), getGraph()).memberAccessControl(); } + /** + * Expand the internal data using a synchronous client. + * + * @param client the solid client + * @return an expanded access control resource + */ + public AccessControlResource expand(final SolidSyncClient client) { + // Copy the data from the existing ACR into a new dataset + final var dataset = rdf.createDataset(); + stream().forEach(dataset::add); + + final var cache = rdf.createDataset(); + expandType(dataset, cache, asIRI(ACP.accessControl), asIRI(ACP.AccessControl), + uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.memberAccessControl), asIRI(ACP.AccessControl), + uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.apply), asIRI(ACP.Policy), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.allOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.anyOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.noneOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + + return new AccessControlResource(getIdentifier(), dataset); + } + + /** + * Expand the internal data using an asynchronous client. + * + * @param client the solid client + * @return an expanded access control resource + */ + public AccessControlResource expand(final SolidClient client) { + // Copy the data from the existing ACR into a new dataset + final var dataset = rdf.createDataset(); + stream().forEach(dataset::add); + + final var cache = rdf.createDataset(); + expandType(dataset, cache, asIRI(ACP.accessControl), asIRI(ACP.AccessControl), + uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.memberAccessControl), asIRI(ACP.AccessControl), + uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.apply), asIRI(ACP.Policy), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.allOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.anyOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + expandType(dataset, cache, asIRI(ACP.noneOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + + return new AccessControlResource(getIdentifier(), dataset); + } + + void expandType(final Dataset dataset, final Dataset cache, final IRI predicate, final IRI type, + final Consumer handler) { + final var subjects = dataset.stream(null, null, predicate, null) + .map(Quad::getObject).filter(IRI.class::isInstance).map(IRI.class::cast) + .filter(subject -> !dataset.contains(null, subject, asIRI(RDF.type), type)).toList(); + + for (final var subject : subjects) { + if (!cache.contains(null, subject, asIRI(RDF.type), type)) { + handler.accept(URI.create(subject.getIRIString())); + } + cache.stream(null, subject, null, null).forEach(dataset::add); + } + } + + void populateCache(final SolidSyncClient client, final URI uri, final Dataset cache) { + try (final var acr = client.read(uri, AccessControlResource.class)) { + acr.stream() + .filter(quad -> !isAccessControlResourceType(quad)) + .forEach(cache::add); + } catch (final Exception ex) { + LOGGER.atDebug() + .setMessage("Unable to fetch access control resource from {}: {}") + .addArgument(uri) + .addArgument(ex::getMessage) + .log(); + } + } + + void populateCache(final SolidClient client, final URI uri, final Dataset cache) { + client.read(uri, AccessControlResource.class).thenAccept(res -> { + try (final var acr = res) { + acr.stream() + .filter(quad -> !isAccessControlResourceType(quad)) + .forEach(cache::add); + } + }) + .exceptionally(err -> { + LOGGER.atDebug() + .setMessage("Unable to fetch access control resource from {}: {}") + .addArgument(uri) + .addArgument(err::getMessage) + .log(); + return null; + }).toCompletableFuture().join(); + } + + static boolean isAccessControlResourceType(final Quad quad) { + return asIRI(RDF.type).equals(quad.getPredicate()) && asIRI(ACP.AccessControlResource).equals(quad.getObject()); + } + /** * Compact the internal data. */ @@ -135,7 +239,7 @@ public enum MatcherType { } public IRI asIRI() { - return AccessControlResource.asIRI(predicate); + return AccessControlResource.asIRI(asURI()); } public URI asURI() { diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java index 6240b55e6fd..e09bcb6c4dd 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -23,6 +23,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.jupiter.api.Assertions.*; +import com.inrupt.client.solid.SolidClient; import com.inrupt.client.solid.SolidSyncClient; import com.inrupt.client.spi.RDFFactory; import com.inrupt.client.vocabulary.ACL; @@ -283,4 +284,55 @@ void buildAcrWithExistingPolicies() { assertEquals(38, acr.size()); } + + @Test + void expandAcr3Sync() { + final var uri = mockHttpServer.acr3(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(12, acr.size()); + + final var expanded = acr.expand(client); + assertEquals(29, expanded.size()); + assertEquals(12, acr.size()); + } + } + + @Test + void expandAcr3Async() { + final var uri = mockHttpServer.acr3(); + final var asyncClient = SolidClient.getClient(); + asyncClient.read(uri, AccessControlResource.class).thenAccept(res -> { + try (final var acr = res) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(12, acr.size()); + + final var expanded = acr.expand(asyncClient); + assertEquals(29, expanded.size()); + assertEquals(12, acr.size()); + } + }).toCompletableFuture().join(); + } + + @Test + void expandAcr4Sync() { + final var uri = mockHttpServer.acr4(); + try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(20, acr.size()); + + final var expanded = acr.expand(client); + assertEquals(22, expanded.size()); + assertEquals(20, acr.size()); + } + } } diff --git a/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java b/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java index 548374d5a2d..e15d587f014 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java +++ b/acp/src/test/java/com/inrupt/client/acp/AcpMockHttpService.java @@ -43,6 +43,14 @@ public URI acr2() { return URI.create(wireMockServer.baseUrl() + "/acr-2"); } + public URI acr3() { + return URI.create(wireMockServer.baseUrl() + "/acr-3"); + } + + public URI acr4() { + return URI.create(wireMockServer.baseUrl() + "/acr-4"); + } + private void setupMocks() { wireMockServer.stubFor(get(urlEqualTo("/acr-1")) .willReturn(aResponse() @@ -58,6 +66,34 @@ private void setupMocks() { .withBodyFile("acr-2.ttl") ) ); + wireMockServer.stubFor(get(urlEqualTo("/acr-3")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withBodyFile("acr-3.ttl") + ) + ); + wireMockServer.stubFor(get(urlEqualTo("/acr-4")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withBodyFile("acr-4.ttl") + ) + ); + wireMockServer.stubFor(get(urlEqualTo("/not-an-acr")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "text/turtle") + .withBodyFile("not-an-acr.ttl") + ) + ); + wireMockServer.stubFor(get(urlEqualTo("/also-not-an-acr")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBodyFile("not-an-acr.json") + ) + ); } public String start() { diff --git a/acp/src/test/resources/__files/acr-3.ttl b/acp/src/test/resources/__files/acr-3.ttl new file mode 100644 index 00000000000..8ffaeca57a8 --- /dev/null +++ b/acp/src/test/resources/__files/acr-3.ttl @@ -0,0 +1,18 @@ +@prefix acp: . + +<> + a acp:AccessControlResource ; + acp:accessControl <#owner-access-control> , <#indexer-access-control> ; + acp:memberAccessControl <#owner-access-control> , <#indexer-access-control> , <#vc-access-control> . + +<#owner-access-control> + a acp:AccessControl ; + acp:apply . + +<#indexer-access-control> + a acp:AccessControl ; + acp:apply . + +<#vc-access-control> + a acp:AccessControl ; + acp:apply . diff --git a/acp/src/test/resources/__files/acr-4.ttl b/acp/src/test/resources/__files/acr-4.ttl new file mode 100644 index 00000000000..ff95f776d39 --- /dev/null +++ b/acp/src/test/resources/__files/acr-4.ttl @@ -0,0 +1,31 @@ +@prefix acp: . +@prefix acl: . +@prefix vc: . + +<> + a acp:AccessControlResource ; + acp:accessControl <#owner-access-control> , <#indexer-access-control> ; + acp:memberAccessControl <#owner-access-control> , <#indexer-access-control> , <#vc-access-control> . + +<#owner-access-control> + a acp:AccessControl ; + acp:apply . + +<#indexer-access-control> + a acp:AccessControl ; + acp:apply <#indexer-policy> . +<#indexer-policy> + a acp:Policy ; + acp:allOf ; + acp:allow acl:Read . + +<#vc-access-control> + a acp:AccessControl ; + acp:apply <#vc-policy> . +<#vc-policy> + a acp:Policy ; + acp:allOf <#vc-matcher> ; + acp:allow acl:Read . +<#vc-matcher> + a acp:Matcher ; + acp:vc vc:SolidAccessGrant . diff --git a/acp/src/test/resources/__files/not-an-acr.json b/acp/src/test/resources/__files/not-an-acr.json new file mode 100644 index 00000000000..48272642adc --- /dev/null +++ b/acp/src/test/resources/__files/not-an-acr.json @@ -0,0 +1,4 @@ +{ + "type": "Type", + "title": "Example resource" +} diff --git a/acp/src/test/resources/__files/not-an-acr.ttl b/acp/src/test/resources/__files/not-an-acr.ttl new file mode 100644 index 00000000000..ad179e6536d --- /dev/null +++ b/acp/src/test/resources/__files/not-an-acr.ttl @@ -0,0 +1,7 @@ +@prefix dc: . +@prefix ex: . + +<> + a ex:Type ; + dc:title "Example resource" . + From 0e362e4e694bf5acdac82631deeba996ffee225c Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Thu, 2 Oct 2025 11:39:08 -0500 Subject: [PATCH 6/7] Add documentation and tests --- .../com/inrupt/client/acp/AccessControl.java | 38 ++- .../client/acp/AccessControlResource.java | 261 ++++++++++-------- .../java/com/inrupt/client/acp/Matcher.java | 82 ++++-- .../java/com/inrupt/client/acp/Policy.java | 87 ++++-- .../com/inrupt/client/acp/package-info.java | 51 ++++ .../client/acp/AccessControlResourceTest.java | 59 +++- acp/src/test/resources/__files/acr-4.ttl | 4 +- pom.xml | 1 - 8 files changed, 391 insertions(+), 192 deletions(-) create mode 100644 acp/src/main/java/com/inrupt/client/acp/package-info.java diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControl.java b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java index dfcb659e887..0e351769acd 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControl.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControl.java @@ -20,10 +20,10 @@ */ package com.inrupt.client.acp; -import static com.inrupt.client.vocabulary.RDF.type; +import static com.inrupt.client.acp.AccessControlResource.asIRI; -import com.inrupt.client.spi.RDFFactory; import com.inrupt.client.vocabulary.ACP; +import com.inrupt.client.vocabulary.RDF; import com.inrupt.rdf.wrapping.commons.ValueMappings; import com.inrupt.rdf.wrapping.commons.WrapperIRI; @@ -31,31 +31,39 @@ import org.apache.commons.rdf.api.Graph; import org.apache.commons.rdf.api.IRI; -import org.apache.commons.rdf.api.RDF; import org.apache.commons.rdf.api.RDFTerm; +/** + * An AccessControl type for use with Access Control Policies. + * + *

An access control applies {@link Policy} objects directly to a resource + * via {@code acp:accessControl} or to container members via {@code acp:memberAccessControl} + */ public class AccessControl extends WrapperIRI { - static final RDF rdf = RDFFactory.getInstance(); + /** + * Create a new AccessControl. + * + * @param identifier the access control identifier + * @param graph the underlying graph + */ + public AccessControl(final RDFTerm identifier, final Graph graph) { + super(identifier, graph); + graph.add((IRI) identifier, asIRI(RDF.type), asIRI(ACP.AccessControl)); + } - public AccessControl(final RDFTerm original, final Graph graph) { - super(original, graph); - graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.AccessControl.toString())); + public Set apply() { + return objects(asIRI(ACP.apply), Policy::asResource, ValueMappings.as(Policy.class)); } - public static IRI asResource(final AccessControl accessControl, final Graph graph) { - graph.add(accessControl, rdf.createIRI(type.toString()), rdf.createIRI(ACP.AccessControl.toString())); + static IRI asResource(final AccessControl accessControl, final Graph graph) { + graph.add(accessControl, asIRI(RDF.type), asIRI(ACP.AccessControl)); accessControl.apply().forEach(policy -> { - graph.add(accessControl, rdf.createIRI(ACP.apply.toString()), policy); + graph.add(accessControl, asIRI(ACP.apply), policy); Policy.asResource(policy, graph); }); return accessControl; } - - public Set apply() { - return objects(rdf.createIRI(ACP.apply.toString()), - Policy::asResource, ValueMappings.as(Policy.class)); - } } diff --git a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java index 2392f52a2fc..eacf803375e 100644 --- a/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java +++ b/acp/src/main/java/com/inrupt/client/acp/AccessControlResource.java @@ -29,6 +29,9 @@ import com.inrupt.rdf.wrapping.commons.WrapperIRI; import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.function.Consumer; @@ -45,6 +48,8 @@ /** * An Access Control Resource type. + * + *

This is the root type for a resource that expresses access control policies. */ public class AccessControlResource extends RDFSource { @@ -52,6 +57,48 @@ public class AccessControlResource extends RDFSource { public static final URI SOLID_ACCESS_GRANT = URI.create("http://www.w3.org/ns/solid/vc#SolidAccessGrant"); + private static final Map EXPANSION_MAPPINGS = Map.of( + ACP.accessControl, asIRI(ACP.AccessControl), + ACP.memberAccessControl, asIRI(ACP.AccessControl), + ACP.apply, asIRI(ACP.Policy), + ACP.allOf, asIRI(ACP.Matcher), + ACP.anyOf, asIRI(ACP.Matcher), + ACP.noneOf, asIRI(ACP.Matcher)); + + private static final List EXPANSION_PROPERTIES = List.of(ACP.accessControl, ACP.memberAccessControl, + ACP.apply, ACP.allOf, ACP.anyOf, ACP.noneOf); + + /** + * Definitions for different matcher types, for use with {@link #find}. + */ + public enum MatcherType { + AGENT(ACP.agent), CLIENT(ACP.client), ISSUER(ACP.issuer), VC(ACP.vc); + + private final URI predicate; + + MatcherType(final URI predicate) { + this.predicate = predicate; + } + + /** + * Return the matcher type as an IRI. + * + * @return the IRI for this predicate + */ + public IRI asIRI() { + return AccessControlResource.asIRI(asURI()); + } + + /** + * Return the matcher type as a URI. + * + * @return the URI for this predicate + */ + public URI asURI() { + return predicate; + } + } + /** * Create a new Access Control Resource. * @@ -96,15 +143,14 @@ public AccessControlResource expand(final SolidSyncClient client) { final var dataset = rdf.createDataset(); stream().forEach(dataset::add); - final var cache = rdf.createDataset(); - expandType(dataset, cache, asIRI(ACP.accessControl), asIRI(ACP.AccessControl), - uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.memberAccessControl), asIRI(ACP.AccessControl), - uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.apply), asIRI(ACP.Policy), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.allOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.anyOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.noneOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); + try (final var cache = rdf.createDataset()) { + for (final var property : EXPANSION_PROPERTIES) { + expandType(dataset, cache, asIRI(property), EXPANSION_MAPPINGS.get(property), + uri -> populateCache(client, uri, cache)); + } + } catch (final Exception ex) { + LOGGER.atDebug().setMessage("Unable to close dataset: {}").addArgument(ex::getMessage).log(); + } return new AccessControlResource(getIdentifier(), dataset); } @@ -120,67 +166,16 @@ public AccessControlResource expand(final SolidClient client) { final var dataset = rdf.createDataset(); stream().forEach(dataset::add); - final var cache = rdf.createDataset(); - expandType(dataset, cache, asIRI(ACP.accessControl), asIRI(ACP.AccessControl), - uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.memberAccessControl), asIRI(ACP.AccessControl), - uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.apply), asIRI(ACP.Policy), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.allOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.anyOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); - expandType(dataset, cache, asIRI(ACP.noneOf), asIRI(ACP.Matcher), uri -> populateCache(client, uri, cache)); - - return new AccessControlResource(getIdentifier(), dataset); - } - - void expandType(final Dataset dataset, final Dataset cache, final IRI predicate, final IRI type, - final Consumer handler) { - final var subjects = dataset.stream(null, null, predicate, null) - .map(Quad::getObject).filter(IRI.class::isInstance).map(IRI.class::cast) - .filter(subject -> !dataset.contains(null, subject, asIRI(RDF.type), type)).toList(); - - for (final var subject : subjects) { - if (!cache.contains(null, subject, asIRI(RDF.type), type)) { - handler.accept(URI.create(subject.getIRIString())); + try (final var cache = rdf.createDataset()) { + for (final var property : EXPANSION_PROPERTIES) { + expandType(dataset, cache, asIRI(property), EXPANSION_MAPPINGS.get(property), + uri -> populateCacheAsync(client, uri, cache)); } - cache.stream(null, subject, null, null).forEach(dataset::add); - } - } - - void populateCache(final SolidSyncClient client, final URI uri, final Dataset cache) { - try (final var acr = client.read(uri, AccessControlResource.class)) { - acr.stream() - .filter(quad -> !isAccessControlResourceType(quad)) - .forEach(cache::add); } catch (final Exception ex) { - LOGGER.atDebug() - .setMessage("Unable to fetch access control resource from {}: {}") - .addArgument(uri) - .addArgument(ex::getMessage) - .log(); + LOGGER.atDebug().setMessage("Unable to close dataset: {}").addArgument(ex::getMessage).log(); } - } - - void populateCache(final SolidClient client, final URI uri, final Dataset cache) { - client.read(uri, AccessControlResource.class).thenAccept(res -> { - try (final var acr = res) { - acr.stream() - .filter(quad -> !isAccessControlResourceType(quad)) - .forEach(cache::add); - } - }) - .exceptionally(err -> { - LOGGER.atDebug() - .setMessage("Unable to fetch access control resource from {}: {}") - .addArgument(uri) - .addArgument(err::getMessage) - .log(); - return null; - }).toCompletableFuture().join(); - } - static boolean isAccessControlResourceType(final Quad quad) { - return asIRI(RDF.type).equals(quad.getPredicate()) && asIRI(ACP.AccessControlResource).equals(quad.getObject()); + return new AccessControlResource(getIdentifier(), dataset); } /** @@ -204,21 +199,14 @@ public void compact() { } } - void removeUnusedStatements(final T resource) { - if (!contains(null, null, null, resource)) { - for (final var quad : stream(null, resource, null, null).toList()) { - remove(quad); - } - } - } - /** * Merge two or more policies into a single policies with combined matchers. * + * @param allow the modes to allow * @param policies the policies to merge * @return the merged policy */ - public Policy merge(final Policy... policies) { + public Policy merge(final Set allow, final Policy... policies) { final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); final var policy = new Policy(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); for (final var p : policies) { @@ -226,64 +214,33 @@ public Policy merge(final Policy... policies) { policy.anyOf().addAll(p.anyOf()); policy.noneOf().addAll(p.noneOf()); } + policy.allow().addAll(allow); return policy; } - public enum MatcherType { - AGENT(ACP.agent), CLIENT(ACP.client), ISSUER(ACP.issuer), VC(ACP.vc); - - private final URI predicate; - - MatcherType(final URI predicate) { - this.predicate = predicate; - } - - public IRI asIRI() { - return AccessControlResource.asIRI(asURI()); - } - - public URI asURI() { - return predicate; - } - } - /** * Find a policy, given a type, value and set of modes. * * @param type the matcher type - * @param value the matcher value - * @param modes the expected modes of the enclosing policy + * @param value the matcher value, may be {@code null} + * @param modes the expected modes of the enclosing policy, may be {@code null} * @return the matched policies */ public Set find(final MatcherType type, final URI value, final Set modes) { - return stream(null, null, type.asIRI(), asIRI(value)) + final IRI matcherValue = value != null ? asIRI(value) : null; + final Set matcherModes = modes != null ? modes : Collections.emptySet(); + return stream(null, null, type.asIRI(), matcherValue) .map(Quad::getSubject) .flatMap(matcher -> stream(null, null, null, matcher)) .map(Quad::getSubject) .filter(policy -> contains(null, policy, asIRI(RDF.type), asIRI(ACP.Policy))) .filter(policy -> stream(null, policy, asIRI(ACP.allow), null) .map(Quad::getObject).filter(IRI.class::isInstance).map(IRI.class::cast) - .map(IRI::getIRIString).map(URI::create).toList().containsAll(modes)) + .map(IRI::getIRIString).map(URI::create).toList().containsAll(matcherModes)) .map(policy -> new Policy(policy, getGraph())) .collect(Collectors.toSet()); } - static class ACPNode extends WrapperIRI { - public ACPNode(final RDFTerm original, final Graph graph) { - super(original, graph); - } - - public Set memberAccessControl() { - return objects(asIRI(ACP.memberAccessControl), - AccessControl::asResource, ValueMappings.as(AccessControl.class)); - } - - public Set accessControl() { - return objects(asIRI(ACP.accessControl), - AccessControl::asResource, ValueMappings.as(AccessControl.class)); - } - } - /** * Add policies to the access control resource. * @@ -382,6 +339,22 @@ public Policy issuerPolicy(final URI issuer, final URI... access) { return simplePolicy(matcher -> matcher.issuer().add(issuer), access); } + static class ACPNode extends WrapperIRI { + public ACPNode(final RDFTerm original, final Graph graph) { + super(original, graph); + } + + public Set memberAccessControl() { + return objects(asIRI(ACP.memberAccessControl), + AccessControl::asResource, ValueMappings.as(AccessControl.class)); + } + + public Set accessControl() { + return objects(asIRI(ACP.accessControl), + AccessControl::asResource, ValueMappings.as(AccessControl.class)); + } + } + Policy simplePolicy(final Consumer handler, final URI... access) { final var baseUri = getIdentifier().getScheme() + ":" + getIdentifier().getSchemeSpecificPart(); final var matcher = new Matcher(asIRI(baseUri + "#" + UUID.randomUUID()), getGraph()); @@ -395,11 +368,69 @@ Policy simplePolicy(final Consumer handler, final URI... access) { return policy; } - private static IRI asIRI(final URI uri) { + void removeUnusedStatements(final T resource) { + if (!contains(null, null, null, resource)) { + for (final var quad : stream(null, resource, null, null).toList()) { + remove(quad); + } + } + } + + void expandType(final Dataset dataset, final Dataset cache, final IRI predicate, final IRI type, + final Consumer handler) { + final var subjects = dataset.stream(null, null, predicate, null) + .map(Quad::getObject).filter(IRI.class::isInstance).map(IRI.class::cast) + .filter(subject -> !dataset.contains(null, subject, asIRI(RDF.type), type)).toList(); + + for (final var subject : subjects) { + if (!cache.contains(null, subject, asIRI(RDF.type), type)) { + handler.accept(URI.create(subject.getIRIString())); + } + cache.stream(null, subject, null, null).forEach(dataset::add); + } + } + + void populateCache(final SolidSyncClient client, final URI uri, final Dataset cache) { + try (final var acr = client.read(uri, AccessControlResource.class)) { + acr.stream() + .filter(quad -> !isAccessControlResourceType(quad)) + .forEach(cache::add); + } catch (final Exception ex) { + LOGGER.atDebug() + .setMessage("Unable to fetch access control resource from {}: {}") + .addArgument(uri) + .addArgument(ex::getMessage) + .log(); + } + } + + void populateCacheAsync(final SolidClient client, final URI uri, final Dataset cache) { + client.read(uri, AccessControlResource.class).thenAccept(res -> { + try (final var acr = res) { + acr.stream() + .filter(quad -> !isAccessControlResourceType(quad)) + .forEach(cache::add); + } + }) + .exceptionally(err -> { + LOGGER.atDebug() + .setMessage("Unable to fetch access control resource from {}: {}") + .addArgument(uri) + .addArgument(err::getMessage) + .log(); + return null; + }).toCompletableFuture().join(); + } + + static boolean isAccessControlResourceType(final Quad quad) { + return asIRI(RDF.type).equals(quad.getPredicate()) && asIRI(ACP.AccessControlResource).equals(quad.getObject()); + } + + static IRI asIRI(final URI uri) { return asIRI(uri.toString()); } - private static IRI asIRI(final String uri) { + static IRI asIRI(final String uri) { return rdf.createIRI(uri); } } diff --git a/acp/src/main/java/com/inrupt/client/acp/Matcher.java b/acp/src/main/java/com/inrupt/client/acp/Matcher.java index 95c5f73b89a..58de0310f67 100644 --- a/acp/src/main/java/com/inrupt/client/acp/Matcher.java +++ b/acp/src/main/java/com/inrupt/client/acp/Matcher.java @@ -20,10 +20,10 @@ */ package com.inrupt.client.acp; -import static com.inrupt.client.vocabulary.RDF.type; +import static com.inrupt.client.acp.AccessControlResource.asIRI; -import com.inrupt.client.spi.RDFFactory; import com.inrupt.client.vocabulary.ACP; +import com.inrupt.client.vocabulary.RDF; import com.inrupt.rdf.wrapping.commons.TermMappings; import com.inrupt.rdf.wrapping.commons.ValueMappings; import com.inrupt.rdf.wrapping.commons.WrapperIRI; @@ -33,49 +33,75 @@ import org.apache.commons.rdf.api.Graph; import org.apache.commons.rdf.api.IRI; -import org.apache.commons.rdf.api.RDF; import org.apache.commons.rdf.api.RDFTerm; +/** + * A Matcher type for use with Access Control Policies. + * + *

A matcher is associated with {@link Policy} objects, defining + * agents, clients, issuers or verifiable credential (vc) types. + */ public class Matcher extends WrapperIRI { - static final RDF rdf = RDFFactory.getInstance(); - - public Matcher(final RDFTerm original, final Graph graph) { - super(original, graph); - graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Matcher.toString())); + /** + * Create a new Matcher. + * + * @param identifier the matcher identifier + * @param graph the underlying graph + */ + public Matcher(final RDFTerm identifier, final Graph graph) { + super(identifier, graph); + graph.add((IRI) identifier, asIRI(RDF.type), asIRI(ACP.Matcher)); } - static RDFTerm asResource(final Matcher matcher, final Graph graph) { - graph.add(matcher, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Matcher.toString())); - matcher.vc().forEach(vc -> - graph.add(matcher, rdf.createIRI(ACP.vc.toString()), rdf.createIRI(vc.toString()))); - matcher.agent().forEach(agent -> - graph.add(matcher, rdf.createIRI(ACP.agent.toString()), rdf.createIRI(agent.toString()))); - matcher.client().forEach(client -> - graph.add(matcher, rdf.createIRI(ACP.client.toString()), rdf.createIRI(client.toString()))); - matcher.issuer().forEach(issuer -> - graph.add(matcher, rdf.createIRI(ACP.issuer.toString()), rdf.createIRI(issuer.toString()))); - return matcher; - } + /** + * Retrieve the acp:vc values. + * + * @return a collection of verifiable credential types + */ public Set vc() { - return objects(rdf.createIRI(ACP.vc.toString()), - TermMappings::asIri, ValueMappings::iriAsUri); + return objects(asIRI(ACP.vc), TermMappings::asIri, ValueMappings::iriAsUri); } + /** + * Retrieve the acp:agent values. + * + * @return a collection of agent identifiers + */ public Set agent() { - return objects(rdf.createIRI(ACP.agent.toString()), - TermMappings::asIri, ValueMappings::iriAsUri); + return objects(asIRI(ACP.agent), TermMappings::asIri, ValueMappings::iriAsUri); } + /** + * Retrieve the acp:client values. + * + * @return a collection of client identifiers + */ public Set client() { - return objects(rdf.createIRI(ACP.client.toString()), - TermMappings::asIri, ValueMappings::iriAsUri); + return objects(asIRI(ACP.client), TermMappings::asIri, ValueMappings::iriAsUri); } + /** + * Retrieve the acp:issuer values. + * + * @return a collection of issuer identifiers + */ public Set issuer() { - return objects(rdf.createIRI(ACP.issuer.toString()), - TermMappings::asIri, ValueMappings::iriAsUri); + return objects(asIRI(ACP.issuer), TermMappings::asIri, ValueMappings::iriAsUri); + } + + static RDFTerm asResource(final Matcher matcher, final Graph graph) { + graph.add(matcher, asIRI(RDF.type), asIRI(ACP.Matcher)); + matcher.vc().forEach(vc -> + graph.add(matcher, asIRI(ACP.vc), asIRI(vc))); + matcher.agent().forEach(agent -> + graph.add(matcher, asIRI(ACP.agent), asIRI(agent))); + matcher.client().forEach(client -> + graph.add(matcher, asIRI(ACP.client), asIRI(client))); + matcher.issuer().forEach(issuer -> + graph.add(matcher, asIRI(ACP.issuer), asIRI(issuer))); + return matcher; } } diff --git a/acp/src/main/java/com/inrupt/client/acp/Policy.java b/acp/src/main/java/com/inrupt/client/acp/Policy.java index a8aabda59ae..3f57945e29a 100644 --- a/acp/src/main/java/com/inrupt/client/acp/Policy.java +++ b/acp/src/main/java/com/inrupt/client/acp/Policy.java @@ -20,10 +20,10 @@ */ package com.inrupt.client.acp; -import static com.inrupt.client.vocabulary.RDF.type; +import static com.inrupt.client.acp.AccessControlResource.asIRI; -import com.inrupt.client.spi.RDFFactory; import com.inrupt.client.vocabulary.ACP; +import com.inrupt.client.vocabulary.RDF; import com.inrupt.rdf.wrapping.commons.TermMappings; import com.inrupt.rdf.wrapping.commons.ValueMappings; import com.inrupt.rdf.wrapping.commons.WrapperIRI; @@ -33,51 +33,84 @@ import org.apache.commons.rdf.api.Graph; import org.apache.commons.rdf.api.IRI; -import org.apache.commons.rdf.api.RDF; import org.apache.commons.rdf.api.RDFTerm; +/** + * A Policy type for use with Access Control Policies. + * + *

A policy will reference various {@link Matcher} objects + * and apply access rules such as {@code Read} or {@code Write}. + */ public class Policy extends WrapperIRI { - static final RDF rdf = RDFFactory.getInstance(); - - public Policy(final RDFTerm original, final Graph graph) { - super(original, graph); - graph.add((IRI) original, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Policy.toString())); - } - - static IRI asResource(final Policy policy, final Graph graph) { - graph.add(policy, rdf.createIRI(type.toString()), rdf.createIRI(ACP.Policy.toString())); - policy.allOf().forEach(matcher -> { - graph.add(policy, rdf.createIRI(ACP.allOf.toString()), matcher); - Matcher.asResource(matcher, graph); - }); - policy.anyOf().forEach(matcher -> { - graph.add(policy, rdf.createIRI(ACP.anyOf.toString()), matcher); - Matcher.asResource(matcher, graph); - }); - policy.allow().forEach(allow -> - graph.add(policy, rdf.createIRI(ACP.allow.toString()), rdf.createIRI(allow.toString()))); - return policy; + /** + * Create a new Policy. + * + * @param identifier the policy identifier + * @param graph the underlying graph for this resource + */ + public Policy(final RDFTerm identifier, final Graph graph) { + super(identifier, graph); + graph.add((IRI) identifier, asIRI(RDF.type), asIRI(ACP.Policy)); } + /** + * Retrieve the acp:allOf structures. + * + * @return a collection of {@link Matcher} objects + */ public Set allOf() { - return objects(rdf.createIRI(ACP.allOf.toString()), + return objects(asIRI(ACP.allOf), Matcher::asResource, ValueMappings.as(Matcher.class)); } + /** + * Retrieve the acp:anyOf structures. + * + * @return a collection of {@link Matcher} objects + */ public Set anyOf() { - return objects(rdf.createIRI(ACP.anyOf.toString()), + return objects(asIRI(ACP.anyOf), Matcher::asResource, ValueMappings.as(Matcher.class)); } + /** + * Retrieve the acp:noneOf structures. + * + * @return a collection of {@link Matcher} objects + */ public Set noneOf() { - return objects(rdf.createIRI(ACP.noneOf.toString()), + return objects(asIRI(ACP.noneOf), Matcher::asResource, ValueMappings.as(Matcher.class)); } + /** + * Retrieve the acp:allow values. + * + * @return a collection of access values, such as {@code ACL.Read} + */ public Set allow() { - return objects(rdf.createIRI(ACP.allow.toString()), + return objects(asIRI(ACP.allow), TermMappings::asIri, ValueMappings::iriAsUri); } + + static IRI asResource(final Policy policy, final Graph graph) { + graph.add(policy, asIRI(RDF.type), asIRI(ACP.Policy)); + policy.allOf().forEach(matcher -> { + graph.add(policy, asIRI(ACP.allOf), matcher); + Matcher.asResource(matcher, graph); + }); + policy.anyOf().forEach(matcher -> { + graph.add(policy, asIRI(ACP.anyOf), matcher); + Matcher.asResource(matcher, graph); + }); + policy.noneOf().forEach(matcher -> { + graph.add(policy, asIRI(ACP.noneOf), matcher); + Matcher.asResource(matcher, graph); + }); + policy.allow().forEach(allow -> + graph.add(policy, asIRI(ACP.allow), asIRI(allow))); + return policy; + } } diff --git a/acp/src/main/java/com/inrupt/client/acp/package-info.java b/acp/src/main/java/com/inrupt/client/acp/package-info.java new file mode 100644 index 00000000000..5b96b0cf47d --- /dev/null +++ b/acp/src/main/java/com/inrupt/client/acp/package-info.java @@ -0,0 +1,51 @@ +/* + * Copyright Inrupt Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + * INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +/** + *

Access Control Policy support for the Inrupt Java Client Libraries.

+ * + *

This module contains classes and methods to convert ACP resources into + * a @{@link AccessControlResource} Java object. + * + *

The following example reads a Solid Access Control Resource and presents it as an {@link AccessControlResource} + * Java object: + * + *

{@code
+ *      try (AccessControlResource acr = client.read(uri, AccessControlResource.class)) {
+ *          // find policies that grant {@code agent} read access
+ *          Set policies = acr.expand(client).find(MatcherType.AGENT, agent, Set.of(ACL.Read));
+ *
+ *          // remove these policies from the ACR
+ *          Set accessControls = new HashSet<>();
+ *          accessControls.addAll(acr.accessControl());
+ *          accessControls.addAll(acr.memberAccessControl());
+ *          for (Policy p : policies) {
+ *              for (AccessControl accessControl = accessControls) {
+ *                  for (Policy policy : accessControl.apply()) {
+ *                      policy.remove(p);
+ *                  }
+ *              }
+ *          }
+ *          acr.compact();
+ *          client.update(acr);
+ *      }}
+ *  
+ */ +package com.inrupt.client.acp; diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java index e09bcb6c4dd..2b6dac61b84 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -179,6 +179,11 @@ void buildAcr() { acr.accessControl().add(accessControl); assertEquals(10, acr.size()); + + policy.anyOf().addAll(acr.agentPolicy(URI.create("https://id.example/user1")).allOf()); + policy.noneOf().addAll(acr.agentPolicy(URI.create("https://id.example/user2")).allOf()); + + assertEquals(18, acr.size()); } @Test @@ -186,16 +191,22 @@ void testAcrFindPolicies() { final var uri = mockHttpServer.acr2(); try (final AccessControlResource acr = client.read(uri, AccessControlResource.class)) { assertEquals(1, acr.find(AccessControlResource.MatcherType.VC, - AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read)).size()); + AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read)).size()); assertEquals(0, acr.find(AccessControlResource.MatcherType.VC, - AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read, ACL.Write)).size()); + AccessControlResource.SOLID_ACCESS_GRANT, Set.of(ACL.Read, ACL.Write)).size()); assertEquals(0, acr.find(AccessControlResource.MatcherType.AGENT, - URI.create("https://bot.example/id"), Set.of(ACL.Read, ACL.Write)).size()); + URI.create("https://bot.example/id"), Set.of(ACL.Read, ACL.Write)).size()); assertEquals(1, acr.find(AccessControlResource.MatcherType.AGENT, - URI.create("https://bot.example/id"), Set.of(ACL.Read)).size()); + URI.create("https://bot.example/id"), Set.of(ACL.Read)).size()); + + assertEquals(2, acr.find(AccessControlResource.MatcherType.AGENT, + null, Set.of(ACL.Read)).size()); + + assertEquals(1, acr.find(AccessControlResource.MatcherType.AGENT, + URI.create("https://bot.example/id"), null).size()); } } @@ -335,4 +346,44 @@ void expandAcr4Sync() { assertEquals(20, acr.size()); } } + + @Test + void expandAcr4Async() { + final var uri = mockHttpServer.acr4(); + final var asyncClient = SolidClient.getClient(); + asyncClient.read(uri, AccessControlResource.class).thenAccept(res -> { + try (final var acr = res) { + assertEquals(2, acr.accessControl().size()); + assertEquals(3, acr.memberAccessControl().size()); + + // Check dataset size + assertEquals(20, acr.size()); + + final var expanded = acr.expand(asyncClient); + assertEquals(22, expanded.size()); + assertEquals(20, acr.size()); + } + }).toCompletableFuture().join(); + } + + @Test + void testMerge() { + final var identifier = "https://data.example/resource"; + final var agent = URI.create("https://id.example/agent"); + final var app = URI.create("https://app.example/id"); + + final var acr = new AccessControlResource(URI.create(identifier), rdf.createDataset()); + final var policy = acr.merge(Set.of(ACL.Read, ACL.Write), acr.agentPolicy(agent), acr.clientPolicy(app)); + + assertEquals(2, policy.allow().size()); + assertTrue(policy.allow().contains(ACL.Read)); + assertTrue(policy.allow().contains(ACL.Write)); + + assertEquals(2, policy.allOf().size()); + assertEquals(0, policy.anyOf().size()); + assertEquals(0, policy.noneOf().size()); + + assertTrue(policy.allOf().stream().anyMatch(matcher -> matcher.client().contains(app))); + assertTrue(policy.allOf().stream().anyMatch(matcher -> matcher.agent().contains(agent))); + } } diff --git a/acp/src/test/resources/__files/acr-4.ttl b/acp/src/test/resources/__files/acr-4.ttl index ff95f776d39..eb277f5d394 100644 --- a/acp/src/test/resources/__files/acr-4.ttl +++ b/acp/src/test/resources/__files/acr-4.ttl @@ -16,7 +16,7 @@ acp:apply <#indexer-policy> . <#indexer-policy> a acp:Policy ; - acp:allOf ; + acp:noneOf ; acp:allow acl:Read . <#vc-access-control> @@ -24,7 +24,7 @@ acp:apply <#vc-policy> . <#vc-policy> a acp:Policy ; - acp:allOf <#vc-matcher> ; + acp:anyOf <#vc-matcher> ; acp:allow acl:Read . <#vc-matcher> a acp:Matcher ; diff --git a/pom.xml b/pom.xml index b5c1589672a..3bd191f193b 100644 --- a/pom.xml +++ b/pom.xml @@ -206,7 +206,6 @@ https://jena.apache.org/documentation/javadoc/arq/ https://www.antlr.org/api/Java/ https://rdf4j.org/javadoc/5.1.0/ - http://www.javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/
Date: Thu, 2 Oct 2025 13:41:39 -0500 Subject: [PATCH 7/7] Adjust tests --- .../client/acp/AccessControlResourceTest.java | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java index 2b6dac61b84..bf7afcd0ec4 100644 --- a/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java +++ b/acp/src/test/java/com/inrupt/client/acp/AccessControlResourceTest.java @@ -164,11 +164,19 @@ void buildAcr() { final var dataset = rdf.createDataset(); final var uri = URI.create(identifier); - final var matcher = new Matcher(rdf.createIRI(identifier + "#matcher"), rdf.createGraph()); - matcher.agent().add(URI.create("https://id.example/agent")); + final var matcher1 = new Matcher(rdf.createIRI(identifier + "#matcher1"), rdf.createGraph()); + matcher1.agent().add(URI.create("https://id.example/agent")); + + final var matcher2 = new Matcher(rdf.createIRI(identifier + "#matcher2"), rdf.createGraph()); + matcher2.client().add(URI.create("https://app.example/id")); + + final var matcher3 = new Matcher(rdf.createIRI(identifier + "#matcher3"), rdf.createGraph()); + matcher3.issuer().add(URI.create("https://openid.example")); final var policy = new Policy(rdf.createIRI(identifier + "#policy"), rdf.createGraph()); - policy.allOf().add(matcher); + policy.allOf().add(matcher1); + policy.anyOf().add(matcher2); + policy.noneOf().add(matcher3); policy.allow().add(ACL.Read); policy.allow().add(ACL.Write); @@ -178,12 +186,7 @@ void buildAcr() { final var acr = new AccessControlResource(uri, dataset); acr.accessControl().add(accessControl); - assertEquals(10, acr.size()); - - policy.anyOf().addAll(acr.agentPolicy(URI.create("https://id.example/user1")).allOf()); - policy.noneOf().addAll(acr.agentPolicy(URI.create("https://id.example/user2")).allOf()); - - assertEquals(18, acr.size()); + assertEquals(16, acr.size()); } @Test