From 17d5ed43921aea1fb878cd642750c0ccc15d0df2 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 4 Aug 2025 16:04:32 +0100 Subject: [PATCH 1/8] Start working on a Fuseki module --- acs-fuseki/.gitignore | 2 + acs-fuseki/pom.xml | 153 ++++++++++++++++++ .../uk/co/amrc/factoryplus/fuseki/App.java | 13 ++ .../co/amrc/factoryplus/fuseki/RunFuseki.java | 46 ++++++ .../co/amrc/factoryplus/fuseki/AppTest.java | 38 +++++ 5 files changed, 252 insertions(+) create mode 100644 acs-fuseki/.gitignore create mode 100644 acs-fuseki/pom.xml create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/App.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java create mode 100644 acs-fuseki/src/test/java/uk/co/amrc/factoryplus/fuseki/AppTest.java diff --git a/acs-fuseki/.gitignore b/acs-fuseki/.gitignore new file mode 100644 index 000000000..ca37d98db --- /dev/null +++ b/acs-fuseki/.gitignore @@ -0,0 +1,2 @@ +dependency-reduced-pom.xml +target/ diff --git a/acs-fuseki/pom.xml b/acs-fuseki/pom.xml new file mode 100644 index 000000000..a66576dea --- /dev/null +++ b/acs-fuseki/pom.xml @@ -0,0 +1,153 @@ + +4.0.0 + + uk.co.amrc.factoryplus.fuseki + acs-fuseki + 0.0.1 + + jar + acs-fuseki + + https://github.com/amrc-factoryplus + + + + ${maven.build.timestamp} + uk.co.amrc.factoryplus.fuseki + + UTF-8 + 17 + + + 3.4.1 + 3.14.0 + 2.9.1 + 3.8.1 + 3.1.4 + 3.5.0 + 3.2.7 + 3.1.4 + 0.8.12 + 3.4.2 + 3.11.2 + 1.7.0 + 3.3.1 + 3.6.0 + 3.21.0 + 3.3.1 + 3.5.2 + + + 5.3.0 + 3.27.3 + 2.24.3 + 2.0.17 + + + + + org.apache.jena + apache-jena-libs + ${dependency.jena} + pom + + + + org.apache.jena + jena-fuseki-main + ${dependency.jena} + + + + org.apache.jena + jena-cmds + ${dependency.jena} + + + + org.apache.logging.log4j + log4j-core + ${dependency.log4j2} + + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${dependency.log4j2} + + + + org.slf4j + slf4j-api + ${dependency.slf4j} + + + + junit + junit + 3.8.1 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${plugin.compiler} + + ${java.version} + + + + + org.apache.maven.plugins + maven-shade-plugin + ${plugin.shade} + + false + + + uk.co.amrc.factoryplus.fuseki.RunFuseki + + true + + + + + + false + + + + + + *:* + + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + META-INF/DEPENDENCIES + META-INF/MANIFEST.MF + + **/module-info.class + + + + + + + package + + + shade + + + + + + + diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/App.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/App.java new file mode 100644 index 000000000..ccec0594a --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/App.java @@ -0,0 +1,13 @@ +package uk.co.amrc.factoryplus.fuseki; + +/** + * Hello world! + * + */ +public class App +{ + public static void main( String[] args ) + { + System.out.println( "Hello World!" ); + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java new file mode 100644 index 000000000..e64905e1c --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java @@ -0,0 +1,46 @@ +/* + * ACS Fuseki server + * Main entry point + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import jakarta.servlet.Filter; +import org.apache.jena.fuseki.Fuseki; +import org.apache.jena.fuseki.main.FusekiServer; +import org.apache.jena.fuseki.main.cmds.FusekiMain; +import org.apache.jena.fuseki.main.sys.FusekiModules; +import org.apache.jena.fuseki.server.Operation; +import org.apache.jena.sparql.util.Symbol; +import org.apache.jena.sys.JenaSystem; + +public class RunFuseki { + + static { + JenaSystem.init(); + } + + public static void main(String ...args) { + FusekiServer server = build(args).build(); + + try { + server.start(); + server.join(); + } + catch (RuntimeException ex) { + ex.printStackTrace(); + } + finally { server.stop(); } + } + + public static FusekiServer.Builder build(String ...args) { + + FusekiModules modules = FusekiModules.create(); + + FusekiServer.Builder builder = + FusekiMain.builder(args) + .fusekiModules(modules); + return builder; + } +} diff --git a/acs-fuseki/src/test/java/uk/co/amrc/factoryplus/fuseki/AppTest.java b/acs-fuseki/src/test/java/uk/co/amrc/factoryplus/fuseki/AppTest.java new file mode 100644 index 000000000..5e76766f1 --- /dev/null +++ b/acs-fuseki/src/test/java/uk/co/amrc/factoryplus/fuseki/AppTest.java @@ -0,0 +1,38 @@ +package uk.co.amrc.factoryplus.fuseki; + +import junit.framework.Test; +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Unit test for simple App. + */ +public class AppTest + extends TestCase +{ + /** + * Create the test case + * + * @param testName name of the test case + */ + public AppTest( String testName ) + { + super( testName ); + } + + /** + * @return the suite of tests being tested + */ + public static Test suite() + { + return new TestSuite( AppTest.class ); + } + + /** + * Rigourous Test :-) + */ + public void testApp() + { + assertTrue( true ); + } +} From 40a8ac97c7237aa8ac07c9696f7e9e5545010c3b Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 6 Aug 2025 09:44:02 +0100 Subject: [PATCH 2/8] Create a per-principal view on the dataset For now this just extracts the Basic auth header directly and ignores the password. This wants integrating with Jetty or something eventually but it doesn't matter for now. --- acs-fuseki/.gitignore | 1 + acs-fuseki/config.ttl | 79 +++++++++++++++++++ .../fuseki/FPAclDatasetAssembler.java | 43 ++++++++++ .../amrc/factoryplus/fuseki/FPAclModule.java | 43 ++++++++++ .../factoryplus/fuseki/FPDatasetGraph.java | 48 +++++++++++ .../factoryplus/fuseki/FPQuerySparql.java | 65 +++++++++++++++ .../co/amrc/factoryplus/fuseki/FPVocab.java | 15 ++++ .../co/amrc/factoryplus/fuseki/RunFuseki.java | 11 ++- 8 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 acs-fuseki/config.ttl create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java diff --git a/acs-fuseki/.gitignore b/acs-fuseki/.gitignore index ca37d98db..9fafc1d3e 100644 --- a/acs-fuseki/.gitignore +++ b/acs-fuseki/.gitignore @@ -1,2 +1,3 @@ +db/ dependency-reduced-pom.xml target/ diff --git a/acs-fuseki/config.ttl b/acs-fuseki/config.ttl new file mode 100644 index 000000000..bf83bff00 --- /dev/null +++ b/acs-fuseki/config.ttl @@ -0,0 +1,79 @@ +# Licensed under the terms of http://www.apache.org/licenses/LICENSE-2.0 + +## Fuseki Server configuration file. + +@prefix : <#> . +@prefix f: . +@prefix rdf: . +@prefix rdfs: . +@prefix ja: . +@prefix rr: . +@prefix tdb: . +@prefix acl: + +[] rdf:type f:Server ; + # Example:: + # Server-wide query timeout. + # + # Timeout - server-wide default: milliseconds. + # Format 1: "1000" -- 1 second timeout + # Format 2: "10000,60000" -- 10s timeout to first result, + # then 60s timeout for the rest of query. + # + # See javadoc for ARQ.queryTimeout for details. + # This can also be set on a per dataset basis in the dataset assembler. + # + # ja:context [ ja:cxtName "arq:queryTimeout" ; ja:cxtValue "30000" ] ; + + # Add any custom classes you want to load. + # Must have a "public static void init()" method. + # ja:loadClass "your.code.Class" ; + + # End triples. + . + +acl:DatasetFPAcl ja:assembler "uk.co.amrc.factoryplus.fuseki.FPAclDatasetAssembler". + +:svc rdf:type f:Service; + f:name "ds"; + f:endpoint [ + f:operation f:query; + f:name "sparql" + ]; + f:endpoint [ + f:operation f:update; + f:name "update" + ]; + + f:dataset :ds; + . + +:ds a acl:DatasetFPAcl; + ja:dataset :tdb; + . + +:tdb rdf:type tdb:DatasetTDB; + tdb:location "db"; + . + +#:ds rdf:type ja:RDFDataset; +# ja:defaultGraph :graph; +# . + +#:inference rdf:type ja:InfModel; +# ja:baseModel :graph; +# ja:reasoner [ja:reasonerClass "openllet.jena.PelletReasonerFactory"]; +# . + +#:inference rdf:type ja:InfModel; +# ja:baseModel :graph; +# ja:reasoner [ +# ja:rulesFrom ; +# rr:enableTGCCaching "true"; +# ]; +# . + +#:graph rdf:type tdb:GraphTDB; +# tdb:dataset :tdb; +# . + diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java new file mode 100644 index 000000000..159eeb7cd --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java @@ -0,0 +1,43 @@ +/* + * ACS Fuseki + * ACL-enabled Dataset Assembler + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.assembler.Assembler; +import org.apache.jena.assembler.Mode; +import org.apache.jena.query.Dataset; +import org.apache.jena.query.DatasetFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.sparql.core.DatasetGraph; +import org.apache.jena.sparql.core.assembler.DatasetAssembler; +import org.apache.jena.sparql.core.assembler.DatasetAssemblerVocab; + +public class FPAclDatasetAssembler extends DatasetAssembler implements Assembler { + final Logger log = LoggerFactory.getLogger(FPAclDatasetAssembler.class); + + public FPAclDatasetAssembler () { + log.info("Constructed assembler"); + } + + @Override + public Dataset open(Assembler a, Resource root, Mode mode) { + log.info("open: {} {} {}", a, root, mode); + var ds = createDataset(a, root); + return DatasetFactory.wrap(ds); + } + + @Override + public DatasetGraph createDataset(Assembler a, Resource root) { + var base = createBaseDataset(root, DatasetAssemblerVocab.pDataset); + log.info("Created base dataset: {}", base); + var ds = new FPDatasetGraph(base); + log.info("Wrapped dataset: {}", ds); + return ds; + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java new file mode 100644 index 000000000..5314b3755 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java @@ -0,0 +1,43 @@ +/* + * ACS Fuseki + * Fuseki module to set up our datasets + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.fuseki.main.sys.FusekiModule; +import org.apache.jena.fuseki.server.DataAccessPoint; +import org.apache.jena.fuseki.server.Operation; +import org.apache.jena.rdf.model.Model; + +class FPAclModule implements FusekiModule { + static final Logger log = LoggerFactory.getLogger(FPAclModule.class); + + public FPAclModule () { + log.info("Construct FPAclModule"); + } + + @Override + public String name () { return "ACS Factory+ ACLs"; } + + @Override + public void configDataAccessPoint (DataAccessPoint dap, Model m) { + log.info("Found DAP {}", dap.getName()); + + var srv = dap.getDataService(); + var ds = srv.getDataset(); + log.info("Found DS class {}", ds.getClass()); + if (!(ds instanceof FPDatasetGraph)) { + log.info("Not an FPDatasetGraph, skipping"); + return; + } + + var sparql = new FPQuerySparql(); + srv.getEndpoints(Operation.Query).forEach(e -> e.setProcessor(sparql)); + log.info("Set up SPARQL operations"); + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java new file mode 100644 index 000000000..6d06a2b30 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java @@ -0,0 +1,48 @@ +/* + * ACS Fuseki + * DatasetGraph to mark this as needing ACLs + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import java.util.Iterator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.graph.Node; +import org.apache.jena.sparql.core.DatasetGraph; +import org.apache.jena.sparql.core.DatasetGraphWrapper; +import org.apache.jena.sparql.core.DatasetGraphWrapperView; +import org.apache.jena.sparql.core.Quad; + +class FPDatasetGraph extends DatasetGraphWrapper { + class AclDSG extends DatasetGraphWrapper + implements DatasetGraphWrapperView + { + final Logger log = LoggerFactory.getLogger(AclDSG.class); + + final String principal; + + public AclDSG (DatasetGraph base, String principal) { + super(base); + this.principal = principal; + log.info("Construct for {} base {}", principal, base); + } + + @Override + public Iterator find (Node g, Node s, Node p, Node o) { + log.info("FIND: {} {} {} ({}) for {}", s, p, o, g, principal); + return super.find(g, s, p, o); + } + } + + public FPDatasetGraph (DatasetGraph base) { + super(base); + } + + public DatasetGraph withAclFor (String principal) { + return new AclDSG(getBaseForQuery(), principal); + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java new file mode 100644 index 000000000..a4e59e567 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java @@ -0,0 +1,65 @@ +/* + * ACS Fuseki + * SPARQL query ACL wrapper + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import java.util.Base64; +import java.nio.charset.StandardCharsets; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.atlas.lib.Pair; +import org.apache.jena.fuseki.servlets.ActionService; +import org.apache.jena.fuseki.servlets.HttpAction; +import org.apache.jena.fuseki.servlets.SPARQL_QueryDataset; +import org.apache.jena.query.Query; +import org.apache.jena.query.QueryExecution; +import org.apache.jena.sparql.core.DatasetGraph; + +public class FPQuerySparql extends SPARQL_QueryDataset { + final Logger log = LoggerFactory.getLogger(FPQuerySparql.class); + + @Override + protected Pair decideDataset + (HttpAction action, Query query, String queryStringLog) + { + var princ = findPrincipal(action); + log.info("decideDataset for {}", princ); + + var ds = (FPDatasetGraph)getDataset(action); + var ads = ds.withAclFor(princ); + + return Pair.create(ads, query); + } + + private String findPrincipal (HttpAction action) { + var auth = action.getRequest().getHeader("Authorization"); + log.info("Auth: {}", auth); + + var parts = auth.split("\\s"); + if (parts.length != 2) { + log.error("Got {} parts to auth", parts.length); + return null; + } + if (!parts[0].equalsIgnoreCase("basic")) { + log.error("Got {} HTTP auth", parts[0]); + return null; + } + + var bcreds = Base64.getDecoder().decode(parts[1]); + var creds = new String(bcreds, StandardCharsets.UTF_8); + log.info("Creds: {}", creds); + var up = creds.split(":"); + + if (up.length != 2) { + log.error("Got {} creds", up.length); + return null; + } + /* XXX We are not doing this properly yet. Accept any password. */ + return up[0]; + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java new file mode 100644 index 000000000..926701958 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java @@ -0,0 +1,15 @@ +/* + * ACS Fuseki + * RDF vocabulary + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import org.apache.jena.rdf.model.Property; + +public class FPVocab { + public static final String NS = "http://factoryplus.app.amrc.co.uk/rdf/2025-05/ac-24-611/acl#"; + public static String getURI() { return NS; } + +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java index e64905e1c..ce09a7cc1 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java @@ -6,7 +6,9 @@ package uk.co.amrc.factoryplus.fuseki; -import jakarta.servlet.Filter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.apache.jena.fuseki.Fuseki; import org.apache.jena.fuseki.main.FusekiServer; import org.apache.jena.fuseki.main.cmds.FusekiMain; @@ -16,16 +18,20 @@ import org.apache.jena.sys.JenaSystem; public class RunFuseki { + static final Logger log = LoggerFactory.getLogger(RunFuseki.class); static { JenaSystem.init(); } public static void main(String ...args) { + log.info("Building Fuseki server"); + FusekiServer server = build(args).build(); try { server.start(); + log.info("Started server"); server.join(); } catch (RuntimeException ex) { @@ -36,7 +42,8 @@ public static void main(String ...args) { public static FusekiServer.Builder build(String ...args) { - FusekiModules modules = FusekiModules.create(); + FusekiModules modules = FusekiModules.create( + new FPAclModule()); FusekiServer.Builder builder = FusekiMain.builder(args) From acf7d3d0d89ba9e5fe1ae5ebe9532650a82ff976 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Thu, 7 Aug 2025 11:39:31 +0100 Subject: [PATCH 3/8] Build Shapes for ACL entries This is very crude at present. The basic structure is that an ACL grant is a triple with predicate `acl:readTriple`; the object of this is an acl:ShexCondition identifying triples the subject may access. (Annoyingly ShEx TripleConstraints are effectively constraints on a node rather than on a triple as such). These `readTriple` triples must be present in the default graph of the dataset; for now I am ignoring other graphs, but if we use them it will most likely be for hypothetical or otherwise 'quoted' triples so we don't want them to be active permission grants. A ShexCondition has three properties, `acl:subject`, `acl:predicate` and `acl:object`, all ShEx expresssions. For now these are limited to: - value NodeConstraints - forwards TripleConstraints with cardinality 1 Since the Jena ShEx library won't (yet) compile ShEx expressions from RDF, only from JSON or ShExC, we need to traverse the graph and build the expressions by hand. Our DatasetGraph builds the ACL for a principal, for now from scratch on every request. This means collating all the Shapes, combining them into a Schema, and recording a set of triples representing the ShexCondition objects. It should be possible to use these to evaluate the ACLs. --- acs-fuseki/.gitignore | 1 + acs-fuseki/config.ttl | 2 +- .../factoryplus/fuseki/FPDatasetGraph.java | 55 ++++++- .../factoryplus/fuseki/FPShapeBuilder.java | 146 ++++++++++++++++++ .../co/amrc/factoryplus/fuseki/FPVocab.java | 15 -- .../uk/co/amrc/factoryplus/fuseki/ShEx.java | 29 ++++ .../uk/co/amrc/factoryplus/fuseki/Vocab.java | 30 ++++ 7 files changed, 261 insertions(+), 17 deletions(-) create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java delete mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/Vocab.java diff --git a/acs-fuseki/.gitignore b/acs-fuseki/.gitignore index 9fafc1d3e..be99fbee8 100644 --- a/acs-fuseki/.gitignore +++ b/acs-fuseki/.gitignore @@ -1,3 +1,4 @@ db/ dependency-reduced-pom.xml target/ +tmp/ diff --git a/acs-fuseki/config.ttl b/acs-fuseki/config.ttl index bf83bff00..de9a5b5b5 100644 --- a/acs-fuseki/config.ttl +++ b/acs-fuseki/config.ttl @@ -42,7 +42,7 @@ acl:DatasetFPAcl ja:assembler "uk.co.amrc.factoryplus.fuseki.FPAclDatasetAssembl ]; f:endpoint [ f:operation f:update; - f:name "update" + f:name "sparql" ]; f:dataset :ds; diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java index 6d06a2b30..ead0ca3dd 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java @@ -11,31 +11,84 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.jena.graph.Graph; import org.apache.jena.graph.Node; +import org.apache.jena.riot.system.PrefixMap; import org.apache.jena.sparql.core.DatasetGraph; import org.apache.jena.sparql.core.DatasetGraphWrapper; import org.apache.jena.sparql.core.DatasetGraphWrapperView; +import org.apache.jena.sparql.core.DatasetGraphQuads; +import org.apache.jena.sparql.core.GraphView; import org.apache.jena.sparql.core.Quad; class FPDatasetGraph extends DatasetGraphWrapper { class AclDSG extends DatasetGraphWrapper - implements DatasetGraphWrapperView + implements DatasetGraphWrapperView { final Logger log = LoggerFactory.getLogger(AclDSG.class); final String principal; + final FPShapeBuilder shapes; public AclDSG (DatasetGraph base, String principal) { super(base); this.principal = principal; + /* Grants must always be in the default graph. Grants in + * other graphs are hypothetical and not active. */ + this.shapes = new FPShapeBuilder(base.getDefaultGraph()); log.info("Construct for {} base {}", principal, base); } + @Override + protected DatasetGraph get () { + log.info("get"); + + if (!shapes.hasSchema()) { + shapes.withPrincipal(principal) + .buildSchema(); + log.info("Built schema for {}", principal); + log.info("Shapes:"); + shapes.getSchema().getShapes() + .forEach(s -> log.info(" {}", s)); + log.info("Triples (may read):"); + shapes.getMayRead() + .forEach(t -> log.info(" {}", t)); + } + + return super.get(); + } + + @Override + public Graph getDefaultGraph () { + log.info("getDefaultGraph"); + return GraphView.createDefaultGraph(this); + } + + @Override + public Graph getUnionGraph () { + log.info("getUnionGraph"); + return GraphView.createUnionGraph(this); + } + + @Override + public Graph getGraph (Node g) { + log.info("getGraph {}", g); + return GraphView.createNamedGraph(this, g); + } + + @Override public boolean containsGraph (Node g) { return true; } + @Override public Iterator find (Node g, Node s, Node p, Node o) { log.info("FIND: {} {} {} ({}) for {}", s, p, o, g, principal); return super.find(g, s, p, o); } + + @Override + public Iterator findNG (Node g, Node s, Node p, Node o) { + log.info("FIND NG: {} {} {} ({}) for {}", s, p, o, g, principal); + return super.findNG(g, s, p, o); + } } public FPDatasetGraph (DatasetGraph base) { diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java new file mode 100644 index 000000000..ace575a66 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java @@ -0,0 +1,146 @@ +/* + * ACS Fuseki + * Build a ShEx expression from RDF + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.apache.jena.graph.*; +import org.apache.jena.rdf.model.*; +import org.apache.jena.riot.system.PrefixMapFactory; +import org.apache.jena.shex.*; +import org.apache.jena.shex.expressions.*; +import org.apache.jena.vocabulary.RDF; + +class FPShapeBuilder { + private Model model; + private List shapes; + private List mayRead; + private Optional schema; + + class InvalidShape extends RuntimeException { + public InvalidShape (Resource r) { + super("Invalid shape: " + r.toString()); + } + } + class InvalidUser extends RuntimeException { + public InvalidUser (String u, String reason) { + super("Invalid user: " + u + ": " + reason); + } + } + + public FPShapeBuilder (Graph g) { + this.model = ModelFactory.createModelForGraph(g); + this.shapes = new ArrayList(); + this.mayRead = new ArrayList(); + this.schema = Optional.empty(); + } + + public boolean hasSchema () { + return schema.isPresent(); + } + + public List getMayRead () { + schema.orElseThrow(); + return mayRead; + } + + public ShexSchema getSchema () { + return schema.orElseThrow(); + } + + public FPShapeBuilder buildSchema () { + var prefixes = new PrefixMapFactory().create(); + prefixes.add("rdf", RDF.getURI()); + prefixes.add("shex", ShEx.NS); + prefixes.add("acl", Vocab.NS); + + schema = Optional.of(ShexSchema.shapes( + /*source*/"", /*baseURI*/"", + prefixes, /*startShape*/null, + shapes, + /*imports*/List.of(), + /*semActs*/List.of(), + /*tripleRefs*/Map.of())); + return this; + } + + public FPShapeBuilder withPrincipal (String user) { + var princ = getIriForUser(user); + model.listObjectsOfProperty(princ, Vocab.readTriple) + .filterKeep(c -> c.isResource()) + .mapWith(c -> c.asResource()) + .forEach(c -> addCondition(mayRead, c)); + return this; + } + + public Resource getIriForUser (String user) { + var princs = model.listResourcesWithProperty(Vocab.username, user) + .toList(); + if (princs.size() > 1) + throw new InvalidUser(user, "more than one IRI"); + if (princs.size() < 1) + throw new InvalidUser(user, "no IRI"); + return princs.get(0); + } + + public void addCondition (List acl, Resource r) { + var tr = Triple.create( + addShapeFor(r, Vocab.subject), + addShapeFor(r, Vocab.predicate), + addShapeFor(r, Vocab.object)); + acl.add(tr); + } + + public Node addShapeFor (Resource r, Property p) { + var obj = model.getProperty(r, p); + if (obj == null) + return Node.ANY; + + var expr = obj.getObject().asResource(); + var label = expr.asNode(); + var shape = new ShexShape(label, buildExpr(expr)); + + shapes.add(shape); + return label; + } + + public ShapeExpression buildExpr (Resource r) { + if (model.contains(r, RDF.type, ShEx.TripleConstraint)) + return buildTripleConstraint(r); + if (model.contains(r, RDF.type, ShEx.NodeConstraint)) + return buildNodeConstraint(r); + throw new InvalidShape(r); + } + + public ShapeExpression buildTripleConstraint (Resource r) { + var pred = model.getRequiredProperty(r, ShEx.predicate) + .getObject().asNode(); + var expr = buildExpr(model + .getProperty(r, ShEx.valueExpr) + .getObject().asResource()); + var tc = new TripleConstraint(r.asNode(), pred, false, expr, + new Cardinality("", 1, 1), null); + return ShapeExprTripleExpr.newBuilder() + .label(r.asNode()) + .shapeExpr(tc) + .build(); + } + + public ShapeExpression buildNodeConstraint (Resource r) { + var values = model.getRequiredProperty(r, ShEx.values) + .getList() + .mapWith(n -> n.asResource().getURI()) + .mapWith(i -> new ValueSetRange(i, null, null, false)) + .toList(); + var vc = new ValueConstraint(values); + var nc = new NodeConstraint(List.of(vc)); + return new ShapeNodeConstraint(nc, null); + } +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java deleted file mode 100644 index 926701958..000000000 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPVocab.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * ACS Fuseki - * RDF vocabulary - * Copyright 2025 University of Sheffield AMRC - */ - -package uk.co.amrc.factoryplus.fuseki; - -import org.apache.jena.rdf.model.Property; - -public class FPVocab { - public static final String NS = "http://factoryplus.app.amrc.co.uk/rdf/2025-05/ac-24-611/acl#"; - public static String getURI() { return NS; } - -} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java new file mode 100644 index 000000000..257bd674f --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java @@ -0,0 +1,29 @@ +/* + * ACS Fuseki + * ShEx vocabulary + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import org.apache.jena.rdf.model.*; + +public class ShEx { + public static final String NS = "http://www.w3.org/ns/shex#"; + public static String getURI() { return NS; } + + private static Property prop (String p) { + return ResourceFactory.createProperty(NS + p); + } + + private static Resource iri (String i) { + return ResourceFactory.createResource(NS + i); + } + + public static final Resource NodeConstraint = iri("NodeConstraint"); + public static final Resource TripleConstraint = iri("TripleConstraint"); + + public static final Property predicate = prop("predicate"); + public static final Property valueExpr = prop("valueExpr"); + public static final Property values = prop("values"); +} diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/Vocab.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/Vocab.java new file mode 100644 index 000000000..f99a32470 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/Vocab.java @@ -0,0 +1,30 @@ +/* + * ACS Fuseki + * RDF vocabulary + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import org.apache.jena.rdf.model.*; + +public class Vocab { + public static final String NS = "http://factoryplus.app.amrc.co.uk/rdf/2025-05/ac-24-611/acl#"; + public static String getURI() { return NS; } + + private static Resource iri (String i) { + return ResourceFactory.createResource(NS + i); + } + private static Property prop (String p) { + return ResourceFactory.createProperty(NS + p); + } + + public static final Resource ShexCondition = iri("ShexCondition"); + + public static final Property object = prop("object"); + public static final Property predicate = prop("predicate"); + public static final Property readTriple = prop("readTriple"); + public static final Property subject = prop("subject"); + public static final Property username = prop("username"); + public static final Property writeTriple = prop("writeTriple"); +} From b49f1ed38b86dec0c1fa8ca9d8b5cfda62fc882e Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Thu, 7 Aug 2025 12:13:51 +0100 Subject: [PATCH 4/8] Swap the inner and outer class It's cleaner this way. --- .../fuseki/FPAclDatasetAssembler.java | 2 +- .../amrc/factoryplus/fuseki/FPAclModule.java | 2 +- .../factoryplus/fuseki/FPDatasetGraph.java | 125 +++++++++--------- .../factoryplus/fuseki/FPQuerySparql.java | 4 +- 4 files changed, 69 insertions(+), 64 deletions(-) diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java index 159eeb7cd..646c375e2 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java @@ -36,7 +36,7 @@ public Dataset open(Assembler a, Resource root, Mode mode) { public DatasetGraph createDataset(Assembler a, Resource root) { var base = createBaseDataset(root, DatasetAssemblerVocab.pDataset); log.info("Created base dataset: {}", base); - var ds = new FPDatasetGraph(base); + var ds = FPDatasetGraph.withBase(base); log.info("Wrapped dataset: {}", ds); return ds; } diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java index 5314b3755..b7f825b87 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclModule.java @@ -31,7 +31,7 @@ public void configDataAccessPoint (DataAccessPoint dap, Model m) { var srv = dap.getDataService(); var ds = srv.getDataset(); log.info("Found DS class {}", ds.getClass()); - if (!(ds instanceof FPDatasetGraph)) { + if (!(ds instanceof FPDatasetGraph.Builder)) { log.info("Not an FPDatasetGraph, skipping"); return; } diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java index ead0ca3dd..249c8ca73 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java @@ -17,85 +17,90 @@ import org.apache.jena.sparql.core.DatasetGraph; import org.apache.jena.sparql.core.DatasetGraphWrapper; import org.apache.jena.sparql.core.DatasetGraphWrapperView; -import org.apache.jena.sparql.core.DatasetGraphQuads; import org.apache.jena.sparql.core.GraphView; import org.apache.jena.sparql.core.Quad; -class FPDatasetGraph extends DatasetGraphWrapper { - class AclDSG extends DatasetGraphWrapper - implements DatasetGraphWrapperView - { - final Logger log = LoggerFactory.getLogger(AclDSG.class); +class FPDatasetGraph extends DatasetGraphWrapper + implements DatasetGraphWrapperView +{ + final Logger log = LoggerFactory.getLogger(FPDatasetGraph.class); - final String principal; - final FPShapeBuilder shapes; + final String principal; + final FPShapeBuilder shapes; - public AclDSG (DatasetGraph base, String principal) { + /* This class is to hold the base DSG until we know what principal + * we will use. */ + static class Builder extends DatasetGraphWrapper { + public Builder (DatasetGraph base) { super(base); - this.principal = principal; - /* Grants must always be in the default graph. Grants in - * other graphs are hypothetical and not active. */ - this.shapes = new FPShapeBuilder(base.getDefaultGraph()); - log.info("Construct for {} base {}", principal, base); } - @Override - protected DatasetGraph get () { - log.info("get"); - - if (!shapes.hasSchema()) { - shapes.withPrincipal(principal) - .buildSchema(); - log.info("Built schema for {}", principal); - log.info("Shapes:"); - shapes.getSchema().getShapes() - .forEach(s -> log.info(" {}", s)); - log.info("Triples (may read):"); - shapes.getMayRead() - .forEach(t -> log.info(" {}", t)); - } - - return super.get(); + public DatasetGraph withPrincipal (String principal) { + return new FPDatasetGraph(getBaseForQuery(), principal); } + } - @Override - public Graph getDefaultGraph () { - log.info("getDefaultGraph"); - return GraphView.createDefaultGraph(this); - } + private FPDatasetGraph (DatasetGraph base, String principal) { + super(base); + this.principal = principal; + /* Grants must always be in the default graph. Grants in + * other graphs are hypothetical and not active. */ + this.shapes = new FPShapeBuilder(base.getDefaultGraph()); + log.info("Construct for {} base {}", principal, base); + } - @Override - public Graph getUnionGraph () { - log.info("getUnionGraph"); - return GraphView.createUnionGraph(this); - } + public static DatasetGraph withBase (DatasetGraph base) { + return new Builder(base); + } - @Override - public Graph getGraph (Node g) { - log.info("getGraph {}", g); - return GraphView.createNamedGraph(this, g); + @Override + protected DatasetGraph get () { + log.info("get"); + + if (!shapes.hasSchema()) { + shapes.withPrincipal(principal) + .buildSchema(); + log.info("Built schema for {}", principal); + log.info("Shapes:"); + shapes.getSchema().getShapes() + .forEach(s -> log.info(" {}", s)); + log.info("Triples (may read):"); + shapes.getMayRead() + .forEach(t -> log.info(" {}", t)); } - @Override public boolean containsGraph (Node g) { return true; } + return super.get(); + } + + @Override + public Graph getDefaultGraph () { + log.info("getDefaultGraph"); + return GraphView.createDefaultGraph(this); + } - @Override - public Iterator find (Node g, Node s, Node p, Node o) { - log.info("FIND: {} {} {} ({}) for {}", s, p, o, g, principal); - return super.find(g, s, p, o); - } + @Override + public Graph getUnionGraph () { + log.info("getUnionGraph"); + return GraphView.createUnionGraph(this); + } - @Override - public Iterator findNG (Node g, Node s, Node p, Node o) { - log.info("FIND NG: {} {} {} ({}) for {}", s, p, o, g, principal); - return super.findNG(g, s, p, o); - } + @Override + public Graph getGraph (Node g) { + log.info("getGraph {}", g); + return GraphView.createNamedGraph(this, g); } - public FPDatasetGraph (DatasetGraph base) { - super(base); + @Override public boolean containsGraph (Node g) { return true; } + + @Override + public Iterator find (Node g, Node s, Node p, Node o) { + log.info("FIND: {} {} {} ({}) for {}", s, p, o, g, principal); + return super.find(g, s, p, o); } - public DatasetGraph withAclFor (String principal) { - return new AclDSG(getBaseForQuery(), principal); + @Override + public Iterator findNG (Node g, Node s, Node p, Node o) { + log.info("FIND NG: {} {} {} ({}) for {}", s, p, o, g, principal); + return super.findNG(g, s, p, o); } } diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java index a4e59e567..e7011ee4c 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java @@ -30,8 +30,8 @@ public class FPQuerySparql extends SPARQL_QueryDataset { var princ = findPrincipal(action); log.info("decideDataset for {}", princ); - var ds = (FPDatasetGraph)getDataset(action); - var ads = ds.withAclFor(princ); + var ds = (FPDatasetGraph.Builder)getDataset(action); + var ads = ds.withPrincipal(princ); return Pair.create(ads, query); } From 517d87f92331cf87650189e7ed13366ae47e168a Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Fri, 8 Aug 2025 11:14:17 +0100 Subject: [PATCH 5/8] Evaluate ACLs in the DatasetGraph Convert the FPShapeBuilder into a builder class for FPShapeEvaluator; this now holds the final ShexSchema. This removes the Optional which is obviously a good thing. At the moment I build the FPShapeEvaluator in get(); it doesn't feel quite right building it in the constructor. This means another Optional, unfortunately, since we're changing state again. A proper implementation would use change-notify and maintain a single schema with all the shapes in the graph in any case. Implement find() on top of stream(), instead of the other way round. The Iterator classes seem to be extremely limited. --- .../factoryplus/fuseki/FPDatasetGraph.java | 57 +++++++++++++------ .../factoryplus/fuseki/FPShapeBuilder.java | 25 ++------ .../factoryplus/fuseki/FPShapeEvaluator.java | 57 +++++++++++++++++++ 3 files changed, 102 insertions(+), 37 deletions(-) create mode 100644 acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeEvaluator.java diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java index 249c8ca73..0416e5d6f 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java @@ -7,10 +7,13 @@ package uk.co.amrc.factoryplus.fuseki; import java.util.Iterator; +import java.util.Optional; +import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.jena.atlas.iterator.Iter; import org.apache.jena.graph.Graph; import org.apache.jena.graph.Node; import org.apache.jena.riot.system.PrefixMap; @@ -26,7 +29,7 @@ class FPDatasetGraph extends DatasetGraphWrapper final Logger log = LoggerFactory.getLogger(FPDatasetGraph.class); final String principal; - final FPShapeBuilder shapes; + private Optional maybeShapes; /* This class is to hold the base DSG until we know what principal * we will use. */ @@ -43,9 +46,7 @@ public DatasetGraph withPrincipal (String principal) { private FPDatasetGraph (DatasetGraph base, String principal) { super(base); this.principal = principal; - /* Grants must always be in the default graph. Grants in - * other graphs are hypothetical and not active. */ - this.shapes = new FPShapeBuilder(base.getDefaultGraph()); + this.maybeShapes = Optional.empty(); log.info("Construct for {} base {}", principal, base); } @@ -56,20 +57,25 @@ public static DatasetGraph withBase (DatasetGraph base) { @Override protected DatasetGraph get () { log.info("get"); - - if (!shapes.hasSchema()) { - shapes.withPrincipal(principal) - .buildSchema(); + var ds = super.get(); + + if (maybeShapes.isEmpty()) { + /* Grants must always be in the default graph. Grants in + * other graphs are hypothetical and not active. */ + var shapes = new FPShapeBuilder(ds.getDefaultGraph()) + .withPrincipal(principal) + .build(); log.info("Built schema for {}", principal); - log.info("Shapes:"); + log.info(" Shapes:"); shapes.getSchema().getShapes() - .forEach(s -> log.info(" {}", s)); - log.info("Triples (may read):"); + .forEach(s -> log.info(" {}", s)); + log.info(" Triples (may read):"); shapes.getMayRead() - .forEach(t -> log.info(" {}", t)); + .forEach(t -> log.info(" {}", t)); + maybeShapes = Optional.of(shapes); } - return super.get(); + return ds; } @Override @@ -92,15 +98,32 @@ public Graph getGraph (Node g) { @Override public boolean containsGraph (Node g) { return true; } + private Stream stream1 (boolean ng, Node g, Node s, Node p, Node o) { + log.info("FIND: {} {} {} in {} ({}) for {}", + s, p, o, g, ng, principal); + var shapes = maybeShapes.orElseThrow(); + var base = get(); + /* XXX I have no idea if this is right. I am not thinking too + * hard about default graph semantics right now. */ + var graph = ng ? base.getGraph(g) : base.getDefaultGraph(); + + return Iter.asStream(ng ? super.findNG(g, s, p, o) : super.find(g, s, p, o)) + .filter(q -> shapes.permitted(graph, + q.getSubject(), q.getPredicate(), q.getObject())); + } + @Override public Iterator find (Node g, Node s, Node p, Node o) { - log.info("FIND: {} {} {} ({}) for {}", s, p, o, g, principal); - return super.find(g, s, p, o); + return stream1(false, g, s, p, o).iterator(); } @Override public Iterator findNG (Node g, Node s, Node p, Node o) { - log.info("FIND NG: {} {} {} ({}) for {}", s, p, o, g, principal); - return super.findNG(g, s, p, o); + return stream1(true, g, s, p, o).iterator(); + } + + @Override + public Stream stream (Node g, Node s, Node p, Node o) { + return stream1(false, g, s, p, o); } } diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java index ace575a66..9565ef4ff 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java @@ -9,7 +9,6 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import org.apache.jena.graph.*; import org.apache.jena.rdf.model.*; @@ -22,7 +21,6 @@ class FPShapeBuilder { private Model model; private List shapes; private List mayRead; - private Optional schema; class InvalidShape extends RuntimeException { public InvalidShape (Resource r) { @@ -39,36 +37,23 @@ public FPShapeBuilder (Graph g) { this.model = ModelFactory.createModelForGraph(g); this.shapes = new ArrayList(); this.mayRead = new ArrayList(); - this.schema = Optional.empty(); } - public boolean hasSchema () { - return schema.isPresent(); - } - - public List getMayRead () { - schema.orElseThrow(); - return mayRead; - } - - public ShexSchema getSchema () { - return schema.orElseThrow(); - } - - public FPShapeBuilder buildSchema () { + public FPShapeEvaluator build () { var prefixes = new PrefixMapFactory().create(); prefixes.add("rdf", RDF.getURI()); prefixes.add("shex", ShEx.NS); prefixes.add("acl", Vocab.NS); - schema = Optional.of(ShexSchema.shapes( + var schema = ShexSchema.shapes( /*source*/"", /*baseURI*/"", prefixes, /*startShape*/null, shapes, /*imports*/List.of(), /*semActs*/List.of(), - /*tripleRefs*/Map.of())); - return this; + /*tripleRefs*/Map.of()); + + return new FPShapeEvaluator(schema, List.copyOf(mayRead)); } public FPShapeBuilder withPrincipal (String user) { diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeEvaluator.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeEvaluator.java new file mode 100644 index 000000000..ee8665b05 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeEvaluator.java @@ -0,0 +1,57 @@ +/* + * ACS Fuseki + * ShEx ACL evaluator + * Copyright 2025 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.fuseki; + +import java.util.List; + + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.graph.*; +import org.apache.jena.rdf.model.*; +import org.apache.jena.shex.*; + +class FPShapeEvaluator { + final Logger log = LoggerFactory.getLogger(FPShapeEvaluator.class); + + ShexValidator validator; + ShexSchema schema; + List mayRead; + + public FPShapeEvaluator (ShexSchema schema, List mayRead) { + this.schema = schema; + this.mayRead = mayRead; + + this.validator = ShexValidator.get(); + } + + public ShexSchema getSchema () { return schema; } + public List getMayRead () { return mayRead; } + + /* Evaluate a request against the ACL */ + public boolean permitted (Graph g, Node s, Node p, Node o) { + return mayRead.stream() + .anyMatch(t -> evaluate(t.getSubject(), s, g) + && evaluate(t.getPredicate(), p, g) + && evaluate(t.getObject(), o, g)); + } + + /* Evaluate a Node against a Shape */ + boolean evaluate (Node cond, Node target, Graph graph) { + if (cond.equals(Node.ANY)) + return true; + + var rep = validator.validate(graph, schema, cond, target); + if (rep.conforms()) + return true; + + log.info("Node {} in {} failed against {}", target, graph, cond); + rep.forEachReport(r -> log.info(" {}", r)); + return false; + } +} From 7acc51539912de4942efc01f2e7a6de8fd735f30 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Fri, 8 Aug 2025 11:34:25 +0100 Subject: [PATCH 6/8] Add a README --- acs-fuseki/README.md | 107 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 acs-fuseki/README.md diff --git a/acs-fuseki/README.md b/acs-fuseki/README.md new file mode 100644 index 000000000..1c86a85bc --- /dev/null +++ b/acs-fuseki/README.md @@ -0,0 +1,107 @@ +# ACS Fuseki plugin + +This is a sketch of a plugin to make Apache Jena Fuseki suitable for use +as an RDF graph service within ACS. The principle feature missing is +robust tripe-level access control; this plugin gives an outline of how +this might be provided. + +## Building and running + +You will need Java, a JDK and Maven. Other dependencies will be +downloaded by Maven as required. You will need `java` and `mvn` in your +`PATH`, and may need to set `JAVA_HOME` and/or `M2_HOME` in the +environment. + +To build, run + + mvn -B package + +This will download the deps and build a JAR in +`target/acs-fuseki-0.0.1.jar`. This is a build of Fuseki Main, i.e. the +triplestore without the UI. Run with + + java -jar target/acs-fuseki-0.0.1.jar --config=config.ttl + +This will run a triplestore exposing a SPARQL endpoint at +`http://localhost:3030/ds/sparql`, accepting queries and update +operations. The database will be created in the `db` directory. + +## Namespaces + +These namespaces are relevant: + + prefix shex: . + prefix acl: . + +## Features + +Access control is only implemented on query; all updates are permitted. +The username to authorise is taken from an HTTP Basic Auth header; the +password is ignored. Obviously this would need integrating properly into +an authentication framework eventually but that's not the point at the +moment. + +ACLs live in the dataset, in the default graph. ACLs in other graphs are +ignored; I am assuming for the moment that these will represent +hypotheticals or other forms of information and should not be used for +access control. Generally access to named graphs is not considered +properly yet. + +A principal is identified by an IRI and a string username; the username +is linked to the IRI by an `acl:username` property. Permission grants +are also linked, with `acl:readTriple` properties. The permission model +is deny by default with grant permissions only. The range of +`acl:readTriple` is the class `acl:ShexCondition`; an object of this +class represents a condition on triples expressed in the +[ShEx](https://shex.io) expression language. + +ShEx is intended for schema validation; it's basically an alternative to +SHACL. It isn't entirely suitable for this purpose but is the only +expression language implementation easily available within Jena. The +primary concept in ShEx is the Shape, which can be seen as a condition a +node must satisfy, both in terms of its internal properties +(IRI/blank/literal, datatype and content if literal) and in terms of its +links to other nodes. Normally the validation process accepts a list of +Shapes and a ShapeMap which determines which nodes must match which +Shapes, and a graph passes validation if all relevant nodes conform. + +The Jena ShEx implementation makes it possible to evaluate a particular +node against a particular shape without needing to validate the whole +graph. We are using this to implement access control. An +`acl:ShexCondition` object has three properties: `acl:subject`, +`acl:predicate` and `acl:object`. These are all optional but if present +must be a ShEx shape expression; a `shex:TripleConstraint` or a +`shex:NodeConstraint`. A triple passes the constraint (access will be +granted) if each member of the triple conforms to the specified shape; +if a property is omitted then any value passes. + +Currently a very small subset of ShEx is supported, consisting of: + +* NodeConstraints with `shex:values` only. +* TripleConstraints with `shex:predicate` and `shex:valueExpr` only. + +Cardinalities other than 'exactly one' are not supported, nor are the +logical operations or any conditions on literals. + +## Implementation + +The ACL layer is implemented primarily in a DatasetGraph subclass; this +is an object which presents an RDF dataset to the rest of Jena. An +Assembler module is provided which can be used in the config file to +create an FPDatasetGraph; this wraps another dataset and implements +access control. + +The DatasetGraph API does not have access to request information to +perform authorisation. For this reason we search for endpoints accessing +our dataset and replace the sparql query operation handler with a +subclass; this deals with extracting the username from the request and +injecting it into the dataset used to perform the query. + +The ShEx shapes are stored in the graph; unfortunately, although ShEx is +defined as an RDF data model, the Jena implementation can only parse +shapes from JSON or ShExC files. This means we need to traverse the RDF +and build the ShapeExpression objects by hand; this is why only a subset +of ShEx is currently implemented. This job is performed by an +FPShapeBuilder, which builds an FPShapeEvaluator containing a ShexSchema +and a list of triples representing the ShexConditions. Currently this +step is performed for every request but this could be optimised. From 5950bd08987b0fc28dcf5e139e1b96c89090dded Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Fri, 8 Aug 2025 11:46:02 +0100 Subject: [PATCH 7/8] Add an ACL example --- acs-fuseki/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/acs-fuseki/README.md b/acs-fuseki/README.md index 1c86a85bc..3b8494950 100644 --- a/acs-fuseki/README.md +++ b/acs-fuseki/README.md @@ -83,6 +83,26 @@ Currently a very small subset of ShEx is supported, consisting of: Cardinalities other than 'exactly one' are not supported, nor are the logical operations or any conditions on literals. +## Example + +An example user account with some permission grants might be: + + prefix ex: . + + ex:user acl:username "user"; + acl:readTriple [ + a acl:ShexCondition; + acl:subject [ + a shex:TripleConstraint; + shex:predicate rdf:type; + shex:valueExpr [ + a shex:NodeConstraint; + shex:values (ex:Class); + ]]]. + +This permits the `user` user to read any triples with a subject in the +class `ex:Class`. + ## Implementation The ACL layer is implemented primarily in a DatasetGraph subclass; this From 45dcc40f216386359931e4caef9bf035349272bb Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 11 Aug 2025 15:06:09 +0100 Subject: [PATCH 8/8] Implement inverse and cardinalities on triples --- .../factoryplus/fuseki/FPShapeBuilder.java | 38 ++++++++++++------- .../uk/co/amrc/factoryplus/fuseki/ShEx.java | 3 ++ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java index 9565ef4ff..7bb13c22d 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Optional; import org.apache.jena.graph.*; import org.apache.jena.rdf.model.*; @@ -65,7 +66,7 @@ public FPShapeBuilder withPrincipal (String user) { return this; } - public Resource getIriForUser (String user) { + Resource getIriForUser (String user) { var princs = model.listResourcesWithProperty(Vocab.username, user) .toList(); if (princs.size() > 1) @@ -75,7 +76,7 @@ public Resource getIriForUser (String user) { return princs.get(0); } - public void addCondition (List acl, Resource r) { + void addCondition (List acl, Resource r) { var tr = Triple.create( addShapeFor(r, Vocab.subject), addShapeFor(r, Vocab.predicate), @@ -83,7 +84,7 @@ public void addCondition (List acl, Resource r) { acl.add(tr); } - public Node addShapeFor (Resource r, Property p) { + Node addShapeFor (Resource r, Property p) { var obj = model.getProperty(r, p); if (obj == null) return Node.ANY; @@ -96,29 +97,38 @@ public Node addShapeFor (Resource r, Property p) { return label; } - public ShapeExpression buildExpr (Resource r) { - if (model.contains(r, RDF.type, ShEx.TripleConstraint)) + ShapeExpression buildExpr (Resource r) { + if (r.hasProperty(RDF.type, ShEx.TripleConstraint)) return buildTripleConstraint(r); - if (model.contains(r, RDF.type, ShEx.NodeConstraint)) + if (r.hasProperty(RDF.type, ShEx.NodeConstraint)) return buildNodeConstraint(r); throw new InvalidShape(r); } - public ShapeExpression buildTripleConstraint (Resource r) { - var pred = model.getRequiredProperty(r, ShEx.predicate) + int readCardinality (Resource r, Property p) { + return Optional.ofNullable(r.getProperty(p)) + .map(s -> s.getInt()) + .orElse(1); + } + + ShapeExpression buildTripleConstraint (Resource r) { + var pred = r.getRequiredProperty(ShEx.predicate) .getObject().asNode(); - var expr = buildExpr(model - .getProperty(r, ShEx.valueExpr) - .getObject().asResource()); - var tc = new TripleConstraint(r.asNode(), pred, false, expr, - new Cardinality("", 1, 1), null); + var expr = buildExpr(r.getProperty(ShEx.valueExpr) + .getResource()); + var inverse = r.hasLiteral(ShEx.inverse, true); + var min = readCardinality(r, ShEx.min); + var max = readCardinality(r, ShEx.max); + + var tc = new TripleConstraint(r.asNode(), pred, inverse, + expr, new Cardinality("", min, max), null); return ShapeExprTripleExpr.newBuilder() .label(r.asNode()) .shapeExpr(tc) .build(); } - public ShapeExpression buildNodeConstraint (Resource r) { + ShapeExpression buildNodeConstraint (Resource r) { var values = model.getRequiredProperty(r, ShEx.values) .getList() .mapWith(n -> n.asResource().getURI()) diff --git a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java index 257bd674f..9bf5f1955 100644 --- a/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java @@ -23,6 +23,9 @@ private static Resource iri (String i) { public static final Resource NodeConstraint = iri("NodeConstraint"); public static final Resource TripleConstraint = iri("TripleConstraint"); + public static final Property inverse = prop("inverse"); + public static final Property max = prop("max"); + public static final Property min = prop("min"); public static final Property predicate = prop("predicate"); public static final Property valueExpr = prop("valueExpr"); public static final Property values = prop("values");