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 this Node. 108 * 109 * @param resource the subject resource 110 * @param transaction the transaction being acted upon 111 * @return a set of roles for each principal 112 */ 113 public Map<String, Collection<String>> getRoles(final FedoraResource resource, final Transaction transaction) { 114 LOGGER.debug("Getting agent roles for: {}", resource.getId()); 115 116 117 // Get the effective ACL by searching the target node and any ancestors. 118 final Optional<ACLHandle> effectiveAcl = authHandleCache.get(resource.getId(), 119 key -> getEffectiveAcl(resource,false)); 120 121 // Construct a list of acceptable acl:accessTo values for the target resource. 122 final List<String> resourcePaths = new ArrayList<>(); 123 if (resource instanceof WebacAcl) { 124 // ACLs don't describe their resource, but we still want the container which is the resource. 125 resourcePaths.add(resource.getContainer().getId()); 126 } else { 127 resourcePaths.add(resource.getDescribedResource().getId()); 128 } 129 130 // Construct a list of acceptable acl:accessToClass values for the target resource. 131 final List<URI> rdfTypes = resource.getDescription().getTypes(); 132 133 // Add the resource location and types of the ACL-bearing parent, 134 // if present and if different than the target resource. 135 effectiveAcl 136 .map(ACLHandle::getResource) 137 .filter(effectiveResource -> !effectiveResource.getId().equals(resource.getId())) 138 .ifPresent(effectiveResource -> { 139 resourcePaths.add(effectiveResource.getId()); 140 rdfTypes.addAll(effectiveResource.getTypes()); 141 }); 142 143 // If we fall through to the system/classpath-based Authorization and it 144 // contains any acl:accessTo properties, it is necessary to add each ancestor 145 // path up the node hierarchy, starting at the resource location up to the 146 // root location. This way, the checkAccessTo predicate (below) can be properly 147 // created to match any acl:accessTo values that are part of the getDefaultAuthorization. 148 // This is not relevant if an effectiveAcl is present. 149 if (!effectiveAcl.isPresent()) { 150 resourcePaths.addAll(getAllPathAncestors(resource.getId())); 151 } 152 153 // Create a function to check acl:accessTo, scoped to the given resourcePaths 154 final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths); 155 156 // Create a function to check acl:accessToClass, scoped to the given rdf:type values, 157 // but transform the URIs to Strings first. 158 final Predicate<WebACAuthorization> checkAccessToClass = 159 accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList())); 160 161 // Read the effective Acl and return a list of acl:Authorization statements 162 final List<WebACAuthorization> authorizations = effectiveAcl 163 .map(ACLHandle::getAuthorizations) 164 .orElseGet(() -> getDefaultAuthorizations()); 165 166 // Filter the acl:Authorization statements so that they correspond only to statements that apply to 167 // the target (or acl-bearing ancestor) resource path or rdf:type. 168 // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION 169 // of acl:modes for each particular acl:agent. 170 final Map<String, Collection<String>> effectiveRoles = new HashMap<>(); 171 authorizations.stream() 172 .filter(checkAccessTo.or(checkAccessToClass)) 173 .forEach(auth -> { 174 concat(auth.getAgents().stream(), 175 dereferenceAgentGroups(transaction, auth.getAgentGroups()).stream()) 176 .filter(agent -> !agent.equals(FOAF_AGENT_VALUE) && 177 !agent.equals(WEBAC_AUTHENTICATED_AGENT_VALUE)) 178 .forEach(agent -> { 179 effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>()) 180 .addAll(auth.getModes().stream().map(URI::toString).collect(toSet())); 181 }); 182 auth.getAgentClasses().stream().filter(agentClass -> agentClass.equals(FOAF_AGENT_VALUE) || 183 agentClass.equals( 184 WEBAC_AUTHENTICATED_AGENT_VALUE)) 185 .forEach(agentClass -> { 186 effectiveRoles.computeIfAbsent(agentClass, key -> new HashSet<>()) 187 .addAll(auth.getModes().stream().map(URI::toString).collect(toSet())); 188 }); 189 }); 190 191 LOGGER.debug("Unfiltered ACL: {}", effectiveRoles); 192 193 return effectiveRoles; 194 } 195 196 /** 197 * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths. 198 * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/". 199 */ 200 private static List<String> getAllPathAncestors(final String path) { 201 final List<String> segments = asList(path.replace(FEDORA_ID_PREFIX, "").split("/")); 202 return range(1, segments.size()) 203 .mapToObj(frameSize -> { 204 final var subpath = String.join("/", segments.subList(1, frameSize)); 205 return FEDORA_ID_PREFIX + (!subpath.isBlank() ? "/" : "") + subpath; 206 }) 207 .collect(toList()); 208 } 209 210 /** 211 * This is a function for generating a Predicate that filters WebACAuthorizations according 212 * to whether the given acl:accessToClass values contain any of the rdf:type values provided 213 * when creating the predicate. 214 */ 215 private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth -> 216 uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri)); 217 218 /** 219 * This is a function for generating a Predicate that filters WebACAuthorizations according 220 * to whether the given acl:accessTo values contain any of the target resource values provided 221 * when creating the predicate. 222 */ 223 private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth -> 224 uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri)); 225 226 /** 227 * This maps a Collection of acl:agentGroup values to a List of agents. 228 * Any out-of-domain URIs are silently ignored. 229 */ 230 private List<String> dereferenceAgentGroups(final Transaction transaction, final Collection<String> agentGroups) { 231 final List<String> members = agentGroups.stream().flatMap(agentGroup -> { 232 if (agentGroup.startsWith(FEDORA_ID_PREFIX)) { 233 //strip off trailing hash. 234 final int hashIndex = agentGroup.indexOf("#"); 235 final String agentGroupNoHash = hashIndex > 0 ? 236 agentGroup.substring(0, hashIndex) : 237 agentGroup; 238 final String hashedSuffix = hashIndex > 0 ? agentGroup.substring(hashIndex) : null; 239 try { 240 final FedoraId fedoraId = FedoraId.create(agentGroupNoHash); 241 final FedoraResource resource = resourceFactory.getResource(transaction, fedoraId); 242 return getAgentMembers(resource, hashedSuffix); 243 } catch (final PathNotFoundException e) { 244 throw new PathNotFoundRuntimeException(e.getMessage(), e); 245 } 246 } else if (agentGroup.equals(FOAF_AGENT_VALUE)) { 247 return of(agentGroup); 248 } else { 249 LOGGER.info("Ignoring agentGroup: {}", agentGroup); 250 return empty(); 251 } 252 }).collect(toList()); 253 254 if (LOGGER.isDebugEnabled() && !agentGroups.isEmpty()) { 255 LOGGER.debug("Found {} members in {} agentGroups resources", members.size(), agentGroups.size()); 256 } 257 258 return members; 259 } 260 261 /** 262 * Given a FedoraResource, return a list of agents. 263 */ 264 private Stream<String> getAgentMembers(final FedoraResource resource, final String hashPortion) { 265 //resolve list of triples, accounting for hash-uris. 266 final List<Triple> triples = resource.getTriples().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(this::stripUserAgentBaseURI); 277 } else { 278 return empty(); 279 } 280 } 281 282 private String stripUserAgentBaseURI(final String object) { 283 if (userBaseUri != null && object.startsWith(userBaseUri)) { 284 return object.substring(userBaseUri.length()); 285 } 286 return object; 287 } 288 289 /** 290 * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream, 291 * making this suitable to use with flatMap. 292 */ 293 private static Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) { 294 if (object.isURI()) { 295 return of(object.getURI()); 296 } else if (object.isLiteral()) { 297 return of(object.getLiteralValue().toString()); 298 } else { 299 return empty(); 300 } 301 } 302 303 304 /** 305 * A simple predicate for filtering out any non-acl triples. 306 */ 307 private static final Predicate<Triple> hasAclPredicate = triple -> 308 triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE); 309 310 /** 311 * This function reads a Fedora ACL resource and all of its acl:Authorization children. 312 * The RDF from each child resource is put into a WebACAuthorization object, and the 313 * full list is returned. 314 * 315 * @param aclResource the ACL resource 316 * @param ancestorAcl flag indicating whether or not the ACL resource associated with an ancestor of the target 317 * resource 318 * @return a list of acl:Authorization objects 319 */ 320 private List<WebACAuthorization> getAuthorizations(final FedoraResource aclResource, 321 final boolean ancestorAcl) { 322 323 final List<WebACAuthorization> authorizations = new ArrayList<>(); 324 325 if (LOGGER.isDebugEnabled()) { 326 LOGGER.debug("ACL: {}", aclResource.getId()); 327 } 328 329 if (aclResource.isAcl()) { 330 //resolve set of subjects that are of type acl:authorization 331 final List<Triple> triples = aclResource.getTriples().collect(toList()); 332 333 final Set<org.apache.jena.graph.Node> authSubjects = triples.stream().filter(t -> { 334 return t.getPredicate().getURI().equals(RDF_NAMESPACE + "type") && 335 t.getObject().getURI().equals(WEBAC_AUTHORIZATION_VALUE); 336 }).map(t -> t.getSubject()).collect(Collectors.toSet()); 337 338 // Read resource, keeping only acl-prefixed triples. 339 final Map<String, Map<String, List<String>>> authMap = new HashMap<>(); 340 triples.stream().filter(hasAclPredicate) 341 .forEach(triple -> { 342 if (authSubjects.contains(triple.getSubject())) { 343 final Map<String, List<String>> aclTriples = 344 authMap.computeIfAbsent(triple.getSubject().getURI(), key -> new HashMap<>()); 345 346 final String predicate = triple.getPredicate().getURI(); 347 final List<String> values = aclTriples.computeIfAbsent(predicate, 348 key -> new ArrayList<>()); 349 nodeToStringStream(triple.getObject()).forEach(values::add); 350 if (predicate.equals(WEBAC_AGENT_VALUE)) { 351 additionalAgentValues(triple.getObject()).forEach(values::add); 352 } 353 } 354 }); 355 // Create a WebACAuthorization object from the provided triples. 356 if (LOGGER.isDebugEnabled()) { 357 LOGGER.debug("Adding acl:Authorization from {}", aclResource.getId()); 358 } 359 authMap.values().forEach(aclTriples -> { 360 final WebACAuthorization authorization = createAuthorizationFromMap(aclTriples); 361 //only include authorizations if the acl resource is not an ancestor acl 362 //or the authorization has at least one acl:default 363 if (!ancestorAcl || authorization.getDefaults().size() > 0) { 364 authorizations.add(authorization); 365 } 366 }); 367 } 368 369 return authorizations; 370 } 371 372 private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) { 373 return new WebACAuthorizationImpl( 374 data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()), 375 data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()), 376 data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream() 377 .map(URI::create).collect(toList()), 378 data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()), 379 data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList()), 380 data.getOrDefault(WEBAC_AGENT_GROUP_VALUE, emptyList()), 381 data.getOrDefault(WEBAC_DEFAULT_VALUE, emptyList())); 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 * @param resource the Fedora resource 390 * @param ancestorAcl the flag for looking up ACL from ancestor hierarchy resources 391 */ 392 Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource, final boolean ancestorAcl) { 393 try { 394 395 final FedoraResource aclResource = resource.getAcl(); 396 397 if (aclResource != null) { 398 final List<WebACAuthorization> authorizations = 399 getAuthorizations(aclResource, ancestorAcl); 400 if (authorizations.size() > 0) { 401 return Optional.of( 402 new ACLHandleImpl(resource, authorizations)); 403 } 404 } 405 406 FedoraResource container = resource.getContainer(); 407 // The resource is not ldp:contained by anything, so checked its described resource. 408 if (container == null && (resource instanceof NonRdfSourceDescription || resource instanceof TimeMap)) { 409 final var described = resource.getDescribedResource(); 410 if (!Objects.equals(resource, described)) { 411 container = described; 412 } 413 } 414 if (container == null) { 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.getId()); 419 return getEffectiveAcl(container, true); 420 } 421 } catch (final RepositoryException ex) { 422 LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage()); 423 return Optional.empty(); 424 } 425 } 426 427 private List<WebACAuthorization> getDefaultAuthorizations() { 428 final Map<String, List<String>> aclTriples = new HashMap<>(); 429 final List<WebACAuthorization> authorizations = new ArrayList<>(); 430 431 getDefaultAcl(null, authPropsConfig.getRootAuthAclPath()) 432 .listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> { 433 if (hasAclPredicate.test(triple)) { 434 final String predicate = triple.getPredicate().getURI(); 435 final List<String> values = aclTriples.computeIfAbsent(predicate, 436 key -> new ArrayList<>()); 437 nodeToStringStream(triple.getObject()).forEach(values::add); 438 if (predicate.equals(WEBAC_AGENT_VALUE)) { 439 additionalAgentValues(triple.getObject()).forEach(values::add); 440 } 441 } 442 }); 443 444 authorizations.add(createAuthorizationFromMap(aclTriples)); 445 return authorizations; 446 } 447 448 private Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) { 449 if (object.isURI()) { 450 final String uri = object.getURI(); 451 if (userBaseUri != null && uri.startsWith(userBaseUri)) { 452 return of(uri.substring(userBaseUri.length())); 453 } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) { 454 return of(uri.substring(groupBaseUri.length())); 455 } 456 } 457 return empty(); 458 } 459 460 /* 461 * The below two methods are ONLY used by tests and so invalidating the cache should not have any impact. 462 */ 463 464 /** 465 * @param userBaseUri the user base uri 466 */ 467 public void setUserBaseUri(final String userBaseUri) { 468 this.userBaseUri = userBaseUri; 469 authHandleCache.invalidateAll(); 470 } 471 472 /** 473 * @param groupBaseUri the group base uri 474 */ 475 public void setGroupBaseUri(final String groupBaseUri) { 476 this.groupBaseUri = groupBaseUri; 477 authHandleCache.invalidateAll(); 478 } 479}