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