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}