Skip to content

Commit a917c56

Browse files
committed
Limited SPARQL update endpoint
Only allows batched `PATCH`-like graph-scoped updates
1 parent 21d2ab0 commit a917c56

File tree

6 files changed

+459
-2
lines changed

6 files changed

+459
-2
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL"
5+
initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL"
6+
purge_cache "$END_USER_VARNISH_SERVICE"
7+
purge_cache "$ADMIN_VARNISH_SERVICE"
8+
purge_cache "$FRONTEND_VARNISH_SERVICE"
9+
10+
# add agent to the writers group
11+
add-agent-to-group.sh \
12+
-f "$OWNER_CERT_FILE" \
13+
-p "$OWNER_CERT_PWD" \
14+
--agent "$AGENT_URI" \
15+
"${ADMIN_BASE_URL}acl/groups/writers/"
16+
17+
# create two test documents
18+
pushd . > /dev/null && cd "$SCRIPT_ROOT/admin/acl"
19+
20+
GRAPH1_URI="${END_USER_BASE_URL}test-graph-1/"
21+
GRAPH2_URI="${END_USER_BASE_URL}test-graph-2/"
22+
23+
# create first test graph
24+
./create-authorization.sh \
25+
-b "$END_USER_BASE_URL" \
26+
-f "$OWNER_CERT_FILE" \
27+
-p "$OWNER_CERT_PWD" \
28+
--label "Test Graph 1" \
29+
--uri "$GRAPH1_URI" \
30+
--agent "${ADMIN_BASE_URL}acl/agents/test/#this"
31+
32+
# create second test graph
33+
./create-authorization.sh \
34+
-b "$END_USER_BASE_URL" \
35+
-f "$OWNER_CERT_FILE" \
36+
-p "$OWNER_CERT_PWD" \
37+
--label "Test Graph 2" \
38+
--uri "$GRAPH2_URI" \
39+
--agent "${ADMIN_BASE_URL}acl/agents/test/#this"
40+
41+
popd > /dev/null
42+
43+
# add initial data to both graphs
44+
(
45+
curl -k -w "%{http_code}\n" -o /dev/null -f -s \
46+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
47+
-H "Content-Type: application/n-triples" \
48+
--data-binary @- \
49+
"$GRAPH1_URI" <<EOF
50+
<${GRAPH1_URI}> <http://purl.org/dc/terms/title> "Graph 1" .
51+
<${GRAPH1_URI}#item> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://xmlns.com/foaf/0.1/Document> .
52+
<${GRAPH1_URI}#item> <http://purl.org/dc/terms/title> "Item 1" .
53+
EOF
54+
) | grep -q "$STATUS_CREATED"
55+
56+
(
57+
curl -k -w "%{http_code}\n" -o /dev/null -f -s \
58+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
59+
-H "Content-Type: application/n-triples" \
60+
--data-binary @- \
61+
"$GRAPH2_URI" <<EOF
62+
<${GRAPH2_URI}> <http://purl.org/dc/terms/title> "Graph 2" .
63+
<${GRAPH2_URI}#item> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://xmlns.com/foaf/0.1/Document> .
64+
<${GRAPH2_URI}#item> <http://purl.org/dc/terms/title> "Item 2" .
65+
EOF
66+
) | grep -q "$STATUS_CREATED"
67+
68+
# perform batched update on both graphs using the new /update endpoint
69+
update=$(cat <<EOF
70+
WITH <${GRAPH1_URI}>
71+
DELETE { ?item <http://purl.org/dc/terms/title> ?oldTitle }
72+
INSERT { ?item <http://purl.org/dc/terms/title> "Updated Item 1" }
73+
WHERE { ?item <http://purl.org/dc/terms/title> ?oldTitle } ;
74+
75+
WITH <${GRAPH2_URI}>
76+
DELETE { ?item <http://purl.org/dc/terms/title> ?oldTitle }
77+
INSERT { ?item <http://purl.org/dc/terms/title> "Updated Item 2" }
78+
WHERE { ?item <http://purl.org/dc/terms/title> ?oldTitle }
79+
EOF
80+
)
81+
82+
curl -k -w "%{http_code}\n" -o /dev/null -s \
83+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
84+
-X POST \
85+
-H "Content-Type: application/sparql-update" \
86+
"${END_USER_BASE_URL}update" \
87+
--data-binary "$update" \
88+
| grep -q "$STATUS_NO_CONTENT"
89+
90+
# verify both graphs were updated
91+
curl -k -f -s \
92+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
93+
-H "Accept: application/n-triples" \
94+
"$GRAPH1_URI" \
95+
| grep -q "Updated Item 1"
96+
97+
curl -k -f -s \
98+
-E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \
99+
-H "Accept: application/n-triples" \
100+
"$GRAPH2_URI" \
101+
| grep -q "Updated Item 2"

platform/datasets/admin.trig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434

3535
}
3636

37+
<update>
38+
{
39+
40+
<update> a foaf:Document ;
41+
dct:title "SPARQL UPDATE endpoint" .
42+
43+
}
44+
3745
<ns>
3846
{
3947

platform/datasets/end-user.trig

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@
3434

3535
}
3636

37+
<update>
38+
{
39+
40+
<update> a foaf:Document ;
41+
dct:title "SPARQL UPDATE endpoint" .
42+
43+
}
44+
3745
<ns>
3846
{
3947

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
/**
2+
* Copyright 2025 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+
*/
17+
package com.atomgraph.linkeddatahub.resource;
18+
19+
import com.atomgraph.linkeddatahub.apps.model.Application;
20+
import com.atomgraph.linkeddatahub.model.Service;
21+
import com.atomgraph.linkeddatahub.server.exception.auth.AuthorizationException;
22+
import com.atomgraph.linkeddatahub.server.security.AgentContext;
23+
import com.atomgraph.linkeddatahub.server.util.PatchUpdateVisitor;
24+
import com.atomgraph.linkeddatahub.server.util.WithGraphVisitor;
25+
import com.atomgraph.linkeddatahub.vocabulary.ACL;
26+
import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY;
27+
import java.net.URI;
28+
import java.util.Optional;
29+
import java.util.Set;
30+
import jakarta.inject.Inject;
31+
import jakarta.ws.rs.BadRequestException;
32+
import jakarta.ws.rs.Consumes;
33+
import jakarta.ws.rs.POST;
34+
import jakarta.ws.rs.WebApplicationException;
35+
import jakarta.ws.rs.core.Context;
36+
import jakarta.ws.rs.core.MultivaluedHashMap;
37+
import jakarta.ws.rs.core.MultivaluedMap;
38+
import jakarta.ws.rs.core.Response;
39+
import jakarta.ws.rs.core.SecurityContext;
40+
import jakarta.ws.rs.core.UriInfo;
41+
import org.apache.jena.rdf.model.Model;
42+
import org.apache.jena.rdf.model.Resource;
43+
import org.apache.jena.sparql.modify.request.UpdateDeleteWhere;
44+
import org.apache.jena.sparql.modify.request.UpdateModify;
45+
import org.apache.jena.update.Update;
46+
import org.apache.jena.update.UpdateRequest;
47+
import org.slf4j.Logger;
48+
import org.slf4j.LoggerFactory;
49+
50+
/**
51+
* JAX-RS resource that handles batched SPARQL UPDATE requests.
52+
* Allows updating multiple graphs in a single request while maintaining security constraints.
53+
*
54+
* @author {@literal Martynas Jusevičius <martynas@atomgraph.com>}
55+
*/
56+
public class SPARQLUpdateEndpointImpl
57+
{
58+
59+
private static final Logger log = LoggerFactory.getLogger(SPARQLUpdateEndpointImpl.class);
60+
61+
private final UriInfo uriInfo;
62+
private final Application application;
63+
private final Service service;
64+
private final SecurityContext securityContext;
65+
private final Optional<AgentContext> agentContext;
66+
67+
/**
68+
* Constructs SPARQL UPDATE endpoint.
69+
*
70+
* @param uriInfo URI information of the current request
71+
* @param application current application
72+
* @param service SPARQL service of the current application
73+
* @param securityContext JAX-RS security context
74+
* @param agentContext authenticated agent's context
75+
*/
76+
@Inject
77+
public SPARQLUpdateEndpointImpl(@Context UriInfo uriInfo,
78+
Application application, Optional<Service> service,
79+
@Context SecurityContext securityContext, Optional<AgentContext> agentContext)
80+
{
81+
this.uriInfo = uriInfo;
82+
this.application = application;
83+
this.service = service.get();
84+
this.securityContext = securityContext;
85+
this.agentContext = agentContext;
86+
}
87+
88+
/**
89+
* Handles batched SPARQL UPDATE requests.
90+
* Each operation in the batch must:
91+
* <ul>
92+
* <li>Be an INSERT/DELETE/WHERE or DELETE WHERE operation</li>
93+
* <li>Specify a WITH &lt;graph-uri&gt; clause</li>
94+
* <li>Not contain any GRAPH patterns</li>
95+
* </ul>
96+
* Authorization is checked for all graph URIs before execution.
97+
*
98+
* @param updateRequest SPARQL UPDATE request
99+
* @return response
100+
*/
101+
@POST
102+
@Consumes(com.atomgraph.core.MediaType.APPLICATION_SPARQL_UPDATE)
103+
public Response post(UpdateRequest updateRequest)
104+
{
105+
if (updateRequest == null) throw new BadRequestException("SPARQL update not specified");
106+
if (log.isDebugEnabled()) log.debug("POST SPARQL UPDATE request with {} operations", updateRequest.getOperations().size());
107+
if (log.isDebugEnabled()) log.debug("SPARQL UPDATE string: {}", updateRequest.toString());
108+
109+
// Validate operations and extract graph URIs
110+
WithGraphVisitor withGraphVisitor = new WithGraphVisitor();
111+
PatchUpdateVisitor patchUpdateVisitor = new PatchUpdateVisitor();
112+
113+
for (Update update : updateRequest.getOperations())
114+
{
115+
// Only UpdateModify (INSERT/DELETE/WHERE) and UpdateDeleteWhere are supported
116+
if (!(update instanceof UpdateModify || update instanceof UpdateDeleteWhere))
117+
throw new WebApplicationException("Only INSERT/DELETE/WHERE and DELETE WHERE forms of SPARQL Update are supported", UNPROCESSABLE_ENTITY.getStatusCode());
118+
119+
// Visit to check for GRAPH patterns (not allowed)
120+
update.visit(patchUpdateVisitor);
121+
122+
// Visit to extract WITH clause graph URIs
123+
update.visit(withGraphVisitor);
124+
}
125+
126+
// Check that no GRAPH keywords are used
127+
if (patchUpdateVisitor.containsNamedGraph())
128+
{
129+
if (log.isWarnEnabled()) log.warn("SPARQL update cannot contain the GRAPH keyword");
130+
throw new WebApplicationException("SPARQL update cannot contain the GRAPH keyword", UNPROCESSABLE_ENTITY.getStatusCode());
131+
}
132+
133+
// Check that all operations have WITH clauses
134+
if (!withGraphVisitor.allHaveWithClause(updateRequest.getOperations().size()))
135+
{
136+
if (log.isWarnEnabled()) log.warn("All SPARQL update operations must specify a WITH <graph-uri> clause");
137+
throw new WebApplicationException("All SPARQL update operations must specify a WITH <graph-uri> clause", UNPROCESSABLE_ENTITY.getStatusCode());
138+
}
139+
140+
Set<String> graphURIs = withGraphVisitor.getGraphURIs();
141+
if (log.isDebugEnabled()) log.debug("Found {} unique graph URIs in WITH clauses: {}", graphURIs.size(), graphURIs);
142+
143+
// Check authorization for all graph URIs
144+
Resource agent = null;
145+
if (getSecurityContext().getUserPrincipal() instanceof com.atomgraph.linkeddatahub.model.auth.Agent)
146+
agent = ((com.atomgraph.linkeddatahub.model.auth.Agent)(getSecurityContext().getUserPrincipal()));
147+
148+
for (String graphURI : graphURIs)
149+
{
150+
if (!isAuthorized(graphURI, agent))
151+
{
152+
if (log.isTraceEnabled()) log.trace("Access not authorized for graph URI: {} with access mode: {}", graphURI, ACL.Write);
153+
throw new AuthorizationException("Access not authorized for graph URI", URI.create(graphURI), ACL.Write);
154+
}
155+
}
156+
157+
// All validations passed, execute the update
158+
if (log.isDebugEnabled()) log.debug("Executing SPARQL UPDATE on endpoint: {}", getService().getSPARQLEndpoint());
159+
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
160+
getService().getSPARQLClient().update(updateRequest, params);
161+
162+
return Response.noContent().build();
163+
}
164+
165+
/**
166+
* Checks if the current agent has Write access to the specified graph.
167+
*
168+
* @param graphURI graph URI to check
169+
* @param agent authenticated agent (can be null for public access)
170+
* @return true if authorized, false otherwise
171+
*/
172+
protected boolean isAuthorized(String graphURI, Resource agent)
173+
{
174+
// Check if agent is the owner of the document - owners automatically get Write access
175+
if (agent != null && isOwner(graphURI, agent))
176+
{
177+
if (log.isDebugEnabled()) log.debug("Agent <{}> is the owner of <{}>, granting Write access", agent, graphURI);
178+
return true;
179+
}
180+
181+
// For now, only owners can perform SPARQL updates
182+
// TODO: Implement full ACL authorization check similar to AuthorizationFilter.authorize()
183+
// This would require loading authorization data and checking for ACL.Write access mode
184+
185+
return false;
186+
}
187+
188+
/**
189+
* Checks if the given agent is the acl:owner of the document.
190+
*
191+
* @param graphURI the document URI
192+
* @param agent the agent whose ownership is checked
193+
* @return true if the agent is the owner, false otherwise
194+
*/
195+
protected boolean isOwner(String graphURI, Resource agent)
196+
{
197+
if (agent == null) return false;
198+
199+
try
200+
{
201+
Model graphModel = getService().getGraphStoreClient().getModel(graphURI);
202+
Resource graphResource = graphModel.createResource(graphURI);
203+
204+
return graphResource.hasProperty(ACL.owner, agent);
205+
}
206+
catch (Exception ex)
207+
{
208+
if (log.isWarnEnabled()) log.warn("Could not check ownership for graph <{}>: {}", graphURI, ex.getMessage());
209+
return false;
210+
}
211+
}
212+
213+
/**
214+
* Returns URI info for the current request.
215+
*
216+
* @return URI info
217+
*/
218+
public UriInfo getUriInfo()
219+
{
220+
return uriInfo;
221+
}
222+
223+
/**
224+
* Returns the current application.
225+
*
226+
* @return application resource
227+
*/
228+
public Application getApplication()
229+
{
230+
return application;
231+
}
232+
233+
/**
234+
* Returns the SPARQL service.
235+
*
236+
* @return service
237+
*/
238+
public Service getService()
239+
{
240+
return service;
241+
}
242+
243+
/**
244+
* Returns the security context.
245+
*
246+
* @return security context
247+
*/
248+
public SecurityContext getSecurityContext()
249+
{
250+
return securityContext;
251+
}
252+
253+
/**
254+
* Returns the authenticated agent's context.
255+
*
256+
* @return optional agent context
257+
*/
258+
public Optional<AgentContext> getAgentContext()
259+
{
260+
return agentContext;
261+
}
262+
263+
}

0 commit comments

Comments
 (0)