|
| 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 <graph-uri> 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