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