Skip to content

Commit 7327548

Browse files
namedgraphclaude
andcommitted
Serve raw ontology graphs without RDFS inference, token-safe @typeof matching
Resolve the application ontology's owl:imports closure natively via ontapi (OntModelFactory.createModel over a ScopedGraphRepository view) instead of manually flattening the closure and materializing an RDFS-inferred model. No inference is applied anymore — every consumer (constructor/constraint inheritance, client-side (rdfs:subClassOf)* queries) traverses hierarchies explicitly. 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 shared repository now only ever holds raw per-document graphs, which ProxyRequestFilter serves directly for closure documents — asserted triples only, identical to a direct document GET. The DESCRIBE fallback over the in-memory closure (terms minted in external namespaces) is inference-free as well. This fixes inferred rdf:type rdfs:Resource leaking into proxied namespace documents, where the extra type produced multi-token @typeof and silently degraded View blocks to a generic property list. Client-side, all exact-equality @typeof comparisons switch to token-aware tokenize(@typeof, ' '), per RDFa's whitespace-separated list semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 20c8277 commit 7327548

16 files changed

Lines changed: 318 additions & 112 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
## [5.6.2] - 2026-07-27
2+
### Changed
3+
- 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
4+
- 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
5+
- `Namespace` no-query GET serves the raw ontology graph from the shared repository instead of building a fresh `createRepository` per request
6+
7+
### Fixed
8+
- 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
9+
- 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)
10+
- 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`
11+
112
## [5.6.1] - 2026-07-26
213
### Security
314
- 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)

http-tests/proxy/GET-proxied-ontology-ns.sh

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ clear-ontology.sh \
4949
--ontology "$namespace"
5050

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

5656
response=$(curl -k -f -s \
5757
-G \
@@ -60,7 +60,24 @@ response=$(curl -k -f -s \
6060
--data-urlencode "uri=${namespace_uri}" \
6161
"$END_USER_BASE_URL")
6262

63-
# verify both class descriptions are present in the response
63+
# verify both class descriptions are present in the response and no inferred triples leak
6464

6565
echo "$response" | grep -q "$class1"
6666
echo "$response" | grep -q "$class2"
67+
! echo "$response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource"
68+
69+
# request the ontology document itself via ?uri= proxy: it is a graph in the ontology closure,
70+
# so it must be served with its raw graph — asserted triples only, identical to a direct
71+
# document GET. Inferred rdf:type rdfs:Resource used to leak from the RDFS-materialized
72+
# in-memory model here, breaking client-side @typeof matching of View blocks.
73+
74+
doc_response=$(curl -k -f -s \
75+
-G \
76+
-E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \
77+
-H "Accept: application/n-triples" \
78+
--data-urlencode "uri=${ontology_doc}" \
79+
"$END_USER_BASE_URL")
80+
81+
echo "$doc_response" | grep -q "$class1"
82+
echo "$doc_response" | grep -q "$class2"
83+
! echo "$doc_response" | grep -q "http://www.w3.org/2000/01/rdf-schema#Resource"

src/main/java/com/atomgraph/linkeddatahub/Application.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
import javax.net.ssl.TrustManagerFactory;
162162
import jakarta.servlet.ServletContext;
163163
import javax.xml.transform.Source;
164+
import org.apache.jena.ontapi.UnionGraph;
164165
import org.apache.jena.ontapi.model.OntModel;
165166
import org.apache.jena.query.Dataset;
166167
import org.apache.jena.query.Query;
@@ -199,6 +200,7 @@
199200
import javax.xml.parsers.ParserConfigurationException;
200201
import javax.xml.transform.TransformerException;
201202
import javax.xml.transform.stream.StreamSource;
203+
import net.jodah.expiringmap.ExpirationPolicy;
202204
import net.jodah.expiringmap.ExpiringMap;
203205
import net.sf.saxon.om.TreeInfo;
204206
import net.sf.saxon.s9api.Processor;
@@ -288,6 +290,7 @@ public class Application extends ResourceConfig
288290
private final KeyStore keyStore, trustStore;
289291
private final URI secretaryWebIDURI;
290292
private final List<Locale> supportedLanguages;
293+
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
291294
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
292295
private final ExpiringMap<String, Model> oidcModelCache = ExpiringMap.builder().variableExpiration().build();
293296
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
@@ -1804,10 +1807,23 @@ public OntologyRepository createRepository(EndUserApplication app)
18041807

18051808
return appRepository;
18061809
}
1807-
1810+
1811+
/**
1812+
* Returns the cache of assembled ontology imports-closure union graphs, keyed by ontology URI
1813+
* (origin-scoped per dataspace, so a single map cannot collide across applications).
1814+
* The union graph is ontapi's view over the raw per-document graphs cached in the (per-app or
1815+
* system) repository; it is not a document graph itself and is never served on the wire.
1816+
*
1817+
* @return ontology URI to union graph map
1818+
*/
1819+
public Map<String, UnionGraph> getOntologyGraphs()
1820+
{
1821+
return ontologyGraphs;
1822+
}
1823+
18081824
/**
18091825
* Returns a registry of readable and writeable media types.
1810-
*
1826+
*
18111827
* @return registry object
18121828
*/
18131829
public MediaTypes getMediaTypes()

src/main/java/com/atomgraph/linkeddatahub/resource/Namespace.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,9 @@ public Response get(@QueryParam(QUERY) Query query,
140140
// the application ontology MUST use a <ns> URI! This is the URI this ontology endpoint is deployed on by the Dispatcher class
141141
String ontologyURI = getApplication().getOntology().getURI();
142142
if (log.isDebugEnabled()) log.debug("Returning raw namespace ontology: {}", ontologyURI);
143-
// not returning the injected in-memory ontology because it has inferences applied to it;
144-
// a fresh, mapping-seeded repository serves the raw SPARQL-loaded ontology
145-
OntologyRepository repository = getSystem().createRepository(getApplication().as(EndUserApplication.class));
143+
// not returning the injected in-memory ontology because it is the full imports closure (a union view);
144+
// the shared repository serves the standalone raw ontology graph
145+
OntologyRepository repository = getSystem().getRepository(getApplication().as(EndUserApplication.class));
146146
return getResponseBuilder(org.apache.jena.rdf.model.ModelFactory.createModelForGraph(repository.get(ontologyURI))).build();
147147
}
148148
else throw new BadRequestException("SPARQL query string not provided");

src/main/java/com/atomgraph/linkeddatahub/resource/admin/ClearOntology.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,14 @@ public Response post(@FormParam("uri") String ontologyURI, @HeaderParam("Referer
7878

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

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

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

116118
if (referer != null) return Response.seeOther(referer).build();

src/main/java/com/atomgraph/linkeddatahub/server/filter/request/OntologyFilter.java

Lines changed: 38 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,27 @@
1919
import com.atomgraph.linkeddatahub.apps.model.Application;
2020
import com.atomgraph.linkeddatahub.apps.model.EndUserApplication;
2121
import com.atomgraph.client.util.jena.PrefixGraphRepository;
22+
import com.atomgraph.linkeddatahub.server.util.ScopedGraphRepository;
2223
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
2324
import com.atomgraph.server.exception.OntologyException;
2425
import java.io.IOException;
2526
import java.net.URI;
2627
import java.net.URISyntaxException;
27-
import java.util.HashSet;
2828
import java.util.Optional;
29-
import java.util.Set;
3029
import jakarta.annotation.Priority;
3130
import jakarta.inject.Inject;
3231
import jakarta.ws.rs.container.ContainerRequestContext;
3332
import jakarta.ws.rs.container.ContainerRequestFilter;
3433
import jakarta.ws.rs.container.PreMatching;
3534
import org.apache.jena.ontapi.OntModelFactory;
3635
import org.apache.jena.ontapi.OntSpecification;
36+
import org.apache.jena.ontapi.UnionGraph;
3737
import org.apache.jena.ontapi.model.OntModel;
3838
import org.apache.jena.rdf.model.Model;
3939
import org.apache.jena.rdf.model.ModelFactory;
40-
import org.apache.jena.vocabulary.OWL;
4140
import org.apache.jena.vocabulary.RDF;
4241
import org.apache.jena.vocabulary.RDFS;
42+
import org.apache.jena.vocabulary.OWL;
4343
import org.slf4j.Logger;
4444
import org.slf4j.LoggerFactory;
4545

@@ -131,8 +131,9 @@ public OntModel getOntology(Application app)
131131
}
132132

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

149-
// only build the materialized model if the ontology is not already cached; the double check under the
150-
// repository lock ensures a single thread materializes it (loadOntology is a compound load + inference +
151-
// put, not atomic), so concurrent cold requests don't duplicate the work or race each other's writes
152-
if (!repository.isCached(uri))
150+
// only assemble the closure if it is not already cached; the double check under the repository
151+
// lock ensures a single thread assembles it (loadOntology is a compound load + union build, not
152+
// atomic), so concurrent cold requests don't duplicate the work or race each other's writes
153+
UnionGraph union = getSystem().getOntologyGraphs().get(uri);
154+
if (union == null)
153155
{
154156
synchronized (repository)
155157
{
156-
if (!repository.isCached(uri)) loadOntology(repository, uri);
158+
union = getSystem().getOntologyGraphs().get(uri);
159+
if (union == null)
160+
{
161+
union = loadOntology(repository, uri);
162+
getSystem().getOntologyGraphs().put(uri, union);
163+
}
157164
}
158165
}
159166

160-
return OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM);
167+
return OntModelFactory.createModel(union, OntSpecification.OWL2_FULL_MEM);
161168
}
162169

163170
/**
164-
* Builds and caches the materialized ontology model. Assembles the owl:imports closure into a single
165-
* graph (so ontapi never manages a union-graph hierarchy over the shared repository), applies RDFS
166-
* inference over the flattened closure, and materializes the inferences into the repository cache so
167-
* the rules engine is not invoked on every request.
171+
* Assembles the ontology's owl:imports closure as a union graph. ontapi resolves the closure through
172+
* a scoped repository view: raw per-document graphs are read through (and cached in) the shared
173+
* repository, while ontapi's union-graph bookkeeping stays local to the view — the shared repository
174+
* keeps serving raw document graphs, and duplicate ontology IDs across applications cannot collide.
175+
* No inference is applied: all consumers traverse class/property hierarchies explicitly.
168176
*
169177
* @param repository graph repository
170178
* @param uri ontology URI
179+
* @return closure union graph
171180
*/
172-
public static void loadOntology(PrefixGraphRepository repository, String uri)
181+
public static UnionGraph loadOntology(PrefixGraphRepository repository, String uri)
173182
{
174183
if (log.isDebugEnabled()) log.debug("Started loading ontology with URI '{}'", uri);
175-
Model union = ModelFactory.createDefaultModel();
176-
Set<String> closure = new HashSet<>();
177-
loadClosure(repository, uri, union, closure);
178-
OntModel inferred = OntModelFactory.createModel(union.getGraph(), OntSpecification.OWL2_FULL_MEM_RDFS_INF);
179-
OntModel materialized = OntModelFactory.createModel(OntSpecification.OWL2_FULL_MEM);
180-
materialized.add(inferred);
181-
// promote rdfs:Class to owl:Class so OWL2 profiles recognise third-party vocab terms (e.g. sp:Describe in sp.ttl)
182-
inferred.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> materialized.add(r, RDF.type, OWL.Class));
183-
repository.put(uri, materialized.getGraph());
184-
// cache imported graphs under their fragment-stripped document URIs too
185-
closure.stream().filter(closureURI -> !closureURI.equals(uri)).forEach(importURI -> addDocumentModel(repository, importURI));
184+
ScopedGraphRepository scoped = new ScopedGraphRepository(repository);
185+
OntModel ontology = OntModelFactory.createModel(repository.get(uri), OntSpecification.OWL2_FULL_MEM, scoped);
186+
UnionGraph union = (UnionGraph)ontology.getGraph();
187+
// promote rdfs:Class to owl:Class so the OWL2 profile recognises third-party vocab terms (e.g. sp:Describe
188+
// in sp.ttl) as named classes. The promotions live in their own union member so no document graph is
189+
// polluted; carrying no owl:Ontology header, the member is ignored by ontapi's union-graph listener
190+
Model promotions = ModelFactory.createDefaultModel();
191+
ontology.listSubjectsWithProperty(RDF.type, RDFS.Class).forEach(r -> promotions.add(r, RDF.type, OWL.Class));
192+
if (!promotions.isEmpty()) union.addSubGraph(promotions.getGraph());
193+
// cache closure graphs under their fragment-stripped document URIs too
194+
scoped.ids().filter(closureURI -> closureURI.startsWith("http://") || closureURI.startsWith("https://")).
195+
forEach(closureURI -> addDocumentModel(repository, closureURI));
186196
if (log.isDebugEnabled()) log.debug("Finished loading ontology with URI '{}'", uri);
187-
}
188-
189-
/**
190-
* Recursively loads the transitive owl:imports closure of an ontology into a single union model,
191-
* fetching each graph via the repository (SPARQL-first / bundled mappings).
192-
*
193-
* @param repository graph repository
194-
* @param uri ontology URI
195-
* @param union accumulator model
196-
* @param seen accumulator of visited URIs (prevents cycles)
197-
*/
198-
public static void loadClosure(PrefixGraphRepository repository, String uri, Model union, Set<String> seen)
199-
{
200-
if (!seen.add(uri)) return;
201-
Model model = ModelFactory.createModelForGraph(repository.get(uri));
202-
union.add(model);
203-
model.listObjectsOfProperty(OWL.imports).toList().forEach(imp ->
204-
{
205-
if (imp.isURIResource()) loadClosure(repository, imp.asResource().getURI(), union, seen);
206-
});
197+
return union;
207198
}
208199

209200
/**

0 commit comments

Comments
 (0)