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}