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.RdfLexicon.RDF_NAMESPACE;
044import static org.fcrepo.kernel.api.RequiredRdfContext.PROPERTIES;
045import static org.fcrepo.kernel.modeshape.FedoraSessionImpl.getJcrSession;
046import static org.fcrepo.kernel.modeshape.utils.FedoraSessionUserUtil.USER_AGENT_BASE_URI_PROPERTY;
047import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode;
048import static org.slf4j.LoggerFactory.getLogger;
049import java.net.URI;
050import java.util.ArrayList;
051import java.util.Collection;
052import java.util.HashMap;
053import java.util.HashSet;
054import java.util.List;
055import java.util.Map;
056import java.util.Optional;
057import java.util.Set;
058import java.util.function.Function;
059import java.util.function.Predicate;
060import java.util.stream.Collectors;
061import java.util.stream.Stream;
062import javax.inject.Inject;
063import javax.jcr.Node;
064import javax.jcr.RepositoryException;
065
066import org.apache.jena.graph.Triple;
067import org.apache.jena.rdf.model.Resource;
068import org.apache.jena.rdf.model.Statement;
069import org.fcrepo.http.commons.session.SessionFactory;
070import org.fcrepo.kernel.api.FedoraSession;
071import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
072import org.fcrepo.kernel.api.models.FedoraResource;
073import org.fcrepo.kernel.api.services.NodeService;
074import org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter;
075import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator;
076import org.slf4j.Logger;
077
078/**
079 * @author acoburn
080 * @since 9/3/15
081 */
082public class WebACRolesProvider {
083
084    public static final String GROUP_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.groupAgent.baseUri";
085
086    private static final Logger LOGGER = getLogger(WebACRolesProvider.class);
087
088    private static final String FEDORA_INTERNAL_PREFIX = "info:fedora";
089
090    private static final String JCR_VERSIONABLE_UUID_PROPERTY = "jcr:versionableUuid";
091
092    private static final org.apache.jena.graph.Node RDF_TYPE_NODE = createURI(RDF_NAMESPACE + "type");
093    private static final org.apache.jena.graph.Node VCARD_GROUP_NODE = createURI(VCARD_GROUP_VALUE);
094    private static final org.apache.jena.graph.Node VCARD_MEMBER_NODE = createURI(VCARD_MEMBER_VALUE);
095
096    @Inject
097    private NodeService nodeService;
098
099    @Inject
100    private SessionFactory sessionFactory;
101
102    private final NodeResourceConverter nodeConverter = NodeResourceConverter.nodeConverter;
103
104    /**
105     * Get the roles assigned to this Node.
106     *
107     * @param node the subject Node
108     * @return a set of roles for each principal
109     */
110    public Map<String, Collection<String>> getRoles(final Node node) {
111        return getAgentRoles(nodeConverter.convert(node));
112    }
113
114    /**
115     *  For a given FedoraResource, get a mapping of acl:agent values to acl:mode values and
116     *  for foaf:Agent and acl:AuthenticatedAgent include the acl:agentClass value to acl:mode.
117     */
118    private Map<String, Collection<String>> getAgentRoles(final FedoraResource resource) {
119        LOGGER.debug("Getting agent roles for: {}", resource.getPath());
120
121        // Get the effective ACL by searching the target node and any ancestors.
122        final Optional<ACLHandle> effectiveAcl = getEffectiveAcl(resource, false, sessionFactory);
123
124        // Construct a list of acceptable acl:accessTo values for the target resource.
125        final List<String> resourcePaths = new ArrayList<>();
126        resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getDescribedResource().getPath());
127
128        // Construct a list of acceptable acl:accessToClass values for the target resource.
129        final List<URI> rdfTypes = resource.getDescription().getTypes();
130
131        // Add the resource location and types of the ACL-bearing parent,
132        // if present and if different than the target resource.
133        effectiveAcl
134            .map(aclHandle -> aclHandle.resource)
135            .filter(effectiveResource -> !effectiveResource.getPath().equals(resource.getPath()))
136            .ifPresent(effectiveResource -> {
137                resourcePaths.add(FEDORA_INTERNAL_PREFIX + effectiveResource.getPath());
138                rdfTypes.addAll(effectiveResource.getTypes());
139            });
140
141        // If we fall through to the system/classpath-based Authorization and it
142        // contains any acl:accessTo properties, it is necessary to add each ancestor
143        // path up the node hierarchy, starting at the resource location up to the
144        // root location. This way, the checkAccessTo predicate (below) can be properly
145        // created to match any acl:accessTo values that are part of the getDefaultAuthorization.
146        // This is not relevant if an effectiveAcl is present.
147        if (!effectiveAcl.isPresent()) {
148            resourcePaths.addAll(getAllPathAncestors(resource.getPath()));
149        }
150
151        // Create a function to check acl:accessTo, scoped to the given resourcePaths
152        final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths);
153
154        // Create a function to check acl:accessToClass, scoped to the given rdf:type values,
155        // but transform the URIs to Strings first.
156        final Predicate<WebACAuthorization> checkAccessToClass =
157            accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList()));
158
159        // Read the effective Acl and return a list of acl:Authorization statements
160        final List<WebACAuthorization> authorizations = effectiveAcl
161                .map(auth -> auth.authorizations)
162                .orElseGet(() -> getDefaultAuthorizations());
163
164        // Filter the acl:Authorization statements so that they correspond only to statements that apply to
165        // the target (or acl-bearing ancestor) resource path or rdf:type.
166        // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION
167        // of acl:modes for each particular acl:agent.
168        final Map<String, Collection<String>> effectiveRoles = new HashMap<>();
169        authorizations.stream()
170                      .filter(checkAccessTo.or(checkAccessToClass))
171                      .forEach(auth -> {
172                          concat(auth.getAgents().stream(), dereferenceAgentGroups(auth.getAgentGroups()).stream())
173                              .filter(agent -> !agent.equals(FOAF_AGENT_VALUE) &&
174                                               !agent.equals(WEBAC_AUTHENTICATED_AGENT_VALUE))
175                              .forEach(agent -> {
176                                  effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>())
177                                                .addAll(auth.getModes().stream().map(URI::toString).collect(toSet()));
178                              });
179                          auth.getAgentClasses().stream().filter(agentClass -> agentClass.equals(FOAF_AGENT_VALUE) ||
180                                                                               agentClass.equals(
181                                                                                   WEBAC_AUTHENTICATED_AGENT_VALUE))
182                              .forEach(agentClass -> {
183                                  effectiveRoles.computeIfAbsent(agentClass, key -> new HashSet<>())
184                                                .addAll(auth.getModes().stream().map(URI::toString).collect(toSet()));
185                              });
186                      });
187
188        LOGGER.debug("Unfiltered ACL: {}", effectiveRoles);
189
190        return effectiveRoles;
191    }
192
193    /**
194     * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths.
195     * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/".
196     */
197    private static List<String> getAllPathAncestors(final String path) {
198        final List<String> segments = asList(path.split("/"));
199        return range(1, segments.size())
200                .mapToObj(frameSize -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, frameSize)))
201                .collect(toList());
202    }
203
204    /**
205     *  This is a function for generating a Predicate that filters WebACAuthorizations according
206     *  to whether the given acl:accessToClass values contain any of the rdf:type values provided
207     *  when creating the predicate.
208     */
209    private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth ->
210        uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri));
211
212    /**
213     *  This is a function for generating a Predicate that filters WebACAuthorizations according
214     *  to whether the given acl:accessTo values contain any of the target resource values provided
215     *  when creating the predicate.
216     */
217    private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth ->
218        uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri));
219
220    /**
221     *  This maps a Collection of acl:agentGroup values to a List of agents.
222     *  Any out-of-domain URIs are silently ignored.
223     */
224    private List<String> dereferenceAgentGroups(final Collection<String> agentGroups) {
225        final FedoraSession internalSession = sessionFactory.getInternalSession();
226        final IdentifierConverter<Resource, FedoraResource> translator =
227                new DefaultIdentifierTranslator(getJcrSession(internalSession));
228
229        final List<String> members = agentGroups.stream().flatMap(agentGroup -> {
230            if (agentGroup.startsWith(FEDORA_INTERNAL_PREFIX)) {
231                //strip off trailing hash.
232                final int hashIndex = agentGroup.indexOf("#");
233                final String agentGroupNoHash = hashIndex > 0 ?
234                                         agentGroup.substring(0, hashIndex) :
235                                         agentGroup;
236                final String hashedSuffix = hashIndex > 0 ? agentGroup.substring(hashIndex) : null;
237                final FedoraResource resource = nodeService.find(
238                    internalSession, agentGroupNoHash.substring(FEDORA_INTERNAL_PREFIX.length()));
239                return getAgentMembers(translator, resource, hashedSuffix);
240            } else if (agentGroup.equals(FOAF_AGENT_VALUE)) {
241                return of(agentGroup);
242            } else {
243                LOGGER.info("Ignoring agentGroup: {}", agentGroup);
244                return empty();
245            }
246        }).collect(toList());
247
248        if (LOGGER.isDebugEnabled() && !agentGroups.isEmpty()) {
249            LOGGER.debug("Found {} members in {} agentGroups resources", members.size(), agentGroups.size());
250        }
251
252        return members;
253    }
254
255    /**
256     * Given a FedoraResource, return a list of agents.
257     */
258    private static Stream<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator,
259                                                  final FedoraResource resource, final String hashPortion) {
260
261        //resolve list of triples, accounting for hash-uris.
262        final List<Triple> triples = resource.getTriples(translator, PROPERTIES).filter(
263            triple -> hashPortion == null || triple.getSubject().getURI().endsWith(hashPortion)).collect(toList());
264        //determine if there is a rdf:type vcard:Group
265        final boolean hasVcardGroup = triples.stream().anyMatch(
266            triple -> triple.matches(triple.getSubject(), RDF_TYPE_NODE, VCARD_GROUP_NODE));
267        //return members only if there is an associated vcard:Group
268        if (hasVcardGroup) {
269            return triples.stream()
270                          .filter(triple -> triple.predicateMatches(VCARD_MEMBER_NODE))
271                          .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream)
272                                                 .map(WebACRolesProvider::stripUserAgentBaseURI);
273        } else {
274            return empty();
275        }
276    }
277
278    private static String stripUserAgentBaseURI(final String object) {
279        final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY);
280        if (userBaseUri != null && object.startsWith(userBaseUri)) {
281            return object.substring(userBaseUri.length());
282        }
283        return object;
284    }
285
286    /**
287     * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream,
288     * making this suitable to use with flatMap.
289     */
290    private static Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) {
291        if (object.isURI()) {
292            return of(object.getURI());
293        } else if (object.isLiteral()) {
294            return of(object.getLiteralValue().toString());
295        } else {
296            return empty();
297        }
298    }
299
300
301    /**
302     *  A simple predicate for filtering out any non-acl triples.
303     */
304    private static final Predicate<Triple> hasAclPredicate = triple ->
305        triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE);
306
307    /**
308     * This function reads a Fedora ACL resource and all of its acl:Authorization children.
309     * The RDF from each child resource is put into a WebACAuthorization object, and the
310     * full list is returned.
311     *
312     * @param aclResource the ACL resource
313     * @param ancestorAcl flag indicating whether or not the ACL resource associated with an ancestor of the target
314     *                    resource
315     * @param sessionFactory the session factory
316     * @return a list of acl:Authorization objects
317     */
318    private static List<WebACAuthorization> getAuthorizations(final FedoraResource aclResource,
319                                                              final boolean ancestorAcl,
320                                                              final SessionFactory sessionFactory) {
321
322        final FedoraSession internalSession = sessionFactory.getInternalSession();
323        final List<WebACAuthorization> authorizations = new ArrayList<>();
324        final IdentifierConverter<Resource, FedoraResource> translator =
325                new DefaultIdentifierTranslator(getJcrSession(internalSession));
326
327        if (LOGGER.isDebugEnabled()) {
328            LOGGER.debug("ACL: {}", aclResource.getPath());
329        }
330
331        if (aclResource.isAcl()) {
332            //resolve set of subjects that are of type acl:authorization
333            final List<Triple> triples = aclResource.getTriples(translator, PROPERTIES).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.getPath());
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     * @param sessionFactory session factory
394     */
395    static Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource, final boolean ancestorAcl,
396                                                final SessionFactory sessionFactory) {
397        try {
398
399            final FedoraResource aclResource = resource.getAcl();
400
401            if (aclResource != null) {
402                final List<WebACAuthorization> authorizations =
403                    getAuthorizations(aclResource, ancestorAcl, sessionFactory);
404                if (authorizations.size() > 0) {
405                    return Optional.of(
406                        new ACLHandle(resource, authorizations));
407                }
408            }
409
410            if (getJcrNode(resource).getDepth() == 0) {
411                LOGGER.debug("No ACLs defined on this node or in parent hierarchy");
412                return Optional.empty();
413            } else {
414                LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath());
415                return getEffectiveAcl(resource.getContainer(), true, sessionFactory);
416            }
417        } catch (final RepositoryException ex) {
418            LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage());
419            return Optional.empty();
420        }
421    }
422
423    private static List<WebACAuthorization> getDefaultAuthorizations() {
424        final Map<String, List<String>> aclTriples = new HashMap<>();
425        final List<WebACAuthorization> authorizations = new ArrayList<>();
426
427        getDefaultAcl(null).listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> {
428            if (hasAclPredicate.test(triple)) {
429                final String predicate = triple.getPredicate().getURI();
430                final List<String> values = aclTriples.computeIfAbsent(predicate,
431                    key -> new ArrayList<>());
432                nodeToStringStream(triple.getObject()).forEach(values::add);
433                if (predicate.equals(WEBAC_AGENT_VALUE)) {
434                    additionalAgentValues(triple.getObject()).forEach(values::add);
435                }
436            }
437        });
438
439        authorizations.add(createAuthorizationFromMap(aclTriples));
440        return authorizations;
441    }
442
443    private static Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) {
444        final String groupBaseUri = System.getProperty(GROUP_AGENT_BASE_URI_PROPERTY);
445        final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY);
446
447        if (object.isURI()) {
448            final String uri = object.getURI();
449            if (userBaseUri != null && uri.startsWith(userBaseUri)) {
450                return of(uri.substring(userBaseUri.length()));
451            } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) {
452                return of(uri.substring(groupBaseUri.length()));
453            }
454        }
455        return empty();
456    }
457}