diff --git a/http-tests/document-hierarchy/POST-batched-update.sh b/http-tests/document-hierarchy/POST-batched-update.sh new file mode 100755 index 000000000..7eec1d38f --- /dev/null +++ b/http-tests/document-hierarchy/POST-batched-update.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# add agent to the writers group +add-agent-to-group.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --agent "$AGENT_URI" \ + "${ADMIN_BASE_URL}acl/groups/writers/" + +# create two test documents +pushd . > /dev/null && cd "$SCRIPT_ROOT/admin/acl" + +GRAPH1_URI="${END_USER_BASE_URL}test-graph-1/" +GRAPH2_URI="${END_USER_BASE_URL}test-graph-2/" + +# create first test graph +./create-authorization.sh \ + -b "$END_USER_BASE_URL" \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --label "Test Graph 1" \ + --uri "$GRAPH1_URI" \ + --agent "${ADMIN_BASE_URL}acl/agents/test/#this" + +# create second test graph +./create-authorization.sh \ + -b "$END_USER_BASE_URL" \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --label "Test Graph 2" \ + --uri "$GRAPH2_URI" \ + --agent "${ADMIN_BASE_URL}acl/agents/test/#this" + +popd > /dev/null + +# add initial data to both graphs +( +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/n-triples" \ + --data-binary @- \ + "$GRAPH1_URI" < "Graph 1" . +<${GRAPH1_URI}#item> . +<${GRAPH1_URI}#item> "Item 1" . +EOF +) | grep -q "$STATUS_CREATED" + +( +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Content-Type: application/n-triples" \ + --data-binary @- \ + "$GRAPH2_URI" < "Graph 2" . +<${GRAPH2_URI}#item> . +<${GRAPH2_URI}#item> "Item 2" . +EOF +) | grep -q "$STATUS_CREATED" + +# perform batched update on both graphs using the new /update endpoint +update=$(cat < +DELETE { ?item ?oldTitle } +INSERT { ?item "Updated Item 1" } +WHERE { ?item ?oldTitle } ; + +WITH <${GRAPH2_URI}> +DELETE { ?item ?oldTitle } +INSERT { ?item "Updated Item 2" } +WHERE { ?item ?oldTitle } +EOF +) + +curl -k -w "%{http_code}\n" -o /dev/null -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -X POST \ + -H "Content-Type: application/sparql-update" \ + "${END_USER_BASE_URL}update" \ + --data-binary "$update" \ +| grep -q "$STATUS_NO_CONTENT" + +# verify both graphs were updated +curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$GRAPH1_URI" \ +| grep -q "Updated Item 1" + +curl -k -f -s \ + -E "$OWNER_CERT_FILE":"$OWNER_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$GRAPH2_URI" \ +| grep -q "Updated Item 2" diff --git a/platform/datasets/admin.trig b/platform/datasets/admin.trig index 111123be6..1b64578d7 100644 --- a/platform/datasets/admin.trig +++ b/platform/datasets/admin.trig @@ -34,6 +34,14 @@ } + +{ + + a foaf:Document ; + dct:title "SPARQL UPDATE endpoint" . + +} + { diff --git a/platform/datasets/end-user.trig b/platform/datasets/end-user.trig index 4c3574b08..afbf2e2ad 100644 --- a/platform/datasets/end-user.trig +++ b/platform/datasets/end-user.trig @@ -34,6 +34,14 @@ } + +{ + + a foaf:Document ; + dct:title "SPARQL UPDATE endpoint" . + +} + { diff --git a/platform/namespace-ontology.trig.template b/platform/namespace-ontology.trig.template index c62472479..4f049cc45 100644 --- a/platform/namespace-ontology.trig.template +++ b/platform/namespace-ontology.trig.template @@ -73,6 +73,25 @@ } +# SPARQL update authorization + +<${admin_origin}/acl/authorizations/sparql-update/> +{ + + <${admin_origin}/acl/authorizations/sparql-update/> a dh:Item ; + sioc:has_container <${admin_origin}/acl/authorizations/> ; + dct:title "SPARQL update access" ; + foaf:primaryTopic <${admin_origin}/acl/authorizations/sparql-update/#this> . + + <${admin_origin}/acl/authorizations/sparql-update/#this> a acl:Authorization ; + rdfs:label "SPARQL update access" ; + rdfs:comment "Allows only authenticated access" ; + acl:accessTo <${end_user_origin}/update> ; + acl:mode acl:Append ; # allow updates over POST + acl:agentClass acl:AuthenticatedAgent . + +} + # write/append authorization <${admin_origin}/acl/authorizations/write-append/> diff --git a/src/main/java/com/atomgraph/linkeddatahub/resource/SPARQLUpdateEndpointImpl.java b/src/main/java/com/atomgraph/linkeddatahub/resource/SPARQLUpdateEndpointImpl.java new file mode 100644 index 000000000..350765518 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/resource/SPARQLUpdateEndpointImpl.java @@ -0,0 +1,263 @@ +/** + * Copyright 2025 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.resource; + +import com.atomgraph.linkeddatahub.apps.model.Application; +import com.atomgraph.linkeddatahub.model.Service; +import com.atomgraph.linkeddatahub.server.exception.auth.AuthorizationException; +import com.atomgraph.linkeddatahub.server.security.AgentContext; +import com.atomgraph.linkeddatahub.server.util.PatchUpdateVisitor; +import com.atomgraph.linkeddatahub.server.util.WithGraphVisitor; +import com.atomgraph.linkeddatahub.vocabulary.ACL; +import static com.atomgraph.server.status.UnprocessableEntityStatus.UNPROCESSABLE_ENTITY; +import java.net.URI; +import java.util.Optional; +import java.util.Set; +import jakarta.inject.Inject; +import jakarta.ws.rs.BadRequestException; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.sparql.modify.request.UpdateDeleteWhere; +import org.apache.jena.sparql.modify.request.UpdateModify; +import org.apache.jena.update.Update; +import org.apache.jena.update.UpdateRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * JAX-RS resource that handles batched SPARQL UPDATE requests. + * Allows updating multiple graphs in a single request while maintaining security constraints. + * + * @author {@literal Martynas Jusevičius } + */ +public class SPARQLUpdateEndpointImpl +{ + + private static final Logger log = LoggerFactory.getLogger(SPARQLUpdateEndpointImpl.class); + + private final UriInfo uriInfo; + private final Application application; + private final Service service; + private final SecurityContext securityContext; + private final Optional agentContext; + + /** + * Constructs SPARQL UPDATE endpoint. + * + * @param uriInfo URI information of the current request + * @param application current application + * @param service SPARQL service of the current application + * @param securityContext JAX-RS security context + * @param agentContext authenticated agent's context + */ + @Inject + public SPARQLUpdateEndpointImpl(@Context UriInfo uriInfo, + Application application, Optional service, + @Context SecurityContext securityContext, Optional agentContext) + { + this.uriInfo = uriInfo; + this.application = application; + this.service = service.get(); + this.securityContext = securityContext; + this.agentContext = agentContext; + } + + /** + * Handles batched SPARQL UPDATE requests. + * Each operation in the batch must: + *
    + *
  • Be an INSERT/DELETE/WHERE or DELETE WHERE operation
  • + *
  • Specify a WITH <graph-uri> clause
  • + *
  • Not contain any GRAPH patterns
  • + *
+ * Authorization is checked for all graph URIs before execution. + * + * @param updateRequest SPARQL UPDATE request + * @return response + */ + @POST + @Consumes(com.atomgraph.core.MediaType.APPLICATION_SPARQL_UPDATE) + public Response post(UpdateRequest updateRequest) + { + if (updateRequest == null) throw new BadRequestException("SPARQL update not specified"); + if (log.isDebugEnabled()) log.debug("POST SPARQL UPDATE request with {} operations", updateRequest.getOperations().size()); + if (log.isDebugEnabled()) log.debug("SPARQL UPDATE string: {}", updateRequest.toString()); + + // Validate operations and extract graph URIs + WithGraphVisitor withGraphVisitor = new WithGraphVisitor(); + PatchUpdateVisitor patchUpdateVisitor = new PatchUpdateVisitor(); + + for (Update update : updateRequest.getOperations()) + { + // Only UpdateModify (INSERT/DELETE/WHERE) and UpdateDeleteWhere are supported + if (!(update instanceof UpdateModify || update instanceof UpdateDeleteWhere)) + throw new WebApplicationException("Only INSERT/DELETE/WHERE and DELETE WHERE forms of SPARQL Update are supported", UNPROCESSABLE_ENTITY.getStatusCode()); + + // Visit to check for GRAPH patterns (not allowed) + update.visit(patchUpdateVisitor); + + // Visit to extract WITH clause graph URIs + update.visit(withGraphVisitor); + } + + // Check that no GRAPH keywords are used + if (patchUpdateVisitor.containsNamedGraph()) + { + if (log.isWarnEnabled()) log.warn("SPARQL update cannot contain the GRAPH keyword"); + throw new WebApplicationException("SPARQL update cannot contain the GRAPH keyword", UNPROCESSABLE_ENTITY.getStatusCode()); + } + + // Check that all operations have WITH clauses + if (!withGraphVisitor.allHaveWithClause(updateRequest.getOperations().size())) + { + if (log.isWarnEnabled()) log.warn("All SPARQL update operations must specify a WITH clause"); + throw new WebApplicationException("All SPARQL update operations must specify a WITH clause", UNPROCESSABLE_ENTITY.getStatusCode()); + } + + Set graphURIs = withGraphVisitor.getGraphURIs(); + if (log.isDebugEnabled()) log.debug("Found {} unique graph URIs in WITH clauses: {}", graphURIs.size(), graphURIs); + + // Check authorization for all graph URIs + Resource agent = null; + if (getSecurityContext().getUserPrincipal() instanceof com.atomgraph.linkeddatahub.model.auth.Agent) + agent = ((com.atomgraph.linkeddatahub.model.auth.Agent)(getSecurityContext().getUserPrincipal())); + + for (String graphURI : graphURIs) + { + if (!isAuthorized(graphURI, agent)) + { + if (log.isTraceEnabled()) log.trace("Access not authorized for graph URI: {} with access mode: {}", graphURI, ACL.Write); + throw new AuthorizationException("Access not authorized for graph URI", URI.create(graphURI), ACL.Write); + } + } + + // All validations passed, execute the update + if (log.isDebugEnabled()) log.debug("Executing SPARQL UPDATE on endpoint: {}", getService().getSPARQLEndpoint()); + MultivaluedMap params = new MultivaluedHashMap<>(); + getService().getSPARQLClient().update(updateRequest, params); + + return Response.noContent().build(); + } + + /** + * Checks if the current agent has Write access to the specified graph. + * + * @param graphURI graph URI to check + * @param agent authenticated agent (can be null for public access) + * @return true if authorized, false otherwise + */ + protected boolean isAuthorized(String graphURI, Resource agent) + { + // Check if agent is the owner of the document - owners automatically get Write access + if (agent != null && isOwner(graphURI, agent)) + { + if (log.isDebugEnabled()) log.debug("Agent <{}> is the owner of <{}>, granting Write access", agent, graphURI); + return true; + } + + // For now, only owners can perform SPARQL updates + // TODO: Implement full ACL authorization check similar to AuthorizationFilter.authorize() + // This would require loading authorization data and checking for ACL.Write access mode + + return false; + } + + /** + * Checks if the given agent is the acl:owner of the document. + * + * @param graphURI the document URI + * @param agent the agent whose ownership is checked + * @return true if the agent is the owner, false otherwise + */ + protected boolean isOwner(String graphURI, Resource agent) + { + if (agent == null) return false; + + try + { + Model graphModel = getService().getGraphStoreClient().getModel(graphURI); + Resource graphResource = graphModel.createResource(graphURI); + + return graphResource.hasProperty(ACL.owner, agent); + } + catch (Exception ex) + { + if (log.isWarnEnabled()) log.warn("Could not check ownership for graph <{}>: {}", graphURI, ex.getMessage()); + return false; + } + } + + /** + * Returns URI info for the current request. + * + * @return URI info + */ + public UriInfo getUriInfo() + { + return uriInfo; + } + + /** + * Returns the current application. + * + * @return application resource + */ + public Application getApplication() + { + return application; + } + + /** + * Returns the SPARQL service. + * + * @return service + */ + public Service getService() + { + return service; + } + + /** + * Returns the security context. + * + * @return security context + */ + public SecurityContext getSecurityContext() + { + return securityContext; + } + + /** + * Returns the authenticated agent's context. + * + * @return optional agent context + */ + public Optional getAgentContext() + { + return agentContext; + } + +} diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java index 91667c801..995c4f42f 100644 --- a/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java +++ b/src/main/java/com/atomgraph/linkeddatahub/server/model/impl/Dispatcher.java @@ -21,6 +21,7 @@ import com.atomgraph.linkeddatahub.resource.Add; import com.atomgraph.linkeddatahub.resource.Generate; import com.atomgraph.linkeddatahub.resource.Namespace; +import com.atomgraph.linkeddatahub.resource.SPARQLUpdateEndpointImpl; import com.atomgraph.linkeddatahub.resource.Transform; import com.atomgraph.linkeddatahub.resource.admin.Clear; import com.atomgraph.linkeddatahub.resource.admin.SignUp; @@ -105,7 +106,7 @@ public Class getSubResource() /** * Returns SPARQL protocol endpoint. - * + * * @return endpoint resource */ @Path("sparql") @@ -114,9 +115,20 @@ public Class getSPARQLEndpoint() return getProxyClass().orElse(SPARQLEndpointImpl.class); } + /** + * Returns SPARQL UPDATE endpoint for batched updates. + * + * @return endpoint resource + */ + @Path("update") + public Class getSPARQLUpdate() + { + return getProxyClass().orElse(SPARQLUpdateEndpointImpl.class); + } + /** * Returns SPARQL endpoint for the in-memory ontology model. - * + * * @return endpoint resource */ @Path("ns") diff --git a/src/main/java/com/atomgraph/linkeddatahub/server/util/WithGraphVisitor.java b/src/main/java/com/atomgraph/linkeddatahub/server/util/WithGraphVisitor.java new file mode 100644 index 000000000..b070c0c69 --- /dev/null +++ b/src/main/java/com/atomgraph/linkeddatahub/server/util/WithGraphVisitor.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 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.util; + +import java.util.HashSet; +import java.util.Set; +import org.apache.jena.sparql.modify.request.UpdateModify; +import org.apache.jena.sparql.modify.request.UpdateVisitorBase; + +/** + * Visitor for SPARQL UPDATE operations to extract graph URIs from WITH clauses. + * Used to validate batched SPARQL UPDATE requests and extract graph URIs for authorization checks. + * + * @author {@literal Martynas Jusevičius } + */ +public class WithGraphVisitor extends UpdateVisitorBase +{ + + private final Set graphURIs = new HashSet<>(); + + @Override + public void visit(UpdateModify update) + { + if (update.getWithIRI() != null) + { + graphURIs.add(update.getWithIRI().toString()); + } + } + + /** + * Returns the set of graph URIs found in WITH clauses. + * + * @return set of graph URI strings + */ + public Set getGraphURIs() + { + return graphURIs; + } + + /** + * Returns true if all visited operations have WITH clauses. + * + * @param operationCount total number of operations that were visited + * @return true if all operations have WITH clauses + */ + public boolean allHaveWithClause(int operationCount) + { + return graphURIs.size() == operationCount; + } + +}