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