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