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;
049
050import java.net.URI;
051import java.util.ArrayList;
052import java.util.Collection;
053import java.util.HashMap;
054import java.util.HashSet;
055import java.util.List;
056import java.util.Map;
057import java.util.Optional;
058import java.util.Set;
059import java.util.function.Function;
060import java.util.function.Predicate;
061import java.util.stream.Collectors;
062import java.util.stream.Stream;
063import javax.inject.Inject;
064import javax.jcr.Node;
065import javax.jcr.RepositoryException;
066
067import org.apache.jena.graph.Triple;
068import org.apache.jena.rdf.model.Resource;
069import org.apache.jena.rdf.model.Statement;
070import org.fcrepo.http.commons.session.SessionFactory;
071import org.fcrepo.kernel.api.FedoraSession;
072import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
073import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
074import org.fcrepo.kernel.api.models.FedoraResource;
075import org.fcrepo.kernel.api.services.NodeService;
076import org.fcrepo.kernel.modeshape.FedoraSessionImpl;
077import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator;
078import org.slf4j.Logger;
079
080/**
081 * @author acoburn
082 * @since 9/3/15
083 */
084public class WebACRolesProvider {
085
086    public static final String GROUP_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.groupAgent.baseUri";
087
088    private static final Logger LOGGER = getLogger(WebACRolesProvider.class);
089
090    private static final String FEDORA_INTERNAL_PREFIX = "info:fedora";
091
092    private static final String JCR_VERSIONABLE_UUID_PROPERTY = "jcr:versionableUuid";
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 NodeService nodeService;
100
101    @Inject
102    private SessionFactory sessionFactory;
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        try {
112            return getAgentRoles(nodeService.find(new FedoraSessionImpl(node.getSession()), node.getPath()));
113        } catch (final RepositoryException ex) {
114            throw new RepositoryRuntimeException(ex);
115        }
116    }
117
118    /**
119     *  For a given FedoraResource, get a mapping of acl:agent values to acl:mode values and
120     *  for foaf:Agent and acl:AuthenticatedAgent include the acl:agentClass value to acl:mode.
121     */
122    private Map<String, Collection<String>> getAgentRoles(final FedoraResource resource) {
123        LOGGER.debug("Getting agent roles for: {}", resource.getPath());
124
125        // Get the effective ACL by searching the target node and any ancestors.
126        final Optional<ACLHandle> effectiveAcl = getEffectiveAcl(resource, false, sessionFactory);
127
128        // Construct a list of acceptable acl:accessTo values for the target resource.
129        final List<String> resourcePaths = new ArrayList<>();
130        resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getPath());
131
132        // Construct a list of acceptable acl:accessToClass values for the target resource.
133        final List<URI> rdfTypes = resource.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.getPath().equals(resource.getPath()))
140            .ifPresent(effectiveResource -> {
141                resourcePaths.add(FEDORA_INTERNAL_PREFIX + effectiveResource.getPath());
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.getPath()));
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(), dereferenceAgentGroups(auth.getAgentGroups()).stream())
177                              .filter(agent -> !agent.equals(FOAF_AGENT_VALUE) &&
178                                               !agent.equals(WEBAC_AUTHENTICATED_AGENT_VALUE))
179                              .forEach(agent -> {
180                                  effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>())
181                                                .addAll(auth.getModes().stream().map(URI::toString).collect(toSet()));
182                              });
183                          auth.getAgentClasses().stream().filter(agentClass -> agentClass.equals(FOAF_AGENT_VALUE) ||
184                                                                               agentClass.equals(
185                                                                                   WEBAC_AUTHENTICATED_AGENT_VALUE))
186                              .forEach(agentClass -> {
187                                  effectiveRoles.computeIfAbsent(agentClass, key -> new HashSet<>())
188                                                .addAll(auth.getModes().stream().map(URI::toString).collect(toSet()));
189                              });
190                      });
191
192        LOGGER.debug("Unfiltered ACL: {}", effectiveRoles);
193
194        return effectiveRoles;
195    }
196
197    /**
198     * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths.
199     * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/".
200     */
201    private static List<String> getAllPathAncestors(final String path) {
202        final List<String> segments = asList(path.split("/"));
203        return range(1, segments.size())
204                .mapToObj(frameSize -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, frameSize)))
205                .collect(toList());
206    }
207
208    /**
209     *  This is a function for generating a Predicate that filters WebACAuthorizations according
210     *  to whether the given acl:accessToClass values contain any of the rdf:type values provided
211     *  when creating the predicate.
212     */
213    private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth ->
214        uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri));
215
216    /**
217     *  This is a function for generating a Predicate that filters WebACAuthorizations according
218     *  to whether the given acl:accessTo values contain any of the target resource values provided
219     *  when creating the predicate.
220     */
221    private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth ->
222        uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri));
223
224    /**
225     *  This maps a Collection of acl:agentGroup values to a List of agents.
226     *  Any out-of-domain URIs are silently ignored.
227     */
228    private List<String> dereferenceAgentGroups(final Collection<String> agentGroups) {
229        final FedoraSession internalSession = sessionFactory.getInternalSession();
230        final IdentifierConverter<Resource, FedoraResource> translator =
231                new DefaultIdentifierTranslator(getJcrSession(internalSession));
232
233        final List<String> members = agentGroups.stream().flatMap(agentGroup -> {
234            if (agentGroup.startsWith(FEDORA_INTERNAL_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                final FedoraResource resource = nodeService.find(
242                    internalSession, agentGroupNoHash.substring(FEDORA_INTERNAL_PREFIX.length()));
243                return getAgentMembers(translator, resource, hashedSuffix);
244            } else if (agentGroup.equals(FOAF_AGENT_VALUE)) {
245                return of(agentGroup);
246            } else {
247                LOGGER.info("Ignoring agentGroup: {}", agentGroup);
248                return empty();
249            }
250        }).collect(toList());
251
252        if (LOGGER.isDebugEnabled() && !agentGroups.isEmpty()) {
253            LOGGER.debug("Found {} members in {} agentGroups resources", members.size(), agentGroups.size());
254        }
255
256        return members;
257    }
258
259    /**
260     * Given a FedoraResource, return a list of agents.
261     */
262    private static Stream<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator,
263                                                  final FedoraResource resource, final String hashPortion) {
264
265        //resolve list of triples, accounting for hash-uris.
266        final List<Triple> triples = resource.getTriples(translator, PROPERTIES).filter(
267            triple -> hashPortion == null || triple.getSubject().getURI().endsWith(hashPortion)).collect(toList());
268        //determine if there is a rdf:type vcard:Group
269        final boolean hasVcardGroup = triples.stream().anyMatch(
270            triple -> triple.matches(triple.getSubject(), RDF_TYPE_NODE, VCARD_GROUP_NODE));
271        //return members only if there is an associated vcard:Group
272        if (hasVcardGroup) {
273            return triples.stream()
274                          .filter(triple -> triple.predicateMatches(VCARD_MEMBER_NODE))
275                          .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream)
276                                                 .map(WebACRolesProvider::stripUserAgentBaseURI);
277        } else {
278            return empty();
279        }
280    }
281
282    private static String stripUserAgentBaseURI(final String object) {
283        final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY);
284        if (userBaseUri != null && object.startsWith(userBaseUri)) {
285            return object.substring(userBaseUri.length());
286        }
287        return object;
288    }
289
290    /**
291     * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream,
292     * making this suitable to use with flatMap.
293     */
294    private static Stream<String> nodeToStringStream(final org.apache.jena.graph.Node object) {
295        if (object.isURI()) {
296            return of(object.getURI());
297        } else if (object.isLiteral()) {
298            return of(object.getLiteralValue().toString());
299        } else {
300            return empty();
301        }
302    }
303
304
305    /**
306     *  A simple predicate for filtering out any non-acl triples.
307     */
308    private static final Predicate<Triple> hasAclPredicate = triple ->
309        triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE);
310
311    /**
312     * This function reads a Fedora ACL resource and all of its acl:Authorization children.
313     * The RDF from each child resource is put into a WebACAuthorization object, and the
314     * full list is returned.
315     *
316     * @param aclResource the ACL resource
317     * @param ancestorAcl flag indicating whether or not the ACL resource associated with an ancestor of the target
318     *                    resource
319     * @param sessionFactory the session factory
320     * @return a list of acl:Authorization objects
321     */
322    private static List<WebACAuthorization> getAuthorizations(final FedoraResource aclResource,
323                                                              final boolean ancestorAcl,
324                                                              final SessionFactory sessionFactory) {
325
326        final FedoraSession internalSession = sessionFactory.getInternalSession();
327        final List<WebACAuthorization> authorizations = new ArrayList<>();
328        final IdentifierConverter<Resource, FedoraResource> translator =
329                new DefaultIdentifierTranslator(getJcrSession(internalSession));
330
331        if (LOGGER.isDebugEnabled()) {
332            LOGGER.debug("ACL: {}", aclResource.getPath());
333        }
334
335        if (aclResource.isAcl()) {
336            //resolve set of subjects that are of type acl:authorization
337            final List<Triple> triples = aclResource.getTriples(translator, PROPERTIES).collect(toList());
338
339            final Set<org.apache.jena.graph.Node> authSubjects = triples.stream().filter(t -> {
340                return t.getPredicate().getURI().equals(RDF_NAMESPACE + "type") &&
341                       t.getObject().getURI().equals(WEBAC_AUTHORIZATION_VALUE);
342            }).map(t -> t.getSubject()).collect(Collectors.toSet());
343
344            // Read resource, keeping only acl-prefixed triples.
345            final Map<String, Map<String, List<String>>> authMap = new HashMap<>();
346            triples.stream().filter(hasAclPredicate)
347                    .forEach(triple -> {
348                        if (authSubjects.contains(triple.getSubject())) {
349                            final Map<String, List<String>> aclTriples =
350                                authMap.computeIfAbsent(triple.getSubject().getURI(), key -> new HashMap<>());
351
352                            final String predicate = triple.getPredicate().getURI();
353                            final List<String> values = aclTriples.computeIfAbsent(predicate,
354                                                                                   key -> new ArrayList<>());
355                            nodeToStringStream(triple.getObject()).forEach(values::add);
356                            if (predicate.equals(WEBAC_AGENT_VALUE)) {
357                                additionalAgentValues(triple.getObject()).forEach(values::add);
358                            }
359                        }
360                    });
361            // Create a WebACAuthorization object from the provided triples.
362            if (LOGGER.isDebugEnabled()) {
363                LOGGER.debug("Adding acl:Authorization from {}", aclResource.getPath());
364            }
365            authMap.values().forEach(aclTriples -> {
366                final WebACAuthorization authorization = createAuthorizationFromMap(aclTriples);
367                //only include authorizations if the acl resource is not an ancestor acl
368                //or the authorization has at least one acl:default
369                if (!ancestorAcl || authorization.getDefaults().size() > 0) {
370                    authorizations.add(authorization);
371                }
372            });
373        }
374
375        return authorizations;
376    }
377
378    private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) {
379        return new WebACAuthorization(
380                data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()),
381                data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()),
382                data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream()
383                .map(URI::create).collect(toList()),
384                data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()),
385                data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList()),
386                data.getOrDefault(WEBAC_AGENT_GROUP_VALUE, emptyList()),
387                data.getOrDefault(WEBAC_DEFAULT_VALUE, emptyList()));
388    }
389
390    /**
391     * Recursively find the effective ACL as a URI along with the FedoraResource that points to it.
392     * This way, if the effective ACL is pointed to from a parent resource, the child will inherit
393     * any permissions that correspond to access to that parent. This ACL resource may or may not exist,
394     * and it may be external to the fedora repository.
395     * @param resource the Fedora resource
396     * @param ancestorAcl the flag for looking up ACL from ancestor hierarchy resources
397     * @param sessionFactory session factory
398     */
399    static Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource, final boolean ancestorAcl,
400                                                final SessionFactory sessionFactory) {
401        try {
402
403            final FedoraResource aclResource = resource.getAcl();
404
405            if (aclResource != null) {
406                final List<WebACAuthorization> authorizations =
407                    getAuthorizations(aclResource, ancestorAcl, sessionFactory);
408                if (authorizations.size() > 0) {
409                    return Optional.of(
410                        new ACLHandle(resource, authorizations));
411                }
412            }
413
414            if (getJcrNode(resource).getDepth() == 0) {
415                LOGGER.debug("No ACLs defined on this node or in parent hierarchy");
416                return Optional.empty();
417            } else {
418                LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath());
419                return getEffectiveAcl(resource.getContainer(), true, sessionFactory);
420            }
421        } catch (final RepositoryException ex) {
422            LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage());
423            return Optional.empty();
424        }
425    }
426
427    private static List<WebACAuthorization> getDefaultAuthorizations() {
428        final Map<String, List<String>> aclTriples = new HashMap<>();
429        final List<WebACAuthorization> authorizations = new ArrayList<>();
430
431        getDefaultAcl(null).listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> {
432            if (hasAclPredicate.test(triple)) {
433                final String predicate = triple.getPredicate().getURI();
434                final List<String> values = aclTriples.computeIfAbsent(predicate,
435                    key -> new ArrayList<>());
436                nodeToStringStream(triple.getObject()).forEach(values::add);
437                if (predicate.equals(WEBAC_AGENT_VALUE)) {
438                    additionalAgentValues(triple.getObject()).forEach(values::add);
439                }
440            }
441        });
442
443        authorizations.add(createAuthorizationFromMap(aclTriples));
444        return authorizations;
445    }
446
447    private static Stream<String> additionalAgentValues(final org.apache.jena.graph.Node object) {
448        final String groupBaseUri = System.getProperty(GROUP_AGENT_BASE_URI_PROPERTY);
449        final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY);
450
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}