Skip to content

Commit 8a56f16

Browse files
authored
Near-term wins: cache TTL config, Dependabot, AGENTS.md, AuthorizationFilter tests (#321)
* 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. * 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. * Add AGENTS.md HTTP API guide for agents 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. * 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.
1 parent b5bd857 commit 8a56f16

7 files changed

Lines changed: 244 additions & 2 deletions

File tree

.github/dependabot.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
version: 2
2+
updates:
3+
# Maven dependencies (pom.xml)
4+
- package-ecosystem: "maven"
5+
directory: "/"
6+
schedule:
7+
interval: "weekly"
8+
open-pull-requests-limit: 10
9+
groups:
10+
# collapse routine minor/patch bumps into a single PR to cut noise; majors stay separate
11+
minor-and-patch:
12+
update-types:
13+
- "minor"
14+
- "patch"
15+
16+
# Base image in the Dockerfile
17+
- package-ecosystem: "docker"
18+
directory: "/"
19+
schedule:
20+
interval: "weekly"
21+
22+
# GitHub Actions used by the workflows
23+
- package-ecosystem: "github-actions"
24+
directory: "/"
25+
schedule:
26+
interval: "weekly"

AGENTS.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# LinkedDataHub — Agent Guide
2+
3+
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).
4+
5+
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.
6+
7+
## Data model
8+
9+
- The content is a **hierarchy of documents** (containers and items). A container holds child documents; items are leaves.
10+
- **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/).
11+
- Identifiers are opaque URLs. Do not parse structure out of them; follow links (hypermedia) instead.
12+
13+
## Authentication
14+
15+
- **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).
16+
- **OAuth2 (Google)** and **OpenID Connect (ORCID)** are available for human logins.
17+
- **Delegation**: an authorized secretary agent can act for a principal via the `On-Behalf-Of: <principal-WebID>` request header.
18+
- 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.
19+
20+
## Reading data
21+
22+
`GET` a document URL with content negotiation:
23+
24+
- `Accept: text/turtle` · `application/rdf+xml` · `application/ld+json` · `application/n-triples` (any RDF serialization Jena supports) → the document's RDF.
25+
- `Accept: text/html` → the application shell (Saxon-JS then renders client-side). Request RDF, not HTML, when you want data.
26+
27+
## Writing data (the discipline)
28+
29+
Writes go through the **document URLs**, never through the SPARQL endpoint (which is read-only):
30+
31+
| Intent | Method | Body | Notes |
32+
|--------|--------|------|-------|
33+
| 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` |
34+
| Create or replace a document at a known URL | `PUT` document URL | RDF | Replaces the whole named graph |
35+
| Update a document in place | `PATCH` document URL | `Content-Type: application/sparql-update` | A SPARQL Update (`INSERT`/`DELETE`) applied to that named graph |
36+
| Delete a document | `DELETE` document URL || Removes the named graph |
37+
38+
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.
39+
40+
## Querying (read-only)
41+
42+
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).
43+
44+
Write portable, standard SPARQL: use explicit `GRAPH` patterns, no engine-specific extensions.
45+
46+
## Content & document model
47+
48+
- 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`.
49+
- **Views** (`ldh:View`) are SPARQL-driven blocks (`SELECT`/`CONSTRUCT`/`DESCRIBE`) rendered as lists, tables, grids, charts, maps, or a graph.
50+
- Forms and validation are ontology-driven (SPIN constructors + SHACL shapes), so instance data is shaped by the app's ontology rather than hardcoded schemas.
51+
52+
## Dataspaces
53+
54+
A single instance hosts multiple **dataspaces**, each a subdomain (origin). Each dataspace pairs an end-user app (`<subdomain>`) with an admin app at the **`admin.` prefix** (`admin.<subdomain>`) — never an `/admin` path. Admin apps manage ontologies, ACLs, and app settings.
55+
56+
## Tooling
57+
58+
- **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.
59+
- **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.
60+
61+
## Standards
62+
63+
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.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
- Documented the pinned-truststore invariant behind the disabled hostname verification on internal HTTP clients
77

88
### Added
9+
- 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
910
- Loopback/wildcard `URLValidator` tests; JWKS-based `JWTVerifier` tests (valid, wrong issuer, wrong audience, expired, missing `kid`, bad signature)
11+
- `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
12+
- Dependabot config (`.github/dependabot.yml`) for Maven, the Docker base image, and GitHub Actions updates; routine Maven minor/patch bumps grouped into one PR
13+
14+
### Changed
15+
- 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
1016

1117
## [5.6.0] - 2026-07-08
1218
### Added

Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ ENV CLIENT_CONNECTION_TIME_TO_LIVE=300000
117117

118118
ENV CLIENT_VALIDATE_AFTER_INACTIVITY=10000
119119

120+
ENV WEBID_CACHE_EXPIRATION=86400
121+
122+
ENV JWKS_CACHE_EXPIRATION=86400
123+
120124
ENV IMPORT_KEEPALIVE=
121125

122126
ENV MAX_IMPORT_THREADS=10

platform/entrypoint.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,14 @@ if [ -n "$CLIENT_VALIDATE_AFTER_INACTIVITY" ]; then
10921092
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.validateAfterInactivity=$CLIENT_VALIDATE_AFTER_INACTIVITY"
10931093
fi
10941094

1095+
if [ -n "$WEBID_CACHE_EXPIRATION" ]; then
1096+
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.webIDCacheExpiration=$WEBID_CACHE_EXPIRATION"
1097+
fi
1098+
1099+
if [ -n "$JWKS_CACHE_EXPIRATION" ]; then
1100+
export CATALINA_OPTS="$CATALINA_OPTS -Dcom.atomgraph.linkeddatahub.jwksCacheExpiration=$JWKS_CACHE_EXPIRATION"
1101+
fi
1102+
10951103
if [ -n "$MAX_CONTENT_LENGTH" ]; then
10961104
MAX_CONTENT_LENGTH_PARAM="--stringparam ldhc:maxContentLength '$MAX_CONTENT_LENGTH' "
10971105
fi

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,9 @@ public class Application extends ResourceConfig
288288
private final KeyStore keyStore, trustStore;
289289
private final URI secretaryWebIDURI;
290290
private final List<Locale> supportedLanguages;
291-
private final ExpiringMap<URI, Model> webIDmodelCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // TO-DO: config for the expiration period?
291+
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
292292
private final ExpiringMap<String, Model> oidcModelCache = ExpiringMap.builder().variableExpiration().build();
293-
private final ExpiringMap<String, jakarta.json.JsonObject> jwksCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // Cache JWKS responses
293+
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
294294
private final Map<URI, XsltExecutable> xsltExecutableCache = new ConcurrentHashMap<>();
295295
private final MessageDigest messageDigest;
296296
private final boolean enableWebIDSignUp;
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2026 Martynas Jusevičius <martynas@atomgraph.com>.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.atomgraph.linkeddatahub.server.filter.request;
17+
18+
import com.atomgraph.linkeddatahub.vocabulary.ACL;
19+
import com.atomgraph.linkeddatahub.vocabulary.LACL;
20+
import jakarta.ws.rs.HttpMethod;
21+
import org.apache.jena.rdf.model.Model;
22+
import org.apache.jena.rdf.model.ModelFactory;
23+
import org.apache.jena.rdf.model.Resource;
24+
import org.apache.jena.rdf.model.ResourceFactory;
25+
import org.apache.jena.vocabulary.RDF;
26+
import org.junit.jupiter.api.Test;
27+
import static org.junit.jupiter.api.Assertions.assertEquals;
28+
import static org.junit.jupiter.api.Assertions.assertNull;
29+
import static org.junit.jupiter.api.Assertions.assertThrows;
30+
import static org.junit.jupiter.api.Assertions.assertTrue;
31+
32+
/**
33+
* Unit tests for the pure authorization logic in {@link AuthorizationFilter}:
34+
* the HTTP-method-to-access-mode contract, mode lookup, and the owner grant.
35+
* These methods do not touch the injected collaborators, so the filter is exercised directly.
36+
*
37+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
38+
*/
39+
public class AuthorizationFilterTest
40+
{
41+
42+
private static final Resource ACCESS_TO = ResourceFactory.createResource("https://localhost/doc/");
43+
private static final Resource AGENT = ResourceFactory.createResource("https://localhost/acl/agents/me/#this");
44+
45+
// HTTP method -> ACL access mode: a security-critical contract (a regression here silently changes what each verb requires)
46+
47+
@Test
48+
public void testReadMethodsRequireRead()
49+
{
50+
assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.GET));
51+
assertEquals(ACL.Read, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.HEAD));
52+
}
53+
54+
@Test
55+
public void testPostRequiresAppend()
56+
{
57+
assertEquals(ACL.Append, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.POST));
58+
}
59+
60+
@Test
61+
public void testWriteMethodsRequireWrite()
62+
{
63+
assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PUT));
64+
assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.DELETE));
65+
assertEquals(ACL.Write, AuthorizationFilter.ACCESS_MODES.get(HttpMethod.PATCH));
66+
}
67+
68+
@Test
69+
public void testUnknownMethodHasNoAccessMode()
70+
{
71+
assertNull(AuthorizationFilter.ACCESS_MODES.get(HttpMethod.OPTIONS));
72+
}
73+
74+
// getAuthorizationByMode: find an authorization in the model that grants the requested mode
75+
76+
@Test
77+
public void testFindsAuthorizationGrantingMode()
78+
{
79+
Model model = ModelFactory.createDefaultModel();
80+
Resource auth = model.createResource("https://localhost/acl/authorizations/1/#this").
81+
addProperty(RDF.type, ACL.Authorization).
82+
addProperty(ACL.mode, ACL.Read).
83+
addProperty(ACL.mode, ACL.Append);
84+
85+
assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Read));
86+
assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Append));
87+
}
88+
89+
@Test
90+
public void testReturnsNullWhenNoAuthorizationGrantsMode()
91+
{
92+
Model model = ModelFactory.createDefaultModel();
93+
model.createResource("https://localhost/acl/authorizations/1/#this").
94+
addProperty(RDF.type, ACL.Authorization).
95+
addProperty(ACL.mode, ACL.Read);
96+
97+
assertNull(new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write));
98+
}
99+
100+
// createOwnerAuthorization: owner is granted Read/Write/Append on the document
101+
102+
@Test
103+
public void testOwnerAuthorizationGrantsReadWriteAppend()
104+
{
105+
Model model = ModelFactory.createDefaultModel();
106+
Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT);
107+
108+
assertTrue(auth.hasProperty(RDF.type, ACL.Authorization));
109+
assertTrue(auth.hasProperty(RDF.type, LACL.OwnerAuthorization));
110+
assertTrue(auth.hasProperty(ACL.accessTo, ACCESS_TO));
111+
assertTrue(auth.hasProperty(ACL.agent, AGENT));
112+
assertTrue(auth.hasProperty(ACL.mode, ACL.Read));
113+
assertTrue(auth.hasProperty(ACL.mode, ACL.Write));
114+
assertTrue(auth.hasProperty(ACL.mode, ACL.Append));
115+
}
116+
117+
@Test
118+
public void testOwnerAuthorizationIsDiscoverableByMode()
119+
{
120+
Model model = ModelFactory.createDefaultModel();
121+
Resource auth = new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, AGENT);
122+
123+
assertEquals(auth, new AuthorizationFilter().getAuthorizationByMode(model, ACL.Write));
124+
}
125+
126+
@Test
127+
public void testCreateOwnerAuthorizationRejectsNullArguments()
128+
{
129+
Model model = ModelFactory.createDefaultModel();
130+
assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(null, ACCESS_TO, AGENT));
131+
assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, null, AGENT));
132+
assertThrows(IllegalArgumentException.class, () -> new AuthorizationFilter().createOwnerAuthorization(model, ACCESS_TO, null));
133+
}
134+
135+
}

0 commit comments

Comments
 (0)