001/* 002 * Licensed to DuraSpace under one or more contributor license agreements. 003 * See the NOTICE file distributed with this work for additional information 004 * regarding copyright ownership. 005 * 006 * DuraSpace licenses this file to you under the Apache License, 007 * Version 2.0 (the "License"); you may not use this file except in 008 * compliance with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package org.fcrepo.auth.webac; 019 020import static java.util.Arrays.asList; 021import static java.util.Collections.emptyList; 022import static java.util.stream.Collectors.toList; 023import static java.util.stream.Collectors.toSet; 024import static java.util.stream.IntStream.range; 025import static java.util.stream.Stream.concat; 026import static java.util.stream.Stream.empty; 027import static java.util.stream.Stream.of; 028import static org.apache.jena.graph.NodeFactory.createURI; 029import static org.apache.jena.rdf.model.ModelFactory.createDefaultModel; 030import static org.apache.jena.riot.Lang.TTL; 031import static org.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE; 032import static org.fcrepo.auth.webac.URIConstants.FOAF_GROUP; 033import static org.fcrepo.auth.webac.URIConstants.FOAF_MEMBER_VALUE; 034import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESS_CONTROL_VALUE; 035import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_CLASS_VALUE; 036import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_VALUE; 037import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_CLASS_VALUE; 038import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_VALUE; 039import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHORIZATION; 040import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_VALUE; 041import static org.fcrepo.auth.webac.URIConstants.WEBAC_NAMESPACE_VALUE; 042import static org.fcrepo.kernel.api.RequiredRdfContext.PROPERTIES; 043import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter; 044import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.getJcrSession; 045import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.isNonRdfSourceDescription; 046import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode; 047import static org.slf4j.LoggerFactory.getLogger; 048 049import java.io.File; 050import java.io.InputStream; 051import java.io.IOException; 052import java.net.URI; 053import java.util.ArrayList; 054import java.util.Collection; 055import java.util.HashMap; 056import java.util.HashSet; 057import java.util.List; 058import java.util.Map; 059import java.util.Optional; 060import java.util.Set; 061import java.util.function.Function; 062import java.util.function.Predicate; 063import java.util.stream.Stream; 064 065import javax.inject.Inject; 066import javax.jcr.ItemNotFoundException; 067import javax.jcr.Node; 068import javax.jcr.RepositoryException; 069import javax.jcr.Session; 070import javax.jcr.version.Version; 071import javax.jcr.version.VersionHistory; 072 073import org.fcrepo.auth.roles.common.AccessRolesProvider; 074import org.fcrepo.http.commons.session.SessionFactory; 075import org.fcrepo.kernel.api.FedoraSession; 076import org.fcrepo.kernel.api.exception.MalformedRdfException; 077import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 078import org.fcrepo.kernel.api.identifiers.IdentifierConverter; 079import org.fcrepo.kernel.api.models.FedoraResource; 080import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 081import org.fcrepo.kernel.api.services.NodeService; 082import org.fcrepo.kernel.modeshape.FedoraSessionImpl; 083import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator; 084 085import org.modeshape.jcr.value.Path; 086import org.slf4j.Logger; 087 088import org.apache.jena.graph.Triple; 089import org.apache.jena.rdf.model.Model; 090import org.apache.jena.rdf.model.Resource; 091import org.apache.jena.rdf.model.Statement; 092import org.apache.jena.shared.JenaException; 093 094/** 095 * @author acoburn 096 * @since 9/3/15 097 */ 098public class WebACRolesProvider implements AccessRolesProvider { 099 100 public static final String ROOT_AUTHORIZATION_PROPERTY = "fcrepo.auth.webac.authorization"; 101 102 public static final String GROUP_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.groupAgent.baseUri"; 103 104 public static final String USER_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.userAgent.baseUri"; 105 106 private static final Logger LOGGER = getLogger(WebACRolesProvider.class); 107 108 private static final String FEDORA_INTERNAL_PREFIX = "info:fedora"; 109 110 private static final String ROOT_AUTHORIZATION_LOCATION = "/root-authorization.ttl"; 111 112 private static final String JCR_VERSIONABLE_UUID_PROPERTY = "jcr:versionableUuid"; 113 114 @Inject 115 private NodeService nodeService; 116 117 @Inject 118 private SessionFactory sessionFactory; 119 120 @Override 121 public void postRoles(final Node node, final Map<String, Set<String>> data) throws RepositoryException { 122 throw new UnsupportedOperationException("postRoles() is not implemented"); 123 } 124 125 @Override 126 public void deleteRoles(final Node node) throws RepositoryException { 127 throw new UnsupportedOperationException("deleteRoles() is not implemented"); 128 } 129 130 @Override 131 public Map<String, Collection<String>> findRolesForPath(final Path absPath, final Session session) 132 throws RepositoryException { 133 return getAgentRoles(locateResource(absPath, new FedoraSessionImpl(session))); 134 } 135 136 private FedoraResource locateResource(final Path path, final FedoraSession session) { 137 try { 138 if (getJcrSession(session).nodeExists(path.toString()) || path.isRoot()) { 139 LOGGER.debug("findRolesForPath: {}", path.getString()); 140 final FedoraResource resource = nodeService.find(session, path.toString()); 141 142 if (resource.hasType("nt:version")) { 143 LOGGER.debug("{} is a version, getting the baseVersion", resource); 144 return getBaseVersion(resource); 145 } 146 return resource; 147 } 148 } catch (final RepositoryException ex) { 149 throw new RepositoryRuntimeException(ex); 150 } 151 LOGGER.trace("Path: {} does not exist, checking parent", path.getString()); 152 return locateResource(path.getParent(), session); 153 } 154 155 /** 156 * Get the versionable FedoraResource for this version resource 157 * 158 * @param resource the Version resource 159 * @return the base versionable resource or the version if not found. 160 */ 161 private FedoraResource getBaseVersion(final FedoraResource resource) { 162 final FedoraSession internalSession = sessionFactory.getInternalSession(); 163 164 try { 165 final VersionHistory base = ((Version) getJcrNode(resource)).getContainingHistory(); 166 if (base.hasProperty(JCR_VERSIONABLE_UUID_PROPERTY)) { 167 final String versionUuid = base.getProperty(JCR_VERSIONABLE_UUID_PROPERTY).getValue().getString(); 168 LOGGER.debug("versionableUuid : {}", versionUuid); 169 return nodeService.find(internalSession, 170 getJcrSession(internalSession).getNodeByIdentifier(versionUuid).getPath()); 171 } 172 } catch (final ItemNotFoundException e) { 173 LOGGER.error("Node with jcr:versionableUuid not found : {}", e.getMessage()); 174 } catch (final RepositoryException e) { 175 throw new RepositoryRuntimeException(e); 176 } 177 return resource; 178 } 179 180 @Override 181 public Map<String, Collection<String>> getRoles(final Node node, final boolean effective) { 182 try { 183 return getAgentRoles(nodeService.find(new FedoraSessionImpl(node.getSession()), node.getPath())); 184 } catch (final RepositoryException ex) { 185 throw new RepositoryRuntimeException(ex); 186 } 187 } 188 189 /** 190 * For a given FedoraResource, get a mapping of acl:agent values to acl:mode values. 191 */ 192 private Map<String, Collection<String>> getAgentRoles(final FedoraResource resource) { 193 LOGGER.debug("Getting agent roles for: {}", resource.getPath()); 194 195 // Get the effective ACL by searching the target node and any ancestors. 196 final Optional<ACLHandle> effectiveAcl = getEffectiveAcl( 197 isNonRdfSourceDescription.test(getJcrNode(resource)) ? 198 ((NonRdfSourceDescription)nodeConverter.convert(getJcrNode(resource))).getDescribedResource() : 199 resource); 200 201 // Construct a list of acceptable acl:accessTo values for the target resource. 202 final List<String> resourcePaths = new ArrayList<>(); 203 resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getPath()); 204 205 // Construct a list of acceptable acl:accessToClass values for the target resource. 206 final List<URI> rdfTypes = resource.getTypes(); 207 208 // Add the resource location and types of the ACL-bearing parent, 209 // if present and if different than the target resource. 210 effectiveAcl 211 .map(aclHandle -> aclHandle.resource) 212 .filter(effectiveResource -> !effectiveResource.getPath().equals(resource.getPath())) 213 .ifPresent(effectiveResource -> { 214 resourcePaths.add(FEDORA_INTERNAL_PREFIX + effectiveResource.getPath()); 215 rdfTypes.addAll(effectiveResource.getTypes()); 216 }); 217 218 // If we fall through to the system/classpath-based Authorization and it 219 // contains any acl:accessTo properties, it is necessary to add each ancestor 220 // path up the node hierarchy, starting at the resource location up to the 221 // root location. This way, the checkAccessTo predicate (below) can be properly 222 // created to match any acl:accessTo values that are part of the getDefaultAuthorization. 223 // This is not relevant if an effectiveAcl is present. 224 if (!effectiveAcl.isPresent()) { 225 resourcePaths.addAll(getAllPathAncestors(resource.getPath())); 226 } 227 228 // Create a function to check acl:accessTo, scoped to the given resourcePaths 229 final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths); 230 231 // Create a function to check acl:accessToClass, scoped to the given rdf:type values, 232 // but transform the URIs to Strings first. 233 final Predicate<WebACAuthorization> checkAccessToClass = 234 accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList())); 235 236 // Read the effective Acl and return a list of acl:Authorization statements 237 final List<WebACAuthorization> authorizations = effectiveAcl 238 .map(auth -> getAuthorizations(auth.uri.toString())) 239 .orElseGet(() -> getDefaultAuthorizations()); 240 241 // Filter the acl:Authorization statements so that they correspond only to statements that apply to 242 // the target (or acl-bearing ancestor) resource path or rdf:type. 243 // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION 244 // of acl:modes for each particular acl:agent. 245 final Map<String, Collection<String>> effectiveRoles = new HashMap<>(); 246 authorizations.stream() 247 .filter(checkAccessTo.or(checkAccessToClass)) 248 .forEach(auth -> { 249 concat(auth.getAgents().stream(), dereferenceAgentClasses(auth.getAgentClasses()).stream()) 250 .forEach(agent -> { 251 effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>()) 252 .addAll(auth.getModes().stream().map(URI::toString).collect(toSet())); 253 }); 254 }); 255 256 LOGGER.debug("Unfiltered ACL: {}", effectiveRoles); 257 258 return effectiveRoles; 259 } 260 261 /** 262 * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths. 263 * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/". 264 */ 265 private static List<String> getAllPathAncestors(final String path) { 266 final List<String> segments = asList(path.split("/")); 267 return range(1, segments.size()) 268 .mapToObj(frameSize -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, frameSize))) 269 .collect(toList()); 270 } 271 272 /** 273 * This is a function for generating a Predicate that filters WebACAuthorizations according 274 * to whether the given acl:accessToClass values contain any of the rdf:type values provided 275 * when creating the predicate. 276 */ 277 private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth -> 278 uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri)); 279 280 /** 281 * This is a function for generating a Predicate that filters WebACAuthorizations according 282 * to whether the given acl:accessTo values contain any of the target resource values provided 283 * when creating the predicate. 284 */ 285 private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth -> 286 uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri)); 287 288 /** 289 * This maps a Collection of acl:agentClass values to a List of agents. 290 * Any out-of-domain URIs are silently ignored. 291 */ 292 private List<String> dereferenceAgentClasses(final Collection<String> agentClasses) { 293 final FedoraSession internalSession = sessionFactory.getInternalSession(); 294 final IdentifierConverter<Resource, FedoraResource> translator = 295 new DefaultIdentifierTranslator(getJcrSession(internalSession)); 296 297 final List<String> members = agentClasses.stream().flatMap(agentClass -> { 298 if (agentClass.startsWith(FEDORA_INTERNAL_PREFIX)) { 299 final FedoraResource resource = nodeService.find( 300 internalSession, agentClass.substring(FEDORA_INTERNAL_PREFIX.length())); 301 return getAgentMembers(translator, resource); 302 } else if (agentClass.equals(FOAF_AGENT_VALUE)) { 303 return of(agentClass); 304 } else { 305 LOGGER.info("Ignoring agentClass: {}", agentClass); 306 return empty(); 307 } 308 }).collect(toList()); 309 310 if (LOGGER.isDebugEnabled() && !agentClasses.isEmpty()) { 311 LOGGER.debug("Found {} members in {} agentClass resources", members.size(), agentClasses.size()); 312 } 313 314 return members; 315 } 316 317 /** 318 * Given a FedoraResource, return a list of agents. 319 */ 320 private static Stream<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator, 321 final FedoraResource resource) { 322 return resource.getTriples(translator, PROPERTIES).filter(memberTestFromTypes.apply(resource.getTypes())) 323 .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream); 324 } 325 326 /** 327 * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream, 328 * making this suitable to use with flatMap. 329 */ 330 private static final Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) { 331 if (object.isURI()) { 332 return of(object.getURI()); 333 } else if (object.isLiteral()) { 334 return of(object.getLiteralValue().toString()); 335 } else { 336 return empty(); 337 } 338 } 339 340 /** 341 * A simple predicate for filtering out any non-foaf:member properties 342 */ 343 private static final Function<List<URI>, Predicate<Triple>> memberTestFromTypes = types -> triple -> 344 types.contains(FOAF_GROUP) && triple.predicateMatches(createURI(FOAF_MEMBER_VALUE)); 345 346 /** 347 * A simple predicate for filtering out any non-acl triples. 348 */ 349 private static final Predicate<Triple> hasAclPredicate = triple -> 350 triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE); 351 352 /** 353 * This function reads a Fedora ACL resource and all of its acl:Authorization children. 354 * The RDF from each child resource is put into a WebACAuthorization object, and the 355 * full list is returned. 356 * 357 * @param location the location of the ACL resource 358 * @return a list of acl:Authorization objects 359 */ 360 private List<WebACAuthorization> getAuthorizations(final String location) { 361 362 final FedoraSession internalSession = sessionFactory.getInternalSession(); 363 final List<WebACAuthorization> authorizations = new ArrayList<>(); 364 final IdentifierConverter<Resource, FedoraResource> translator = 365 new DefaultIdentifierTranslator(getJcrSession(internalSession)); 366 367 LOGGER.debug("Effective ACL: {}", location); 368 369 // Find the specified ACL resource 370 371 if (location.startsWith(FEDORA_INTERNAL_PREFIX)) { 372 373 final FedoraResource resource = nodeService.find(internalSession, 374 location.substring(FEDORA_INTERNAL_PREFIX.length())); 375 376 // Read each child resource, filtering on acl:Authorization type, keeping only acl-prefixed triples. 377 resource.getChildren().forEach(child -> { 378 if (child.getTypes().contains(WEBAC_AUTHORIZATION)) { 379 final Map<String, List<String>> aclTriples = new HashMap<>(); 380 child.getTriples(translator, PROPERTIES).filter(hasAclPredicate) 381 .forEach(triple -> { 382 final String predicate = triple.getPredicate().getURI(); 383 final List<String> values = aclTriples.computeIfAbsent(predicate, 384 key -> new ArrayList<>()); 385 nodeToStringStream(triple.getObject()).forEach(values::add); 386 if (predicate.equals(WEBAC_AGENT_VALUE)) { 387 additionalAgentValues(triple.getObject()).forEach(values::add); 388 } 389 }); 390 // Create a WebACAuthorization object from the provided triples. 391 LOGGER.debug("Adding acl:Authorization from {}", child.getPath()); 392 authorizations.add(createAuthorizationFromMap(aclTriples)); 393 } 394 }); 395 } 396 return authorizations; 397 } 398 399 private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) { 400 return new WebACAuthorization( 401 data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()), 402 data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()), 403 data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream() 404 .map(URI::create).collect(toList()), 405 data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()), 406 data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList())); 407 } 408 409 /** 410 * Recursively find the effective ACL as a URI along with the FedoraResource that points to it. 411 * This way, if the effective ACL is pointed to from a parent resource, the child will inherit 412 * any permissions that correspond to access to that parent. This ACL resource may or may not exist, 413 * and it may be external to the fedora repository. 414 */ 415 static Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource) { 416 try { 417 final IdentifierConverter<Resource, FedoraResource> translator = 418 new DefaultIdentifierTranslator(getJcrNode(resource).getSession()); 419 final List<String> acls = resource.getTriples(translator, PROPERTIES) 420 .filter(triple -> triple.getPredicate().equals(createURI(WEBAC_ACCESS_CONTROL_VALUE))) 421 .map(triple -> { 422 if (triple.getObject().isURI()) { 423 return triple.getObject().getURI(); 424 } 425 final String error = String.format("The value %s of the %s on this resource must be a URI", 426 triple.getObject(), WEBAC_ACCESS_CONTROL_VALUE); 427 LOGGER.error(error); 428 throw new MalformedRdfException(error); 429 }).collect(toList()); 430 431 if (!acls.isEmpty()) { 432 if (acls.size() > 1) { 433 LOGGER.warn("Found multiple ACLs defined for this node. Using: {}", acls.get(0)); 434 } 435 return Optional.of(new ACLHandle(URI.create(acls.get(0)), resource)); 436 } else if (getJcrNode(resource).getDepth() == 0) { 437 LOGGER.debug("No ACLs defined on this node or in parent hierarchy"); 438 return Optional.empty(); 439 } else { 440 LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath()); 441 return getEffectiveAcl(resource.getContainer()); 442 } 443 } catch (final RepositoryException ex) { 444 LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage()); 445 return Optional.empty(); 446 } 447 } 448 449 private static List<WebACAuthorization> getDefaultAuthorizations() { 450 final Map<String, List<String>> aclTriples = new HashMap<>(); 451 final List<WebACAuthorization> authorizations = new ArrayList<>(); 452 453 getDefaultAcl().listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> { 454 if (hasAclPredicate.test(triple)) { 455 final String predicate = triple.getPredicate().getURI(); 456 final List<String> values = aclTriples.computeIfAbsent(predicate, 457 key -> new ArrayList<>()); 458 nodeToStringStream(triple.getObject()).forEach(values::add); 459 if (predicate.equals(WEBAC_AGENT_VALUE)) { 460 additionalAgentValues(triple.getObject()).forEach(values::add); 461 } 462 } 463 }); 464 465 authorizations.add(createAuthorizationFromMap(aclTriples)); 466 return authorizations; 467 } 468 469 private static Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) { 470 final String groupBaseUri = System.getProperty(GROUP_AGENT_BASE_URI_PROPERTY); 471 final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY); 472 473 if (object.isURI()) { 474 final String uri = object.getURI(); 475 if (userBaseUri != null && uri.startsWith(userBaseUri)) { 476 return of(uri.substring(userBaseUri.length())); 477 } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) { 478 return of(uri.substring(groupBaseUri.length())); 479 } 480 } 481 return empty(); 482 } 483 484 private static Model getDefaultAcl() { 485 final String rootAcl = System.getProperty(ROOT_AUTHORIZATION_PROPERTY); 486 final Model model = createDefaultModel(); 487 488 if (rootAcl != null && new File(rootAcl).isFile()) { 489 try { 490 LOGGER.debug("Getting root authorization from file: {}", rootAcl); 491 return model.read(rootAcl); 492 } catch (final JenaException ex) { 493 LOGGER.error("Error parsing root authorization file: {}", ex.getMessage()); 494 } 495 } 496 try (final InputStream is = WebACRolesProvider.class.getResourceAsStream(ROOT_AUTHORIZATION_LOCATION)) { 497 LOGGER.debug("Getting root authorization from classpath: {}", ROOT_AUTHORIZATION_LOCATION); 498 return model.read(is, null, TTL.getName()); 499 } catch (final IOException ex) { 500 LOGGER.error("Error reading root authorization file: {}", ex.getMessage()); 501 } catch (final JenaException ex) { 502 LOGGER.error("Error parsing root authorization file: {}", ex.getMessage()); 503 } 504 return createDefaultModel(); 505 } 506}