diff --git a/acs-fuseki/.gitignore b/acs-fuseki/.gitignore new file mode 100644 index 000000000..be99fbee8 --- /dev/null +++ b/acs-fuseki/.gitignore @@ -0,0 +1,4 @@ +db/ +dependency-reduced-pom.xml +target/ +tmp/ diff --git a/acs-fuseki/README.md b/acs-fuseki/README.md new file mode 100644 index 000000000..3b8494950 --- /dev/null +++ b/acs-fuseki/README.md @@ -0,0 +1,127 @@ +# 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. + +## 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 +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. diff --git a/acs-fuseki/config.ttl b/acs-fuseki/config.ttl new file mode 100644 index 000000000..de9a5b5b5 --- /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 "sparql" + ]; + + 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/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/FPAclDatasetAssembler.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPAclDatasetAssembler.java new file mode 100644 index 000000000..646c375e2 --- /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 = 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 new file mode 100644 index 000000000..b7f825b87 --- /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.Builder)) { + 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..0416e5d6f --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPDatasetGraph.java @@ -0,0 +1,129 @@ +/* + * 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 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; +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.GraphView; +import org.apache.jena.sparql.core.Quad; + +class FPDatasetGraph extends DatasetGraphWrapper + implements DatasetGraphWrapperView +{ + final Logger log = LoggerFactory.getLogger(FPDatasetGraph.class); + + final String principal; + private Optional maybeShapes; + + /* 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); + } + + public DatasetGraph withPrincipal (String principal) { + return new FPDatasetGraph(getBaseForQuery(), principal); + } + } + + private FPDatasetGraph (DatasetGraph base, String principal) { + super(base); + this.principal = principal; + this.maybeShapes = Optional.empty(); + log.info("Construct for {} base {}", principal, base); + } + + public static DatasetGraph withBase (DatasetGraph base) { + return new Builder(base); + } + + @Override + protected DatasetGraph get () { + log.info("get"); + 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:"); + shapes.getSchema().getShapes() + .forEach(s -> log.info(" {}", s)); + log.info(" Triples (may read):"); + shapes.getMayRead() + .forEach(t -> log.info(" {}", t)); + maybeShapes = Optional.of(shapes); + } + + return ds; + } + + @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; } + + 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) { + return stream1(false, g, s, p, o).iterator(); + } + + @Override + public Iterator findNG (Node g, Node s, Node p, Node 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/FPQuerySparql.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPQuerySparql.java new file mode 100644 index 000000000..e7011ee4c --- /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.Builder)getDataset(action); + var ads = ds.withPrincipal(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/FPShapeBuilder.java b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java new file mode 100644 index 000000000..7bb13c22d --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/FPShapeBuilder.java @@ -0,0 +1,141 @@ +/* + * 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; + + 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(); + } + + public FPShapeEvaluator build () { + var prefixes = new PrefixMapFactory().create(); + prefixes.add("rdf", RDF.getURI()); + prefixes.add("shex", ShEx.NS); + prefixes.add("acl", Vocab.NS); + + var schema = ShexSchema.shapes( + /*source*/"", /*baseURI*/"", + prefixes, /*startShape*/null, + shapes, + /*imports*/List.of(), + /*semActs*/List.of(), + /*tripleRefs*/Map.of()); + + return new FPShapeEvaluator(schema, List.copyOf(mayRead)); + } + + 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; + } + + 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); + } + + 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); + } + + 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; + } + + ShapeExpression buildExpr (Resource r) { + if (r.hasProperty(RDF.type, ShEx.TripleConstraint)) + return buildTripleConstraint(r); + if (r.hasProperty(RDF.type, ShEx.NodeConstraint)) + return buildNodeConstraint(r); + throw new InvalidShape(r); + } + + 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(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(); + } + + 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/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; + } +} 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..ce09a7cc1 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/RunFuseki.java @@ -0,0 +1,53 @@ +/* + * ACS Fuseki server + * Main entry point + * 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.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 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) { + ex.printStackTrace(); + } + finally { server.stop(); } + } + + public static FusekiServer.Builder build(String ...args) { + + FusekiModules modules = FusekiModules.create( + new FPAclModule()); + + FusekiServer.Builder builder = + FusekiMain.builder(args) + .fusekiModules(modules); + return builder; + } +} 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..9bf5f1955 --- /dev/null +++ b/acs-fuseki/src/main/java/uk/co/amrc/factoryplus/fuseki/ShEx.java @@ -0,0 +1,32 @@ +/* + * 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 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"); +} 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"); +} 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 ); + } +}