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.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE; 030import static org.fcrepo.auth.webac.URIConstants.VCARD_GROUP_VALUE; 031import static org.fcrepo.auth.webac.URIConstants.VCARD_MEMBER_VALUE; 032import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_CLASS_VALUE; 033import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_VALUE; 034import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_CLASS_VALUE; 035import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_GROUP_VALUE; 036import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_VALUE; 037import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHENTICATED_AGENT_VALUE; 038import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHORIZATION_VALUE; 039import static org.fcrepo.auth.webac.URIConstants.WEBAC_DEFAULT_VALUE; 040import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_VALUE; 041import static org.fcrepo.auth.webac.URIConstants.WEBAC_NAMESPACE_VALUE; 042import static org.fcrepo.http.api.FedoraAcl.getDefaultAcl; 043import static org.fcrepo.kernel.api.RdfLexicon.RDF_NAMESPACE; 044import static org.fcrepo.kernel.api.RequiredRdfContext.PROPERTIES; 045import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.getJcrSession; 046import static org.fcrepo.kernel.modeshape.utils.FedoraSessionUserUtil.USER_AGENT_BASE_URI_PROPERTY; 047import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode; 048import static org.slf4j.LoggerFactory.getLogger; 049 050import java.net.URI; 051import java.util.ArrayList; 052import java.util.Collection; 053import java.util.HashMap; 054import java.util.HashSet; 055import java.util.List; 056import java.util.Map; 057import java.util.Optional; 058import java.util.Set; 059import java.util.function.Function; 060import java.util.function.Predicate; 061import java.util.stream.Collectors; 062import java.util.stream.Stream; 063import javax.inject.Inject; 064import javax.jcr.Node; 065import javax.jcr.RepositoryException; 066 067import org.apache.jena.graph.Triple; 068import org.apache.jena.rdf.model.Resource; 069import org.apache.jena.rdf.model.Statement; 070import org.fcrepo.http.commons.session.SessionFactory; 071import org.fcrepo.kernel.api.FedoraSession; 072import org.fcrepo.kernel.api.exception.RepositoryRuntimeException; 073import org.fcrepo.kernel.api.identifiers.IdentifierConverter; 074import org.fcrepo.kernel.api.models.FedoraResource; 075import org.fcrepo.kernel.api.services.NodeService; 076import org.fcrepo.kernel.modeshape.FedoraSessionImpl; 077import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator; 078import org.slf4j.Logger; 079 080/** 081 * @author acoburn 082 * @since 9/3/15 083 */ 084public class WebACRolesProvider { 085 086 public static final String GROUP_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.groupAgent.baseUri"; 087 088 private static final Logger LOGGER = getLogger(WebACRolesProvider.class); 089 090 private static final String FEDORA_INTERNAL_PREFIX = "info:fedora"; 091 092 private static final String JCR_VERSIONABLE_UUID_PROPERTY = "jcr:versionableUuid"; 093 094 private static final org.apache.jena.graph.Node RDF_TYPE_NODE = createURI(RDF_NAMESPACE + "type"); 095 private static final org.apache.jena.graph.Node VCARD_GROUP_NODE = createURI(VCARD_GROUP_VALUE); 096 private static final org.apache.jena.graph.Node VCARD_MEMBER_NODE = createURI(VCARD_MEMBER_VALUE); 097 098 @Inject 099 private NodeService nodeService; 100 101 @Inject 102 private SessionFactory sessionFactory; 103 104 /** 105 * Get the roles assigned to this Node. 106 * 107 * @param node the subject Node 108 * @return a set of roles for each principal 109 */ 110 public Map<String, Collection<String>> getRoles(final Node node) { 111 try { 112 return getAgentRoles(nodeService.find(new FedoraSessionImpl(node.getSession()), node.getPath())); 113 } catch (final RepositoryException ex) { 114 throw new RepositoryRuntimeException(ex); 115 } 116 } 117 118 /** 119 * For a given FedoraResource, get a mapping of acl:agent values to acl:mode values and 120 * for foaf:Agent and acl:AuthenticatedAgent include the acl:agentClass value to acl:mode. 121 */ 122 private Map<String, Collection<String>> getAgentRoles(final FedoraResource resource) { 123 LOGGER.debug("Getting agent roles for: {}", resource.getPath()); 124 125 // Get the effective ACL by searching the target node and any ancestors. 126 final Optional<ACLHandle> effectiveAcl = getEffectiveAcl(resource, false, sessionFactory); 127 128 // Construct a list of acceptable acl:accessTo values for the target resource. 129 final List<String> resourcePaths = new ArrayList<>(); 130 resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getPath()); 131 132 // Construct a list of acceptable acl:accessToClass values for the target resource. 133 final List<URI> rdfTypes = resource.getTypes(); 134 135 // Add the resource location and types of the ACL-bearing parent, 136 // if present and if different than the target resource. 137 effectiveAcl 138 .map(aclHandle -> aclHandle.resource) 139 .filter(effectiveResource -> !effectiveResource.getPath().equals(resource.getPath())) 140 .ifPresent(effectiveResource -> { 141 resourcePaths.add(FEDORA_INTERNAL_PREFIX + effectiveResource.getPath()); 142 rdfTypes.addAll(effectiveResource.getTypes()); 143 }); 144 145 // If we fall through to the system/classpath-based Authorization and it 146 // contains any acl:accessTo properties, it is necessary to add each ancestor 147 // path up the node hierarchy, starting at the resource location up to the 148 // root location. This way, the checkAccessTo predicate (below) can be properly 149 // created to match any acl:accessTo values that are part of the getDefaultAuthorization. 150 // This is not relevant if an effectiveAcl is present. 151 if (!effectiveAcl.isPresent()) { 152 resourcePaths.addAll(getAllPathAncestors(resource.getPath())); 153 } 154 155 // Create a function to check acl:accessTo, scoped to the given resourcePaths 156 final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths); 157 158 // Create a function to check acl:accessToClass, scoped to the given rdf:type values, 159 // but transform the URIs to Strings first. 160 final Predicate<WebACAuthorization> checkAccessToClass = 161 accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList())); 162 163 // Read the effective Acl and return a list of acl:Authorization statements 164 final List<WebACAuthorization> authorizations = effectiveAcl 165 .map(auth -> auth.authorizations) 166 .orElseGet(() -> getDefaultAuthorizations()); 167 168 // Filter the acl:Authorization statements so that they correspond only to statements that apply to 169 // the target (or acl-bearing ancestor) resource path or rdf:type. 170 // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION 171 // of acl:modes for each particular acl:agent. 172 final Map<String, Collection<String>> effectiveRoles = new HashMap<>(); 173 authorizations.stream() 174 .filter(checkAccessTo.or(checkAccessToClass)) 175 .forEach(auth -> { 176 concat(auth.getAgents().stream(), dereferenceAgentGroups(auth.getAgentGroups()).stream()) 177 .filter(agent -> !agent.equals(FOAF_AGENT_VALUE) && 178 !agent.equals(WEBAC_AUTHENTICATED_AGENT_VALUE)) 179 .forEach(agent -> { 180 effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>()) 181 .addAll(auth.getModes().stream().map(URI::toString).collect(toSet())); 182 }); 183 auth.getAgentClasses().stream().filter(agentClass -> agentClass.equals(FOAF_AGENT_VALUE) || 184 agentClass.equals( 185 WEBAC_AUTHENTICATED_AGENT_VALUE)) 186 .forEach(agentClass -> { 187 effectiveRoles.computeIfAbsent(agentClass, key -> new HashSet<>()) 188 .addAll(auth.getModes().stream().map(URI::toString).collect(toSet())); 189 }); 190 }); 191 192 LOGGER.debug("Unfiltered ACL: {}", effectiveRoles); 193 194 return effectiveRoles; 195 } 196 197 /** 198 * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths. 199 * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/". 200 */ 201 private static List<String> getAllPathAncestors(final String path) { 202 final List<String> segments = asList(path.split("/")); 203 return range(1, segments.size()) 204 .mapToObj(frameSize -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, frameSize))) 205 .collect(toList()); 206 } 207 208 /** 209 * This is a function for generating a Predicate that filters WebACAuthorizations according 210 * to whether the given acl:accessToClass values contain any of the rdf:type values provided 211 * when creating the predicate. 212 */ 213 private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth -> 214 uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri)); 215 216 /** 217 * This is a function for generating a Predicate that filters WebACAuthorizations according 218 * to whether the given acl:accessTo values contain any of the target resource values provided 219 * when creating the predicate. 220 */ 221 private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth -> 222 uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri)); 223 224 /** 225 * This maps a Collection of acl:agentGroup values to a List of agents. 226 * Any out-of-domain URIs are silently ignored. 227 */ 228 private List<String> dereferenceAgentGroups(final Collection<String> agentGroups) { 229 final FedoraSession internalSession = sessionFactory.getInternalSession(); 230 final IdentifierConverter<Resource, FedoraResource> translator = 231 new DefaultIdentifierTranslator(getJcrSession(internalSession)); 232 233 final List<String> members = agentGroups.stream().flatMap(agentGroup -> { 234 if (agentGroup.startsWith(FEDORA_INTERNAL_PREFIX)) { 235 //strip off trailing hash. 236 final int hashIndex = agentGroup.indexOf("#"); 237 final String agentGroupNoHash = hashIndex > 0 ? 238 agentGroup.substring(0, hashIndex) : 239 agentGroup; 240 final String hashedSuffix = hashIndex > 0 ? agentGroup.substring(hashIndex) : null; 241 final FedoraResource resource = nodeService.find( 242 internalSession, agentGroupNoHash.substring(FEDORA_INTERNAL_PREFIX.length())); 243 return getAgentMembers(translator, resource, hashedSuffix); 244 } else if (agentGroup.equals(FOAF_AGENT_VALUE)) { 245 return of(agentGroup); 246 } else { 247 LOGGER.info("Ignoring agentGroup: {}", agentGroup); 248 return empty(); 249 } 250 }).collect(toList()); 251 252 if (LOGGER.isDebugEnabled() && !agentGroups.isEmpty()) { 253 LOGGER.debug("Found {} members in {} agentGroups resources", members.size(), agentGroups.size()); 254 } 255 256 return members; 257 } 258 259 /** 260 * Given a FedoraResource, return a list of agents. 261 */ 262 private static Stream<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator, 263 final FedoraResource resource, final String hashPortion) { 264 265 //resolve list of triples, accounting for hash-uris. 266 final List<Triple> triples = resource.getTriples(translator, PROPERTIES).filter( 267 triple -> hashPortion == null || triple.getSubject().getURI().endsWith(hashPortion)).collect(toList()); 268 //determine if there is a rdf:type vcard:Group 269 final boolean hasVcardGroup = triples.stream().anyMatch( 270 triple -> triple.matches(triple.getSubject(), RDF_TYPE_NODE, VCARD_GROUP_NODE)); 271 //return members only if there is an associated vcard:Group 272 if (hasVcardGroup) { 273 return triples.stream() 274 .filter(triple -> triple.predicateMatches(VCARD_MEMBER_NODE)) 275 .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream) 276 .map(WebACRolesProvider::stripUserAgentBaseURI); 277 } else { 278 return empty(); 279 } 280 } 281 282 private static String stripUserAgentBaseURI(final String object) { 283 final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY); 284 if (userBaseUri != null && object.startsWith(userBaseUri)) { 285 return object.substring(userBaseUri.length()); 286 } 287 return object; 288 } 289 290 /** 291 * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream, 292 * making this suitable to use with flatMap. 293 */ 294 private static Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) { 295 if (object.isURI()) { 296 return of(object.getURI()); 297 } else if (object.isLiteral()) { 298 return of(object.getLiteralValue().toString()); 299 } else { 300 return empty(); 301 } 302 } 303 304 305 /** 306 * A simple predicate for filtering out any non-acl triples. 307 */ 308 private static final Predicate<Triple> hasAclPredicate = triple -> 309 triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE); 310 311 /** 312 * This function reads a Fedora ACL resource and all of its acl:Authorization children. 313 * The RDF from each child resource is put into a WebACAuthorization object, and the 314 * full list is returned. 315 * 316 * @param aclResource the ACL resource 317 * @param ancestorAcl flag indicating whether or not the ACL resource associated with an ancestor of the target 318 * resource 319 * @param sessionFactory the session factory 320 * @return a list of acl:Authorization objects 321 */ 322 private static List<WebACAuthorization> getAuthorizations(final FedoraResource aclResource, 323 final boolean ancestorAcl, 324 final SessionFactory sessionFactory) { 325 326 final FedoraSession internalSession = sessionFactory.getInternalSession(); 327 final List<WebACAuthorization> authorizations = new ArrayList<>(); 328 final IdentifierConverter<Resource, FedoraResource> translator = 329 new DefaultIdentifierTranslator(getJcrSession(internalSession)); 330 331 if (LOGGER.isDebugEnabled()) { 332 LOGGER.debug("ACL: {}", aclResource.getPath()); 333 } 334 335 if (aclResource.isAcl()) { 336 //resolve set of subjects that are of type acl:authorization 337 final List<Triple> triples = aclResource.getTriples(translator, PROPERTIES).collect(toList()); 338 339 final Set<org.apache.jena.graph.Node> authSubjects = triples.stream().filter(t -> { 340 return t.getPredicate().getURI().equals(RDF_NAMESPACE + "type") && 341 t.getObject().getURI().equals(WEBAC_AUTHORIZATION_VALUE); 342 }).map(t -> t.getSubject()).collect(Collectors.toSet()); 343 344 // Read resource, keeping only acl-prefixed triples. 345 final Map<String, Map<String, List<String>>> authMap = new HashMap<>(); 346 triples.stream().filter(hasAclPredicate) 347 .forEach(triple -> { 348 if (authSubjects.contains(triple.getSubject())) { 349 final Map<String, List<String>> aclTriples = 350 authMap.computeIfAbsent(triple.getSubject().getURI(), key -> new HashMap<>()); 351 352 final String predicate = triple.getPredicate().getURI(); 353 final List<String> values = aclTriples.computeIfAbsent(predicate, 354 key -> new ArrayList<>()); 355 nodeToStringStream(triple.getObject()).forEach(values::add); 356 if (predicate.equals(WEBAC_AGENT_VALUE)) { 357 additionalAgentValues(triple.getObject()).forEach(values::add); 358 } 359 } 360 }); 361 // Create a WebACAuthorization object from the provided triples. 362 if (LOGGER.isDebugEnabled()) { 363 LOGGER.debug("Adding acl:Authorization from {}", aclResource.getPath()); 364 } 365 authMap.values().forEach(aclTriples -> { 366 final WebACAuthorization authorization = createAuthorizationFromMap(aclTriples); 367 //only include authorizations if the acl resource is not an ancestor acl 368 //or the authorization has at least one acl:default 369 if (!ancestorAcl || authorization.getDefaults().size() > 0) { 370 authorizations.add(authorization); 371 } 372 }); 373 } 374 375 return authorizations; 376 } 377 378 private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) { 379 return new WebACAuthorization( 380 data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()), 381 data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()), 382 data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream() 383 .map(URI::create).collect(toList()), 384 data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()), 385 data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList()), 386 data.getOrDefault(WEBAC_AGENT_GROUP_VALUE, emptyList()), 387 data.getOrDefault(WEBAC_DEFAULT_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 * @param resource the Fedora resource 396 * @param ancestorAcl the flag for looking up ACL from ancestor hierarchy resources 397 * @param sessionFactory session factory 398 */ 399 static Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource, final boolean ancestorAcl, 400 final SessionFactory sessionFactory) { 401 try { 402 403 final FedoraResource aclResource = resource.getAcl(); 404 405 if (aclResource != null) { 406 final List<WebACAuthorization> authorizations = 407 getAuthorizations(aclResource, ancestorAcl, sessionFactory); 408 if (authorizations.size() > 0) { 409 return Optional.of( 410 new ACLHandle(resource, authorizations)); 411 } 412 } 413 414 if (getJcrNode(resource).getDepth() == 0) { 415 LOGGER.debug("No ACLs defined on this node or in parent hierarchy"); 416 return Optional.empty(); 417 } else { 418 LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath()); 419 return getEffectiveAcl(resource.getContainer(), true, sessionFactory); 420 } 421 } catch (final RepositoryException ex) { 422 LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage()); 423 return Optional.empty(); 424 } 425 } 426 427 private static List<WebACAuthorization> getDefaultAuthorizations() { 428 final Map<String, List<String>> aclTriples = new HashMap<>(); 429 final List<WebACAuthorization> authorizations = new ArrayList<>(); 430 431 getDefaultAcl(null).listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> { 432 if (hasAclPredicate.test(triple)) { 433 final String predicate = triple.getPredicate().getURI(); 434 final List<String> values = aclTriples.computeIfAbsent(predicate, 435 key -> new ArrayList<>()); 436 nodeToStringStream(triple.getObject()).forEach(values::add); 437 if (predicate.equals(WEBAC_AGENT_VALUE)) { 438 additionalAgentValues(triple.getObject()).forEach(values::add); 439 } 440 } 441 }); 442 443 authorizations.add(createAuthorizationFromMap(aclTriples)); 444 return authorizations; 445 } 446 447 private static Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) { 448 final String groupBaseUri = System.getProperty(GROUP_AGENT_BASE_URI_PROPERTY); 449 final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY); 450 451 if (object.isURI()) { 452 final String uri = object.getURI(); 453 if (userBaseUri != null && uri.startsWith(userBaseUri)) { 454 return of(uri.substring(userBaseUri.length())); 455 } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) { 456 return of(uri.substring(groupBaseUri.length())); 457 } 458 } 459 return empty(); 460 } 461}