001/* 002 * The contents of this file are subject to the license and copyright 003 * detailed in the LICENSE and NOTICE files at the root of the source 004 * tree. 005 */ 006package org.fcrepo.auth.webac; 007 008import static java.util.Arrays.asList; 009import static java.util.Collections.emptyList; 010import static java.util.stream.Collectors.toList; 011import static java.util.stream.Collectors.toSet; 012import static java.util.stream.IntStream.range; 013import static java.util.stream.Stream.concat; 014import static java.util.stream.Stream.empty; 015import static java.util.stream.Stream.of; 016import static org.apache.jena.graph.NodeFactory.createURI; 017import static org.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE; 018import static org.fcrepo.auth.webac.URIConstants.VCARD_GROUP_VALUE; 019import static org.fcrepo.auth.webac.URIConstants.VCARD_MEMBER_VALUE; 020import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_CLASS_VALUE; 021import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_VALUE; 022import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_CLASS_VALUE; 023import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_GROUP_VALUE; 024import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_VALUE; 025import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHENTICATED_AGENT_VALUE; 026import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHORIZATION_VALUE; 027import static org.fcrepo.auth.webac.URIConstants.WEBAC_DEFAULT_VALUE; 028import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_VALUE; 029import static org.fcrepo.auth.webac.URIConstants.WEBAC_NAMESPACE_VALUE; 030import static org.fcrepo.http.api.FedoraAcl.getDefaultAcl; 031import static org.fcrepo.kernel.api.FedoraTypes.FEDORA_ID_PREFIX; 032import static org.fcrepo.kernel.api.RdfLexicon.RDF_NAMESPACE; 033import static org.slf4j.LoggerFactory.getLogger; 034 035import java.net.URI; 036import java.util.ArrayList; 037import java.util.Collection; 038import java.util.HashMap; 039import java.util.HashSet; 040import java.util.List; 041import java.util.Map; 042import java.util.Objects; 043import java.util.Optional; 044import java.util.Set; 045import java.util.function.Function; 046import java.util.function.Predicate; 047import java.util.stream.Collectors; 048import java.util.stream.Stream; 049 050import javax.annotation.PostConstruct; 051import javax.inject.Inject; 052 053import org.fcrepo.config.AuthPropsConfig; 054import org.fcrepo.kernel.api.Transaction; 055import org.fcrepo.kernel.api.auth.ACLHandle; 056import org.fcrepo.kernel.api.auth.WebACAuthorization; 057import org.fcrepo.kernel.api.exception.PathNotFoundException; 058import org.fcrepo.kernel.api.exception.PathNotFoundRuntimeException; 059import org.fcrepo.kernel.api.exception.RepositoryException; 060import org.fcrepo.kernel.api.identifiers.FedoraId; 061import org.fcrepo.kernel.api.models.FedoraResource; 062import org.fcrepo.kernel.api.models.NonRdfSourceDescription; 063import org.fcrepo.kernel.api.models.ResourceFactory; 064import org.fcrepo.kernel.api.models.TimeMap; 065import org.fcrepo.kernel.api.models.WebacAcl; 066 067import org.apache.jena.graph.Triple; 068import org.apache.jena.rdf.model.Statement; 069import org.slf4j.Logger; 070import org.springframework.stereotype.Component; 071 072import com.github.benmanes.caffeine.cache.Cache; 073 074 075/** 076 * @author acoburn 077 * @since 9/3/15 078 */ 079@Component 080public class WebACRolesProvider { 081 082 private static final Logger LOGGER = getLogger(WebACRolesProvider.class); 083 084 private static final org.apache.jena.graph.Node RDF_TYPE_NODE = createURI(RDF_NAMESPACE + "type"); 085 private static final org.apache.jena.graph.Node VCARD_GROUP_NODE = createURI(VCARD_GROUP_VALUE); 086 private static final org.apache.jena.graph.Node VCARD_MEMBER_NODE = createURI(VCARD_MEMBER_VALUE); 087 088 @Inject 089 private AuthPropsConfig authPropsConfig; 090 091 @Inject 092 private ResourceFactory resourceFactory; 093 094 @Inject 095 private Cache<String, Optional<ACLHandle>> authHandleCache; 096 097 private String userBaseUri; 098 private String groupBaseUri; 099 100 @PostConstruct 101 public void setup() { 102 this.userBaseUri = authPropsConfig.getUserAgentBaseUri(); 103 this.groupBaseUri = authPropsConfig.getGroupAgentBaseUri(); 104 } 105 106 /** 107 * Get the roles assigned to a FedoraId using the default authorization. This allows non-resource transactions to 108 * retrieve an ACL without the need for a stub resource. 109 * 110 * @param id the subject id 111 * @param fedoraResource the parent resource of the id, most likely info:fedora 112 * @param transaction the transaction being acted upon 113 * @return a mapping of each principal to a set of its roles 114 */ 115 public Map<String, Collection<String>> getRoles(final FedoraId id, 116 final FedoraResource fedoraResource, 117 final Transaction transaction) { 118 LOGGER.debug("Getting agent roles for id: {}", id); 119 // Construct a list of acceptable acl:accessTo values for the target resource. 120 final List<String> resourcePaths = new ArrayList<>(); 121 122 // See if the root acl has been updated 123 final var effectiveAcl = authHandleCache.get(fedoraResource.getId(), 124 key -> getEffectiveAcl(fedoraResource, false)); 125 effectiveAcl.map(ACLHandle::getResource) 126 .filter(effectiveResource -> !effectiveResource.getId().equals(id.getResourceId())) 127 .ifPresent(effectiveResource -> { 128 resourcePaths.add(effectiveResource.getId()); 129 }); 130 131 132 // Add the tx + ancestor paths 133 resourcePaths.add(id.getResourceId()); 134 resourcePaths.addAll(getAllPathAncestors(id.getResourceId())); 135 136 // Create a function to check acl:accessTo, scoped to the given resourcePaths 137 final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths); 138 139 final var authorizations = effectiveAcl.map(ACLHandle::getAuthorizations) 140 .orElseGet(this::getDefaultAuthorizations); 141 final var effectiveRoles = getEffectiveRoles(authorizations, checkAccessTo, transaction); 142 143 LOGGER.debug("Unfiltered ACL: {}", effectiveRoles); 144 return effectiveRoles; 145 } 146 147 /** 148 * Get the roles assigned to this Node. 149 * 150 * @param resource the subject resource 151 * @param transaction the transaction being acted upon 152 * @return a set of roles for each principal 153 */ 154 public Map<String, Collection<String>> getRoles(final FedoraResource resource, final Transaction transaction) { 155 LOGGER.debug("Getting agent roles for resource: {}", resource.getId()); 156 157 // Get the effective ACL by searching the target node and any ancestors. 158 final Optional<ACLHandle> effectiveAcl = authHandleCache.get(resource.getId(), 159 key -> getEffectiveAcl(resource, false)); 160 161 // Construct a list of acceptable acl:accessTo values for the target resource. 162 final List<String> resourcePaths = new ArrayList<>(); 163 if (resource instanceof WebacAcl) { 164 // ACLs don't describe their resource, but we still want the container which is the resource. 165 resourcePaths.add(resource.getContainer().getId()); 166 } else { 167 resourcePaths.add(resource.getDescribedResource().getId()); 168 } 169 170 // Construct a list of acceptable acl:accessToClass values for the target resource. 171 final List<URI> rdfTypes = resource.getDescription().getTypes(); 172 173 // Add the resource location and types of the ACL-bearing parent, 174 // if present and if different than the target resource. 175 effectiveAcl 176 .map(ACLHandle::getResource) 177 .filter(effectiveResource -> !effectiveResource.getId().equals(resource.getId())) 178 .ifPresent(effectiveResource -> { 179 resourcePaths.add(effectiveResource.getId()); 180 rdfTypes.addAll(effectiveResource.getTypes()); 181 }); 182 183 // If we fall through to the system/classpath-based Authorization and it 184 // contains any acl:accessTo properties, it is necessary to add each ancestor 185 // path up the node hierarchy, starting at the resource location up to the 186 // root location. This way, the checkAccessTo predicate (below) can be properly 187 // created to match any acl:accessTo values that are part of the getDefaultAuthorization. 188 // This is not relevant if an effectiveAcl is present. 189 if (effectiveAcl.isEmpty()) { 190 resourcePaths.addAll(getAllPathAncestors(resource.getId())); 191 } 192 193 // Create a function to check acl:accessTo, scoped to the given resourcePaths 194 final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths); 195 196 // Create a function to check acl:accessToClass, scoped to the given rdf:type values, 197 // but transform the URIs to Strings first. 198 final Predicate<WebACAuthorization> checkAccessToClass = 199 accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList())); 200 201 // Read the effective Acl and return a list of acl:Authorization statements 202 final List<WebACAuthorization> authorizations = effectiveAcl 203 .map(ACLHandle::getAuthorizations) 204 .orElseGet(this::getDefaultAuthorizations); 205 206 // Filter the acl:Authorization statements so that they correspond only to statements that apply to 207 // the target (or acl-bearing ancestor) resource path or rdf:type. 208 // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION 209 // of acl:modes for each particular acl:agent. 210 final Map<String, Collection<String>> effectiveRoles = 211 getEffectiveRoles(authorizations, checkAccessTo.or(checkAccessToClass), transaction); 212 213 LOGGER.debug("Unfiltered ACL: {}", effectiveRoles); 214 215 return effectiveRoles; 216 } 217 218 /** 219 * Get the effective roles for a list of authorizations 220 * 221 * @param authorizations The authorizations to get roles for 222 * @param authorizationFilter The filter to apply on the list of roles 223 * @param transaction the transaction being acted upon 224 * @return a mapping of each principal to a set of its roles 225 */ 226 private Map<String, Collection<String>> getEffectiveRoles(final List<WebACAuthorization> authorizations, 227 final Predicate<WebACAuthorization> authorizationFilter, 228 final Transaction transaction) { 229 final Predicate<String> isFoafOrAuthenticated = (agentClass) -> 230 agentClass.equals(FOAF_AGENT_VALUE) || agentClass.equals(WEBAC_AUTHENTICATED_AGENT_VALUE); 231 232 final Map<String, Collection<String>> effectiveRoles = new HashMap<>(); 233 authorizations.stream() 234 .filter(authorizationFilter) 235 .forEach(auth -> { 236 final var modes = auth.getModes().stream().map(URI::toString).collect(toSet()); 237 concat(auth.getAgents().stream(), 238 dereferenceAgentGroups(transaction, auth.getAgentGroups()).stream()) 239 .filter(Predicate.not(isFoafOrAuthenticated)) 240 .forEach(agent -> { 241 effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>()) 242 .addAll(modes); 243 }); 244 auth.getAgentClasses() 245 .stream() 246 .filter(isFoafOrAuthenticated) 247 .forEach(agentClass -> { 248 effectiveRoles.computeIfAbsent(agentClass, key -> new HashSet<>()) 249 .addAll(modes); 250 }); 251 }); 252 return effectiveRoles; 253 } 254 255 /** 256 * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths. 257 * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/". 258 */ 259 private static List<String> getAllPathAncestors(final String path) { 260 final List<String> segments = asList(path.replace(FEDORA_ID_PREFIX, "").split("/")); 261 return range(1, segments.size()) 262 .mapToObj(frameSize -> { 263 final var subpath = String.join("/", segments.subList(1, frameSize)); 264 return FEDORA_ID_PREFIX + (!subpath.isBlank() ? "/" : "") + subpath; 265 }) 266 .collect(toList()); 267 } 268 269 /** 270 * This is a function for generating a Predicate that filters WebACAuthorizations according 271 * to whether the given acl:accessToClass values contain any of the rdf:type values provided 272 * when creating the predicate. 273 */ 274 private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth -> 275 uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri)); 276 277 /** 278 * This is a function for generating a Predicate that filters WebACAuthorizations according 279 * to whether the given acl:accessTo values contain any of the target resource values provided 280 * when creating the predicate. 281 */ 282 private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth -> 283 uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri)); 284 285 /** 286 * This maps a Collection of acl:agentGroup values to a List of agents. 287 * Any out-of-domain URIs are silently ignored. 288 */ 289 private List<String> dereferenceAgentGroups(final Transaction transaction, final Collection<String> agentGroups) { 290 final List<String> members = agentGroups.stream().flatMap(agentGroup -> { 291 if (agentGroup.startsWith(FEDORA_ID_PREFIX)) { 292 //strip off trailing hash. 293 final int hashIndex = agentGroup.indexOf("#"); 294 final String agentGroupNoHash = hashIndex > 0 ? 295 agentGroup.substring(0, hashIndex) : 296 agentGroup; 297 final String hashedSuffix = hashIndex > 0 ? agentGroup.substring(hashIndex) : null; 298 try { 299 final FedoraId fedoraId = FedoraId.create(agentGroupNoHash); 300 final FedoraResource resource = resourceFactory.getResource(transaction, fedoraId); 301 return getAgentMembers(resource, hashedSuffix); 302 } catch (final PathNotFoundException e) { 303 throw new PathNotFoundRuntimeException(e.getMessage(), e); 304 } 305 } else if (agentGroup.equals(FOAF_AGENT_VALUE)) { 306 return of(agentGroup); 307 } else { 308 LOGGER.info("Ignoring agentGroup: {}", agentGroup); 309 return empty(); 310 } 311 }).collect(toList()); 312 313 if (LOGGER.isDebugEnabled() && !agentGroups.isEmpty()) { 314 LOGGER.debug("Found {} members in {} agentGroups resources", members.size(), agentGroups.size()); 315 } 316 317 return members; 318 } 319 320 /** 321 * Given a FedoraResource, return a list of agents. 322 */ 323 private Stream<String> getAgentMembers(final FedoraResource resource, final String hashPortion) { 324 //resolve list of triples, accounting for hash-uris. 325 final List<Triple> triples = resource.getTriples().filter( 326 triple -> hashPortion == null || triple.getSubject().getURI().endsWith(hashPortion)).collect(toList()); 327 //determine if there is a rdf:type vcard:Group 328 final boolean hasVcardGroup = triples.stream().anyMatch( 329 triple -> triple.matches(triple.getSubject(), RDF_TYPE_NODE, VCARD_GROUP_NODE)); 330 //return members only if there is an associated vcard:Group 331 if (hasVcardGroup) { 332 return triples.stream() 333 .filter(triple -> triple.predicateMatches(VCARD_MEMBER_NODE)) 334 .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream) 335 .map(this::stripUserAgentBaseURI); 336 } else { 337 return empty(); 338 } 339 } 340 341 private String stripUserAgentBaseURI(final String object) { 342 if (userBaseUri != null && object.startsWith(userBaseUri)) { 343 return object.substring(userBaseUri.length()); 344 } 345 return object; 346 } 347 348 /** 349 * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream, 350 * making this suitable to use with flatMap. 351 */ 352 private static Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) { 353 if (object.isURI()) { 354 return of(object.getURI()); 355 } else if (object.isLiteral()) { 356 return of(object.getLiteralValue().toString()); 357 } else { 358 return empty(); 359 } 360 } 361 362 363 /** 364 * A simple predicate for filtering out any non-acl triples. 365 */ 366 private static final Predicate<Triple> hasAclPredicate = triple -> 367 triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE); 368 369 /** 370 * This function reads a Fedora ACL resource and all of its acl:Authorization children. 371 * The RDF from each child resource is put into a WebACAuthorization object, and the 372 * full list is returned. 373 * 374 * @param aclResource the ACL resource 375 * @param ancestorAcl flag indicating whether or not the ACL resource associated with an ancestor of the target 376 * resource 377 * @return a list of acl:Authorization objects 378 */ 379 private List<WebACAuthorization> getAuthorizations(final FedoraResource aclResource, 380 final boolean ancestorAcl) { 381 382 final List<WebACAuthorization> authorizations = new ArrayList<>(); 383 384 if (LOGGER.isDebugEnabled()) { 385 LOGGER.debug("ACL: {}", aclResource.getId()); 386 } 387 388 if (aclResource.isAcl()) { 389 //resolve set of subjects that are of type acl:authorization 390 final List<Triple> triples = aclResource.getTriples().collect(toList()); 391 392 final Set<org.apache.jena.graph.Node> authSubjects = triples.stream().filter(t -> { 393 return t.getPredicate().getURI().equals(RDF_NAMESPACE + "type") && 394 t.getObject().getURI().equals(WEBAC_AUTHORIZATION_VALUE); 395 }).map(t -> t.getSubject()).collect(Collectors.toSet()); 396 397 // Read resource, keeping only acl-prefixed triples. 398 final Map<String, Map<String, List<String>>> authMap = new HashMap<>(); 399 triples.stream().filter(hasAclPredicate) 400 .forEach(triple -> { 401 if (authSubjects.contains(triple.getSubject())) { 402 final Map<String, List<String>> aclTriples = 403 authMap.computeIfAbsent(triple.getSubject().getURI(), key -> new HashMap<>()); 404 405 final String predicate = triple.getPredicate().getURI(); 406 final List<String> values = aclTriples.computeIfAbsent(predicate, 407 key -> new ArrayList<>()); 408 nodeToStringStream(triple.getObject()).forEach(values::add); 409 if (predicate.equals(WEBAC_AGENT_VALUE)) { 410 additionalAgentValues(triple.getObject()).forEach(values::add); 411 } 412 } 413 }); 414 // Create a WebACAuthorization object from the provided triples. 415 if (LOGGER.isDebugEnabled()) { 416 LOGGER.debug("Adding acl:Authorization from {}", aclResource.getId()); 417 } 418 authMap.values().forEach(aclTriples -> { 419 final WebACAuthorization authorization = createAuthorizationFromMap(aclTriples); 420 //only include authorizations if the acl resource is not an ancestor acl 421 //or the authorization has at least one acl:default 422 if (!ancestorAcl || authorization.getDefaults().size() > 0) { 423 authorizations.add(authorization); 424 } 425 }); 426 } 427 428 return authorizations; 429 } 430 431 private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) { 432 return new WebACAuthorizationImpl( 433 data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()), 434 data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()), 435 data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream() 436 .map(URI::create).collect(toList()), 437 data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()), 438 data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList()), 439 data.getOrDefault(WEBAC_AGENT_GROUP_VALUE, emptyList()), 440 data.getOrDefault(WEBAC_DEFAULT_VALUE, emptyList())); 441 } 442 443 /** 444 * Recursively find the effective ACL as a URI along with the FedoraResource that points to it. 445 * This way, if the effective ACL is pointed to from a parent resource, the child will inherit 446 * any permissions that correspond to access to that parent. This ACL resource may or may not exist, 447 * and it may be external to the fedora repository. 448 * @param resource the Fedora resource 449 * @param ancestorAcl the flag for looking up ACL from ancestor hierarchy resources 450 */ 451 Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource, final boolean ancestorAcl) { 452 try { 453 454 final FedoraResource aclResource = resource.getAcl(); 455 456 if (aclResource != null) { 457 final List<WebACAuthorization> authorizations = 458 getAuthorizations(aclResource, ancestorAcl); 459 if (authorizations.size() > 0) { 460 return Optional.of( 461 new ACLHandleImpl(resource, authorizations)); 462 } 463 } 464 465 FedoraResource container = resource.getContainer(); 466 // The resource is not ldp:contained by anything, so checked its described resource. 467 if (container == null && (resource instanceof NonRdfSourceDescription || resource instanceof TimeMap)) { 468 final var described = resource.getDescribedResource(); 469 if (!Objects.equals(resource, described)) { 470 container = described; 471 } 472 } 473 if (container == null) { 474 LOGGER.debug("No ACLs defined on this node or in parent hierarchy"); 475 return Optional.empty(); 476 } else { 477 LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getId()); 478 return getEffectiveAcl(container, true); 479 } 480 } catch (final RepositoryException ex) { 481 LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage()); 482 return Optional.empty(); 483 } 484 } 485 486 private List<WebACAuthorization> getDefaultAuthorizations() { 487 final List<WebACAuthorization> authorizations = new ArrayList<>(); 488 489 final var defaultAcls = getDefaultAcl(null, authPropsConfig.getRootAuthAclPath()); 490 final var aclSubjects = defaultAcls.listSubjects(); 491 492 aclSubjects.forEach(aclResource -> { 493 final Map<String, List<String>> aclTriples = new HashMap<>(); 494 aclResource.listProperties().mapWith(Statement::asTriple).forEach(aclTriple -> { 495 if (hasAclPredicate.test(aclTriple)) { 496 final String predicate = aclTriple.getPredicate().getURI(); 497 final List<String> values = aclTriples.computeIfAbsent(predicate, key -> new ArrayList<>()); 498 nodeToStringStream(aclTriple.getObject()).forEach(values::add); 499 if (predicate.equals(WEBAC_AGENT_VALUE)) { 500 additionalAgentValues(aclTriple.getObject()).forEach(values::add); 501 } 502 } 503 }); 504 505 if (!aclTriples.isEmpty()) { 506 authorizations.add(createAuthorizationFromMap(aclTriples)); 507 } 508 }); 509 510 return authorizations; 511 } 512 513 private Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) { 514 if (object.isURI()) { 515 final String uri = object.getURI(); 516 if (userBaseUri != null && uri.startsWith(userBaseUri)) { 517 return of(uri.substring(userBaseUri.length())); 518 } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) { 519 return of(uri.substring(groupBaseUri.length())); 520 } 521 } 522 return empty(); 523 } 524 525 /* 526 * The below two methods are ONLY used by tests and so invalidating the cache should not have any impact. 527 */ 528 529 /** 530 * @param userBaseUri the user base uri 531 */ 532 public void setUserBaseUri(final String userBaseUri) { 533 this.userBaseUri = userBaseUri; 534 authHandleCache.invalidateAll(); 535 } 536 537 /** 538 * @param groupBaseUri the group base uri 539 */ 540 public void setGroupBaseUri(final String groupBaseUri) { 541 this.groupBaseUri = groupBaseUri; 542 authHandleCache.invalidateAll(); 543 } 544}