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