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