Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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: <principal-WebID>` 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 (`<subdomain>`) with an admin app at the **`admin.` prefix** (`admin.<subdomain>`) — 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.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
- Documented the pinned-truststore invariant behind the disabled hostname verification on internal HTTP clients

### 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
- Loopback/wildcard `URLValidator` tests; JWKS-based `JWTVerifier` tests (valid, wrong issuer, wrong audience, expired, missing `kid`, bad signature)
- `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
- 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
Expand Down
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions platform/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/atomgraph/linkeddatahub/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,9 @@ public class Application extends ResourceConfig
private final KeyStore keyStore, trustStore;
private final URI secretaryWebIDURI;
private final List<Locale> supportedLanguages;
private final ExpiringMap<URI, Model> webIDmodelCache = ExpiringMap.builder().expiration(1, TimeUnit.DAYS).build(); // TO-DO: config for the expiration period?
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(1, TimeUnit.DAYS).build(); // Cache JWKS responses
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
private final Map<URI, XsltExecutable> xsltExecutableCache = new ConcurrentHashMap<>();
private final MessageDigest messageDigest;
private final boolean enableWebIDSignUp;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright 2026 Martynas Jusevičius <martynas@atomgraph.com>.
*
* 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 <martynas@atomgraph.com>}
*/
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));
}

}
Loading