From 19a5e073264e40c5d177eb7416c961787047c9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 17:31:57 +0300 Subject: [PATCH 1/4] Make WebID and JWKS cache TTLs configurable The WebID model cache and JWKS cache had a hardcoded 1-day expiration (the TO-DO on the webIDmodelCache field). Read the TTL (seconds) from the com.atomgraph.linkeddatahub.{webIDCacheExpiration,jwksCacheExpiration} system properties, wired from WEBID_CACHE_EXPIRATION / JWKS_CACHE_EXPIRATION env vars via CATALINA_OPTS in the entrypoint, mirroring the CLIENT_* timeout mechanism. Default stays 86400 (1 day), so behaviour is unchanged; lowering the WebID TTL bounds how long a revoked WebID stays authenticated. --- CHANGELOG.md | 4 ++++ Dockerfile | 4 ++++ platform/entrypoint.sh | 8 ++++++++ .../java/com/atomgraph/linkeddatahub/Application.java | 4 ++-- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d82acc00c..7042684d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [Unreleased] +### Changed +- Cache TTLs configurable: the WebID model cache and the JWKS cache now read their expiration (seconds) from `WEBID_CACHE_EXPIRATION` / `JWKS_CACHE_EXPIRATION` (default 86400 = 1 day), via `CATALINA_OPTS` system properties like the `CLIENT_*` timeouts. Lowering `WEBID_CACHE_EXPIRATION` bounds how long a revoked WebID stays authenticated + ## [5.6.0] - 2026-07-08 ### Added - `OntologyRepository` (renamed from `OntologyModelGetter`): a bounded, evicting ontology cache that serves bundled vocabularies without querying SPARQL; per-app creation is thread-safe and each ontology is materialized once under a lock (`owl:imports` closure flattened manually, then RDFS-inferred and materialized). Seeded ad-hoc in `Namespace` diff --git a/Dockerfile b/Dockerfile index afcdb0e8d..98243907d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,6 +117,10 @@ ENV CLIENT_CONNECTION_TIME_TO_LIVE=300000 ENV CLIENT_VALIDATE_AFTER_INACTIVITY=10000 +ENV WEBID_CACHE_EXPIRATION=86400 + +ENV JWKS_CACHE_EXPIRATION=86400 + ENV IMPORT_KEEPALIVE= ENV MAX_IMPORT_THREADS=10 diff --git a/platform/entrypoint.sh b/platform/entrypoint.sh index d5c81dd76..1a8915ad6 100755 --- a/platform/entrypoint.sh +++ b/platform/entrypoint.sh @@ -1092,6 +1092,14 @@ if [ -n "$CLIENT_VALIDATE_AFTER_INACTIVITY" ]; then export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.validateAfterInactivity=$CLIENT_VALIDATE_AFTER_INACTIVITY" fi +if [ -n "$WEBID_CACHE_EXPIRATION" ]; then + export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.webIDCacheExpiration=$WEBID_CACHE_EXPIRATION" +fi + +if [ -n "$JWKS_CACHE_EXPIRATION" ]; then + export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.jwksCacheExpiration=$JWKS_CACHE_EXPIRATION" +fi + if [ -n "$MAX_CONTENT_LENGTH" ]; then MAX_CONTENT_LENGTH_PARAM="--stringparam ldhc:maxContentLength '$MAX_CONTENT_LENGTH' " fi diff --git a/src/main/java/com/atomgraph/linkeddatahub/Application.java b/src/main/java/com/atomgraph/linkeddatahub/Application.java index 2d429ce4f..981609522 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/Application.java +++ b/src/main/java/com/atomgraph/linkeddatahub/Application.java @@ -288,9 +288,9 @@ public class Application extends ResourceConfig private final KeyStore keyStore, trustStore; private final URI secretaryWebIDURI; private final List supportedLanguages; - private final ExpiringMap webIDmodelCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // TO-DO: config for the expiration period? + private final ExpiringMap 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 oidcModelCache = ExpiringMap.builder().variableExpiration().build(); - private final ExpiringMap jwksCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // Cache JWKS responses + private final ExpiringMap 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 private final Map xsltExecutableCache = new ConcurrentHashMap<>(); private final MessageDigest messageDigest; private final boolean enableWebIDSignUp; From 1a774250aca3dea963d69abfd2d5c989b5e0669f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 17:33:54 +0300 Subject: [PATCH 2/4] Add Dependabot config Weekly update PRs for Maven dependencies (grouping routine minor/patch bumps), the Docker base image, and GitHub Actions. The client 4.3.0 -> 5.x drift went unnoticed for a full major version; automating this prevents a recurrence. --- .github/dependabot.yml | 26 ++++++++++++++++++++++++++ CHANGELOG.md | 3 +++ 2 files changed, 29 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..c1c05e267 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + # Maven dependencies (pom.xml) + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + # collapse routine minor/patch bumps into a single PR to cut noise; majors stay separate + minor-and-patch: + update-types: + - "minor" + - "patch" + + # Base image in the Dockerfile + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + + # GitHub Actions used by the workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7042684d4..99e0fe744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ ## [Unreleased] +### Added +- Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR + ### Changed - Cache TTLs configurable: the WebID model cache and the JWKS cache now read their expiration (seconds) from `WEBID_CACHE_EXPIRATION` / `JWKS_CACHE_EXPIRATION` (default 86400 = 1 day), via `CATALINA_OPTS` system properties like the `CLIENT_*` timeouts. Lowering `WEBID_CACHE_EXPIRATION` bounds how long a revoked WebID stays authenticated From dce69d9811c57d7e7f3a91fe4ab9c78c3ef4728e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Sun, 12 Jul 2026 17:34:52 +0300 Subject: [PATCH 3/4] Add AGENTS.md HTTP API guide for agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A machine-readable capability manifest for LLM/HTTP agents driving a running LinkedDataHub instance: the document-as-named-graph model, WebID-TLS auth, the write discipline (POST/PUT to create, PATCH with application/sparql-update to update, DELETE to remove — never the read-only SPARQL endpoint), the content and dataspace model, and the bin/ + Web-Algebra tooling. Mirrors the per-service AGENTS.md convention REST-VKG already serves. --- AGENTS.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + 2 files changed, 64 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5305a8558 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# LinkedDataHub — Agent Guide + +This document describes how an autonomous agent (or any HTTP/LLM client) drives a **running LinkedDataHub (LDH) instance's HTTP API**. It is the API-usage counterpart to `CLAUDE.md` (which is for contributing to the codebase). + +LinkedDataHub is a data-driven Knowledge Graph platform. Everything — documents, applications, access control, the UI — is RDF, managed over a small, uniform HTTP API and standard protocols. There is no bespoke REST surface to learn: you work with RDF documents and SPARQL. + +## Data model + +- The content is a **hierarchy of documents** (containers and items). A container holds child documents; items are leaves. +- **Every document URL is a named graph.** Reading a document returns the RDF in that graph; writing changes it. This is the [SPARQL 1.1 Graph Store Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/). +- Identifiers are opaque URLs. Do not parse structure out of them; follow links (hypermedia) instead. + +## Authentication + +- **WebID-TLS** (client certificate) is the primary mechanism for programmatic agents. Every request carries the cert; the certificate's WebID is the agent identity. With `curl`: `-E cert.pem:password` (`-k` in dev with self-signed certs). +- **OAuth2 (Google)** and **OpenID Connect (ORCID)** are available for human logins. +- **Delegation**: an authorized secretary agent can act for a principal via the `On-Behalf-Of: ` request header. +- Authorization is WebID-based ACLs (`acl:Read`/`Append`/`Write`/`Control`), enforced per document. A response's `Link` headers advertise the modes the current agent holds on that resource. + +## Reading data + +`GET` a document URL with content negotiation: + +- `Accept: text/turtle` · `application/rdf+xml` · `application/ld+json` · `application/n-triples` (any RDF serialization Jena supports) → the document's RDF. +- `Accept: text/html` → the application shell (Saxon-JS then renders client-side). Request RDF, not HTML, when you want data. + +## Writing data (the discipline) + +Writes go through the **document URLs**, never through the SPARQL endpoint (which is read-only): + +| Intent | Method | Body | Notes | +|--------|--------|------|-------| +| Create a child in a container | `POST` container URL | RDF (e.g. `Content-Type: text/turtle`) | Server mints the child URL and returns it in `Location` | +| Create or replace a document at a known URL | `PUT` document URL | RDF | Replaces the whole named graph | +| Update a document in place | `PATCH` document URL | `Content-Type: application/sparql-update` | A SPARQL Update (`INSERT`/`DELETE`) applied to that named graph | +| Delete a document | `DELETE` document URL | — | Removes the named graph | + +Relative URIs in a request body resolve against the target URL. See `bin/post.sh`, `bin/put.sh`, `bin/patch.sh`, `bin/delete.sh` for exact, working invocations. + +## Querying (read-only) + +The dataspace exposes a **read-only SPARQL 1.1 Query** endpoint (advertised via the Service Description `sd:endpoint`; conventionally `/sparql`). `GET`/`POST` a `SELECT`/`CONSTRUCT`/`DESCRIBE`/`ASK`; results are content-negotiated. The endpoint does **not** accept SPARQL Update — mutate via `PATCH` on document URLs (above). + +Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions. + +## Content & document model + +- Documents carry ordered **content blocks**. Only `ldh:Object` (an embedded RDF resource view) and `ldh:XHTML` (rich text) are permitted as block values; anything else must be wrapped in an `ldh:Object`. +- **Views** (`ldh:View`) are SPARQL-driven blocks (`SELECT`/`CONSTRUCT`/`DESCRIBE`) rendered as lists, tables, grids, charts, maps, or a graph. +- Forms and validation are ontology-driven (SPIN constructors + SHACL shapes), so instance data is shaped by the app's ontology rather than hardcoded schemas. + +## Dataspaces + +A single instance hosts multiple **dataspaces**, each a subdomain (origin). Each dataspace pairs an end-user app (``) with an admin app at the **`admin.` prefix** (`admin.`) — never an `/admin` path. Admin apps manage ontologies, ACLs, and app settings. + +## Tooling + +- **CLI**: the `bin/` scripts wrap every operation above (`get.sh`, `post.sh`, `put.sh`, `patch.sh`, `delete.sh`, `create-container.sh`, `create-item.sh`, `add-view.sh`, `add-select.sh`, `add-construct.sh`, `add-result-set-chart.sh`, `add-file.sh`, `webid-keygen.sh`). They are the authoritative reference for request shapes. +- **Programmatic / MCP**: [Web-Algebra](https://github.com/AtomGraph/Web-Algebra) is the recommended path for agent-composed workflows — a JSON DSL and MCP server whose operations (create container/item, add view/chart, generate portal, …) compose multi-step LDH writes atomically under WebID auth. + +## Standards + +WebID-TLS · SPARQL 1.1 Query & Update · Graph Store Protocol · Linked Data Templates · SHACL · SPIN · RDF (Turtle/RDF-XML/JSON-LD/N-Triples). LDH composes existing W3C/IETF standards; it does not define new wire protocols. diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e0fe744..6e75ca4ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] ### Added +- `AGENTS.md`: an agent-facing guide to driving a running instance's HTTP API — data model, WebID auth, read/write discipline (writes via `POST`/`PUT`/`PATCH` on document URLs; read-only SPARQL), content model, dataspaces, tooling - Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR ### Changed From fdb817d61280b96a260dd519d072ece65e4cbd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martynas=20Jusevi=C4=8Dius?= Date: Mon, 13 Jul 2026 10:37:00 +0300 Subject: [PATCH 4/4] Add AuthorizationFilter unit tests Cover the pure decision logic that had no unit coverage: the HTTP-method to ACL access-mode contract (GET/HEAD->Read, POST->Append, PUT/DELETE/PATCH->Write), getAuthorizationByMode lookup, and createOwnerAuthorization granting the owner Read/Write/Append. No SPARQL or JAX-RS mocking needed. --- CHANGELOG.md | 1 + .../request/AuthorizationFilterTest.java | 135 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e75ca4ed..f81dffc9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] ### Added +- Unit tests for `AuthorizationFilter`: the HTTP-method → ACL access-mode contract (`GET`/`HEAD`→Read, `POST`→Append, `PUT`/`DELETE`/`PATCH`→Write), mode lookup, and the owner Read/Write/Append grant - `AGENTS.md`: an agent-facing guide to driving a running instance's HTTP API — data model, WebID auth, read/write discipline (writes via `POST`/`PUT`/`PATCH` on document URLs; read-only SPARQL), content model, dataspaces, tooling - Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java new file mode 100644 index 000000000..b4cb06ed1 --- /dev/null +++ b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/AuthorizationFilterTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Martynas Jusevičius . + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.atomgraph.linkeddatahub.server.filter.request; + +import com.atomgraph.linkeddatahub.vocabulary.ACL; +import com.atomgraph.linkeddatahub.vocabulary.LACL; +import jakarta.ws.rs.HttpMethod; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.ResourceFactory; +import org.apache.jena.vocabulary.RDF; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for the pure authorization logic in {@link AuthorizationFilter}: + * the HTTP-method-to-access-mode contract, mode lookup, and the owner grant. + * These methods do not touch the injected collaborators, so the filter is exercised directly. + * + * @author Martynas Jusevičius {@literal } + */ +public class AuthorizationFilterTest +{ + + private static final Resource ACCESS_TO = ResourceFactory.createResource("https://localhost/doc/"); + private static final Resource AGENT = ResourceFactory.createResource("https://localhost/acl/agents/me/#this"); + + // HTTP method -> ACL access mode: a security-critical contract (a regression here silently changes what each verb requires) + + @Test + public void testReadMethodsRequireRead() + { + assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.GET)); + assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.HEAD)); + } + + @Test + public void testPostRequiresAppend() + { + assertEquals(ACL.Append, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.POST)); + } + + @Test + public void testWriteMethodsRequireWrite() + { + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PUT)); + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.DELETE)); + assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PATCH)); + } + + @Test + public void testUnknownMethodHasNoAccessMode() + { + assertNull(AuthorizationFilter.ACCESS_MODES.get(HttpMethod.OPTIONS)); + } + + // getAuthorizationByMode: find an authorization in the model that grants the requested mode + + @Test + public void testFindsAuthorizationGrantingMode() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = model.createResource("https://localhost/acl/authorizations/1/#this"). + addProperty(RDF.type, ACL.Authorization). + addProperty(ACL.mode, ACL.Read). + addProperty(ACL.mode, ACL.Append); + + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Read)); + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Append)); + } + + @Test + public void testReturnsNullWhenNoAuthorizationGrantsMode() + { + Model model = ModelFactory.createDefaultModel(); + model.createResource("https://localhost/acl/authorizations/1/#this"). + addProperty(RDF.type, ACL.Authorization). + addProperty(ACL.mode, ACL.Read); + + assertNull(new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write)); + } + + // createOwnerAuthorization: owner is granted Read/Write/Append on the document + + @Test + public void testOwnerAuthorizationGrantsReadWriteAppend() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT); + + assertTrue(auth.hasProperty(RDF.type, ACL.Authorization)); + assertTrue(auth.hasProperty(RDF.type, LACL.OwnerAuthorization)); + assertTrue(auth.hasProperty(ACL.accessTo, ACCESS_TO)); + assertTrue(auth.hasProperty(ACL.agent, AGENT)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Read)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Write)); + assertTrue(auth.hasProperty(ACL.mode, ACL.Append)); + } + + @Test + public void testOwnerAuthorizationIsDiscoverableByMode() + { + Model model = ModelFactory.createDefaultModel(); + Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT); + + assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write)); + } + + @Test + public void testCreateOwnerAuthorizationRejectsNullArguments() + { + Model model = ModelFactory.createDefaultModel(); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(null, ACCESS_TO, AGENT)); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, null, AGENT)); + assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, null)); + } + +}