Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## [5.6.2] - 2026-07-27
### Changed
- No RDFS inference over application ontologies anymore: the `owl:imports` closure is resolved natively by ontapi (`OntModelFactory.createModel` over a `ScopedGraphRepository` view) as a live union graph instead of being manually flattened, RDFS-inferred and materialized. All consumers already traverse hierarchies explicitly (constructor/constraint inheritance, `(rdfs:subClassOf)*` client queries); `rdfs:Class` terms are promoted to `owl:Class` in a separate union member so no document graph is polluted. Closure union graphs are cached in a bounded map on `Application`, keyed by ontology URI
- The Linked Data proxy serves ontology-closure documents with their raw graphs — asserted triples only, identical to a direct document GET; the `DESCRIBE` fallback over the in-memory closure (for terms minted in external namespaces) is now inference-free as well
- `Namespace` no-query GET serves the raw ontology graph from the shared repository instead of building a fresh `createRepository` per request

### Fixed
- Proxied ontology documents carried inferred `rdf:type rdfs:Resource` on every term (from the RDFS-materialized in-memory model), producing multi-token `@typeof` that broke View block rendering client-side
- Block/View/query/chart templates matched `@typeof` by exact string equality, breaking on any multi-typed resource; all comparisons switched to token-aware `tokenize(@typeof, ' ')` (RDFa defines `@typeof` as a whitespace-separated list)
- Entity-inlining and SEF compilation moved to `pre-integration-test` so `maven-war-plugin:war` cannot overwrite entity-resolved overlay stylesheets before the Dockerfile copies `target/ROOT`

## [5.6.1] - 2026-07-26
### Security
- SSRF: `URLValidator` blocks wildcard/any-local (`0.0.0.0`, `::`) addresses and checks every resolved address; loopback stays reachable, `ALLOW_INTERNAL_URLS` remains the escape hatch (LNK-003/LNK-009)
Expand Down
25 changes: 21 additions & 4 deletions http-tests/proxy/GET-proxied-ontology-ns.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ clear-ontology.sh \
--ontology "$namespace"

# request the namespace document URI (without fragment) via ?uri= proxy.
# the namespace document is not DataManager-mapped and not a registered app,
# so ProxyRequestFilter falls through to the OntModel DESCRIBE path, which
# returns descriptions of all #-fragment terms in that namespace.
# the namespace document is not DataManager-mapped, not a registered app and not a graph
# in the ontology closure, so ProxyRequestFilter falls through to the closure DESCRIBE
# fallback, which returns descriptions of all #-fragment terms in that namespace.

response=$(curl -k -f -s \
-G \
Expand All @@ -60,7 +60,24 @@ response=$(curl -k -f -s \
--data-urlencode "uri=${namespace_uri}" \
"$END_USER_BASE_URL")

# verify both class descriptions are present in the response
# verify both class descriptions are present in the response and no inferred triples leak

echo "$response" | grep -q "$class1"
echo "$response" | grep -q "$class2"
! echo "$response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource"

# request the ontology document itself via ?uri= proxy: it is a graph in the ontology closure,
# so it must be served with its raw graph — asserted triples only, identical to a direct
# document GET. Inferred rdf:type rdfs:Resource used to leak from the RDFS-materialized
# in-memory model here, breaking client-side @typeof matching of View blocks.

doc_response=$(curl -k -f -s \
-G \
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
-H "Accept: application/n-triples" \
--data-urlencode "uri=${ontology_doc}" \
"$END_USER_BASE_URL")

echo "$doc_response" | grep -q "$class1"
echo "$doc_response" | grep -q "$class2"
! echo "$doc_response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource"
20 changes: 18 additions & 2 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
import javax.net.ssl.TrustManagerFactory;
import jakarta.servlet.ServletContext;
import javax.xml.transform.Source;
import org.apache.jena.ontapi.UnionGraph;
import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.query.Dataset;
import org.apache.jena.query.Query;
Expand Down Expand Up @@ -199,6 +200,7 @@
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.stream.StreamSource;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import net.sf.saxon.om.TreeInfo;
import net.sf.saxon.s9api.Processor;
Expand Down Expand Up @@ -288,6 +290,7 @@ public class Application extends ResourceConfig
private final KeyStore keyStore, trustStore;
private final URI secretaryWebIDURI;
private final List<Locale> supportedLanguages;
private final ExpiringMap<String, UnionGraph> ontologyGraphs = ExpiringMap.builder().maxSize(1000).expirationPolicy(ExpirationPolicy.ACCESSED).expiration(1, TimeUnit.HOURS).build(); // assembled ontology imports-closure union graphs, keyed by ontology URI; evicted entries are transparently rebuilt by OntologyFilter on the next cache miss
private final ExpiringMap<URI, Model> webIDmodelCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.webIDCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // TTL (seconds) configurable via WEBID_CACHE_EXPIRATION; a lower value bounds how long a revoked WebID stays cached
private final ExpiringMap<String, Model> oidcModelCache = ExpiringMap.builder().variableExpiration().build();
private final ExpiringMap<String, jakarta.json.JsonObject> jwksCache = ExpiringMap.builder().expiration(Long.parseLong(System.getProperty("com.atomgraph.linkeddatahub.jwksCacheExpiration", "86400")), TimeUnit.SECONDS).build(); // Cache JWKS responses; TTL (seconds) configurable via JWKS_CACHE_EXPIRATION
Expand Down Expand Up @@ -1804,10 +1807,23 @@ public OntologyRepository createRepository(EndUserApplication app)

return appRepository;
}


/**
* Returns the cache of assembled ontology imports-closure union graphs, keyed by ontology URI
* (origin-scoped per dataspace, so a single map cannot collide across applications).
* The union graph is ontapi's view over the raw per-document graphs cached in the (per-app or
* system) repository; it is not a document graph itself and is never served on the wire.
*
* @return ontology URI to union graph map
*/
public Map<String, UnionGraph> getOntologyGraphs()
{
return ontologyGraphs;
}

/**
* Returns a registry of readable and writeable media types.
*
*
* @return registry object
*/
public MediaTypes getMediaTypes()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,9 @@ public Response get(@QueryParam(QUERY) Query query,
// the application ontology MUST use a <ns> URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class
String ontologyURI = getApplication().getOntology().getURI();
if (log.isDebugEnabled()) log.debug("Returning raw namespace ontology: {}", ontologyURI);
// not returning the injected in-memory ontology because it has inferences applied to it;
// a fresh, mapping-seeded repository serves the raw SPARQL-loaded ontology
OntologyRepository repository = getSystem().createRepository(getApplication().as(EndUserApplication.class));
// not returning the injected in-memory ontology because it is the full imports closure (a union view);
// the shared repository serves the standalone raw ontology graph
OntologyRepository repository = getSystem().getRepository(getApplication().as(EndUserApplication.class));
return getResponseBuilder(org.apache.jena.rdf.model.ModelFactory.createModelForGraph(repository.get(ontologyURI))).build();
}
else throw new BadRequestException("SPARQL query string not provided");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,14 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer

EndUserApplication endUserApp = getApplication().as(AdminApplication.class).getEndUserApplication(); // we're assuming the current app is admin
OntologyRepository repository = getSystem().getRepository(endUserApp);
if (repository.isCached(ontologyURI))
if (repository.isCached(ontologyURI) || getSystem().getOntologyGraphs().containsKey(ontologyURI))
{
if (log.isDebugEnabled()) log.debug("Clearing ontology with URI '{}' from memory", ontologyURI);
repository.remove(ontologyURI);
getSystem().getOntologyGraphs().remove(ontologyURI);

URI ontologyDocURI = UriBuilder.fromUri(ontologyURI).fragment(null).build(); // skip fragment from the ontology URI to get its graph URI
repository.remove(ontologyDocURI.toString()); // the raw graph is also aliased under the fragment-stripped document URI
// frontend proxy still uses URL-pattern BAN for direct document GETs (until Stage 3 brings xkey tagging to varnish-frontend).
// xkey purge covers proxied SPARQL CONSTRUCT/SELECT responses tagged by their backend (varnish-admin / varnish-end-user).
URI frontendProxy = getSystem().getFrontendProxy();
Expand All @@ -110,7 +112,7 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer
}

// !!! we need to reload the ontology model before returning a response, to make sure the next request already gets the new version !!!
OntologyFilter.loadOntology(repository, ontologyURI);
getSystem().getOntologyGraphs().put(ontologyURI, OntologyFilter.loadOntology(repository, ontologyURI));
}

if (referer != null) return Response.seeOther(referer).build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,27 @@
import com.atomgraph.linkeddatahub.apps.model.Application;
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
import com.atomgraph.client.util.jena.PrefixGraphRepository;
import com.atomgraph.linkeddatahub.server.util.ScopedGraphRepository;
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
import com.atomgraph.server.exception.OntologyException;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.PreMatching;
import org.apache.jena.ontapi.OntModelFactory;
import org.apache.jena.ontapi.OntSpecification;
import org.apache.jena.ontapi.UnionGraph;
import org.apache.jena.ontapi.model.OntModel;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
import org.apache.jena.vocabulary.OWL;
import org.apache.jena.vocabulary.RDF;
import org.apache.jena.vocabulary.RDFS;
import org.apache.jena.vocabulary.OWL;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -131,8 +131,9 @@ public OntModel getOntology(Application app)
}

/**
* Loads the ontology model for the specified ontology URI, building its owl:imports closure with
* RDFS inference and materializing the inferences into the repository cache.
* Returns the ontology model for the specified ontology URI, assembling its owl:imports closure
* on a cache miss. The returned model is a fresh per-request wrapper over the shared closure
* union graph.
*
* @param app application resource
* @param uri ontology URI
Expand All @@ -146,64 +147,54 @@ public OntModel getOntology(Application app, String uri)
final PrefixGraphRepository repository = app.canAs(EndUserApplication.class) ?
getSystem().getRepository(app.as(EndUserApplication.class)) : getSystem().getRepository();

// only build the materialized model if the ontology is not already cached; the double check under the
// repository lock ensures a single thread materializes it (loadOntology is a compound load + inference +
// put, not atomic), so concurrent cold requests don't duplicate the work or race each other's writes
if (!repository.isCached(uri))
// only assemble the closure if it is not already cached; the double check under the repository
// lock ensures a single thread assembles it (loadOntology is a compound load + union build, not
// atomic), so concurrent cold requests don't duplicate the work or race each other's writes
UnionGraph union = getSystem().getOntologyGraphs().get(uri);
if (union == null)
{
synchronized (repository)
{
if (!repository.isCached(uri)) loadOntology(repository, uri);
union = getSystem().getOntologyGraphs().get(uri);
if (union == null)
{
union = loadOntology(repository, uri);
getSystem().getOntologyGraphs().put(uri, union);
}
}
}

return OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM);
return OntModelFactory.createModel(union, OntSpecification.OWL2_FULL_MEM);
}

/**
* Builds and caches the materialized ontology model. Assembles the owl:imports closure into a single
* graph (so ontapi never manages a union-graph hierarchy over the shared repository), applies RDFS
* inference over the flattened closure, and materializes the inferences into the repository cache so
* the rules engine is not invoked on every request.
* Assembles the ontology's owl:imports closure as a union graph. ontapi resolves the closure through
* a scoped repository view: raw per-document graphs are read through (and cached in) the shared
* repository, while ontapi's union-graph bookkeeping stays local to the view — the shared repository
* keeps serving raw document graphs, and duplicate ontology IDs across applications cannot collide.
* No inference is applied: all consumers traverse class/property hierarchies explicitly.
*
* @param repository graph repository
* @param uri ontology URI
* @return closure union graph
*/
public static void loadOntology(PrefixGraphRepository repository, String uri)
public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri)
{
if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri);
Model union = ModelFactory.createDefaultModel();
Set<String> closure = new HashSet<>();
loadClosure(repository, uri, union, closure);
OntModel inferred = OntModelFactory.createModel(union.getGraph(), OntSpecification.OWL2_FULL_MEM_RDFS_INF);
OntModel materialized = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM);
materialized.add(inferred);
// promote rdfs:Class to owl:Class so OWL2 profiles recognise third-party vocab terms (e.g. sp:Describe in sp.ttl)
inferred.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> materialized.add(r, RDF.type, OWL.Class));
repository.put(uri, materialized.getGraph());
// cache imported graphs under their fragment-stripped document URIs too
closure.stream().filter(closureURI -> !closureURI.equals(uri)).forEach(importURI -> addDocumentModel(repository, importURI));
ScopedGraphRepository scoped = new ScopedGraphRepository(repository);
OntModel ontology = OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM, scoped);
UnionGraph union = (UnionGraph)ontology.getGraph();
// promote rdfs:Class to owl:Class so the OWL2 profile recognises third-party vocab terms (e.g. sp:Describe
// in sp.ttl) as named classes. The promotions live in their own union member so no document graph is
// polluted; carrying no owl:Ontology header, the member is ignored by ontapi's union-graph listener
Model promotions = ModelFactory.createDefaultModel();
ontology.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> promotions.add(r, RDF.type, OWL.Class));
if (!promotions.isEmpty()) union.addSubGraph(promotions.getGraph());
// cache closure graphs under their fragment-stripped document URIs too
scoped.ids().filter(closureURI -> closureURI.startsWith("http://") || closureURI.startsWith("https://")).
forEach(closureURI -> addDocumentModel(repository, closureURI));
if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}'", uri);
}

/**
* Recursively loads the transitive owl:imports closure of an ontology into a single union model,
* fetching each graph via the repository (SPARQL-first / bundled mappings).
*
* @param repository graph repository
* @param uri ontology URI
* @param union accumulator model
* @param seen accumulator of visited URIs (prevents cycles)
*/
public static void loadClosure(PrefixGraphRepository repository, String uri, Model union, Set<String> seen)
{
if (!seen.add(uri)) return;
Model model = ModelFactory.createModelForGraph(repository.get(uri));
union.add(model);
model.listObjectsOfProperty(OWL.imports).toList().forEach(imp ->
{
if (imp.isURIResource()) loadClosure(repository, imp.asResource().getURI(), union, seen);
});
return union;
}

/**
Expand Down
Loading
Loading