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}