001/**
002 * Copyright 2015 DuraSpace, Inc.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.fcrepo.auth.webac;
017
018import static java.util.Collections.unmodifiableList;
019import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel;
020import static org.apache.commons.lang3.StringUtils.substringBeforeLast;
021import static org.apache.jena.riot.Lang.TTL;
022import static org.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE;
023import static org.fcrepo.auth.webac.URIConstants.FOAF_GROUP;
024import static org.fcrepo.auth.webac.URIConstants.FOAF_MEMBER_VALUE;
025import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESS_CONTROL_VALUE;
026import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_CLASS_VALUE;
027import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_VALUE;
028import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_CLASS_VALUE;
029import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_VALUE;
030import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHORIZATION;
031import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_VALUE;
032import static org.fcrepo.auth.webac.URIConstants.WEBAC_NAMESPACE_VALUE;
033import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
034import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.isNonRdfSourceDescription;
035import static org.fcrepo.kernel.modeshape.utils.UncheckedFunction.uncheck;
036import static org.modeshape.jcr.api.JcrConstants.JCR_CONTENT;
037import static org.slf4j.LoggerFactory.getLogger;
038
039import java.io.File;
040import java.io.InputStream;
041import java.io.IOException;
042import java.net.URI;
043import java.util.AbstractMap;
044import java.util.ArrayList;
045import java.util.Arrays;
046import java.util.Collection;
047import java.util.HashMap;
048import java.util.HashSet;
049import java.util.List;
050import java.util.Map;
051import java.util.Optional;
052import java.util.Set;
053import java.util.function.Function;
054import java.util.function.Predicate;
055import java.util.stream.Collectors;
056import java.util.stream.IntStream;
057import java.util.stream.Stream;
058
059import javax.jcr.Node;
060import javax.jcr.RepositoryException;
061import javax.jcr.Session;
062
063import org.fcrepo.auth.roles.common.AccessRolesProvider;
064import org.fcrepo.http.commons.session.SessionFactory;
065import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
066import org.fcrepo.kernel.api.models.FedoraResource;
067import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
068import org.fcrepo.kernel.api.services.NodeService;
069import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator;
070import org.fcrepo.kernel.modeshape.rdf.impl.PropertiesRdfContext;
071import org.fcrepo.kernel.modeshape.utils.UncheckedPredicate;
072
073import org.apache.commons.lang3.tuple.Pair;
074import org.modeshape.jcr.value.Path;
075import org.slf4j.Logger;
076import org.springframework.beans.factory.annotation.Autowired;
077
078import com.hp.hpl.jena.graph.Triple;
079import com.hp.hpl.jena.rdf.model.Model;
080import com.hp.hpl.jena.rdf.model.Property;
081import com.hp.hpl.jena.rdf.model.Resource;
082import com.hp.hpl.jena.shared.JenaException;
083
084/**
085 * @author acoburn
086 * @since 9/3/15
087 */
088public class WebACRolesProvider implements AccessRolesProvider {
089
090    public static final String ROOT_AUTHORIZATION_PROPERTY = "fcrepo.auth.webac.authorization";
091
092    private static final Logger LOGGER = getLogger(WebACRolesProvider.class);
093
094    private static final List<String> EMPTY = unmodifiableList(new ArrayList<>());
095
096    private static final String FEDORA_INTERNAL_PREFIX = "info:fedora";
097
098    private static final String ROOT_AUTHORIZATION_LOCATION = "/root-authorization.ttl";
099
100    @Autowired
101    private NodeService nodeService;
102
103    @Autowired
104    private SessionFactory sessionFactory;
105
106    @Override
107    public void postRoles(final Node node, final Map<String, Set<String>> data) throws RepositoryException {
108        throw new UnsupportedOperationException("postRoles() is not implemented");
109    }
110
111    @Override
112    public void deleteRoles(final Node node) throws RepositoryException {
113        throw new UnsupportedOperationException("deleteRoles() is not implemented");
114    }
115
116    @Override
117    public Map<String, List<String>> findRolesForPath(final Path absPath, final Session session)
118            throws RepositoryException {
119        return getAgentRoles(locateResource(absPath, session));
120    }
121
122    private FedoraResource locateResource(final Path path, final Session session) {
123
124        final Predicate<Path> exists = UncheckedPredicate.uncheck(x -> session.nodeExists(x.toString()));
125
126        if (exists.test(path) || path.isRoot()) {
127            LOGGER.debug("findRolesForPath: {}", path.getString());
128            return nodeService.find(session, path.toString());
129        }
130        LOGGER.trace("Path: {} does not exist, checking parent", path.getString());
131        return locateResource(path.getParent(), session);
132    }
133
134    @Override
135    public Map<String, List<String>> getRoles(final Node node, final boolean effective) {
136        return getAgentRoles(nodeService.cast(node));
137    }
138
139    /**
140     *  For a given FedoraResource, get a mapping of acl:agent values to acl:mode values.
141     */
142    private Map<String, List<String>> getAgentRoles(final FedoraResource resource) {
143        LOGGER.debug("Getting agent roles for: {}", resource.getPath());
144
145        // Get the effective ACL by searching the target node and any ancestors.
146        final Optional<Pair<URI, FedoraResource>> effectiveAcl = getEffectiveAcl(
147                isNonRdfSourceDescription.test(resource.getNode()) ?
148                    ((NonRdfSourceDescription)nodeConverter.convert(resource.getNode())).getDescribedResource() :
149                    resource);
150
151        // Construct a list of acceptable acl:accessTo values for the target resource.
152        final List<String> resourcePaths = new ArrayList<>();
153        resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getPath());
154
155        // Construct a list of acceptable acl:accessToClass values for the target resource.
156        final List<URI> rdfTypes = resource.getTypes();
157
158        // Add the resource location and types of the ACL-bearing parent,
159        // if present and if different than the target resource.
160        effectiveAcl
161            .map(x -> x.getRight())
162            .filter(x -> !x.getPath().equals(resource.getPath()))
163            .ifPresent(x -> {
164                resourcePaths.add(FEDORA_INTERNAL_PREFIX + x.getPath());
165                rdfTypes.addAll(x.getTypes());
166            });
167
168        // If we fall through to the system/classpath-based Authorization and it
169        // contains any acl:accessTo properties, it is necessary to add each ancestor
170        // path up the node hierarchy, starting at the resource location up to the
171        // root location. This way, the checkAccessTo predicate (below) can be properly
172        // created to match any acl:accessTo values that are part of the getDefaultAuthorization.
173        // This is not relevant if an effectiveAcl is present.
174        if (!effectiveAcl.isPresent()) {
175            resourcePaths.addAll(getAllPathAncestors(resource.getPath()));
176        }
177
178        // Create a function to check acl:accessTo, scoped to the given resourcePaths
179        final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths);
180
181        // Create a function to check acl:accessToClass, scoped to the given rdf:type values,
182        // but transform the URIs to Strings first.
183        final Predicate<WebACAuthorization> checkAccessToClass =
184            accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(Collectors.toList()));
185
186        // Read the effective Acl and return a list of acl:Authorization statements
187        final List<WebACAuthorization> authorizations = effectiveAcl
188                .map(uncheck(x -> getAuthorizations(x.getLeft().toString())))
189                .orElseGet(() -> getDefaultAuthorizations());
190
191        // Filter the acl:Authorization statements so that they correspond only to statements that apply to
192        // the target (or acl-bearing ancestor) resource path or rdf:type.
193        // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION
194        // of acl:modes for each particular acl:agent.
195        final Map<String, Set<String>> effectiveRoles = new HashMap<>();
196        authorizations.stream()
197            .filter(x -> checkAccessTo.test(x) || checkAccessToClass.test(x))
198            .forEach(x -> {
199                Stream.concat(x.getAgents().stream(), dereferenceAgentClasses(x.getAgentClasses()).stream())
200                    .distinct()
201                    .forEach(y -> {
202                        effectiveRoles.putIfAbsent(y, new HashSet<>());
203                        effectiveRoles.get(y).addAll(
204                            x.getModes().stream()
205                                        .map(URI::toString)
206                                        .collect(Collectors.toList()));
207                    });
208            });
209
210        LOGGER.debug("Unfiltered ACL: {}", effectiveRoles);
211
212        // Transform the effectiveRoles from a Set to a List.
213        return effectiveRoles.entrySet().stream()
214            .map(x -> new AbstractMap.SimpleEntry<>(x.getKey(), new ArrayList<>(x.getValue())))
215            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
216    }
217
218    /**
219     * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths.
220     * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/".
221     */
222    private List<String> getAllPathAncestors(final String path) {
223        final List<String> segments = Arrays.asList(path.split("/"));
224        return IntStream.range(1, segments.size()).boxed()
225                    .map(x -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, x)))
226                    .collect(Collectors.toList());
227    }
228
229    /**
230     *  This is a function for generating a Predicate that filters WebACAuthorizations according
231     *  to whether the given acl:accessToClass values contain any of the rdf:type values provided
232     *  when creating the predicate.
233     */
234    private Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> x -> {
235        return uris.stream()
236                   .distinct()
237                   .filter(y -> x.getAccessToClassURIs().contains(y))
238                   .findFirst()
239                   .isPresent();
240    };
241
242    /**
243     *  This is a function for generating a Predicate that filters WebACAuthorizations according
244     *  to whether the given acl:accessTo values contain any of the target resource values provided
245     *  when creating the predicate.
246     */
247    private Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> x -> {
248        return uris.stream()
249                   .distinct()
250                   .filter(y -> x.getAccessToURIs().contains(y))
251                   .findFirst()
252                   .isPresent();
253    };
254
255    /**
256     *  This maps a Collection of acl:agentClass values to a List of agents.
257     *  Any out-of-domain URIs are silently ignored.
258     */
259    private List<String> dereferenceAgentClasses(final Collection<String> agentClasses) {
260        final Session internalSession = sessionFactory.getInternalSession();
261        final IdentifierConverter<Resource, FedoraResource> translator =
262                new DefaultIdentifierTranslator(internalSession);
263
264        final List<String> members = new ArrayList<>();
265        agentClasses.stream()
266                    .distinct()
267                    .forEach(x -> {
268                        if (x.startsWith(FEDORA_INTERNAL_PREFIX)) {
269                            final FedoraResource resource = nodeService.find(
270                                internalSession, x.substring(FEDORA_INTERNAL_PREFIX.length()));
271                            members.addAll(getAgentMembers(translator, resource));
272                        } else if (x.equals(FOAF_AGENT_VALUE)) {
273                            members.add(x);
274                        } else {
275                            LOGGER.info("Ignoring agentClass: {}", x);
276                        }
277                    });
278
279        if (LOGGER.isDebugEnabled() && !agentClasses.isEmpty()) {
280            LOGGER.debug("Found {} members in {} agentClass resources", members.size(), agentClasses.size());
281        }
282
283        return members;
284    }
285
286    /**
287     *  Given a FedoraResource, return a list of agents.
288     */
289    private Set<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator,
290            final FedoraResource resource) {
291        final Set<String> members = new HashSet<>();
292        final Model model = createDefaultModel();
293
294        final Predicate<Property> isMember = memberTestFromTypes.apply(resource.getTypes());
295
296        resource.getTriples(translator, PropertiesRdfContext.class)
297            .filter(p -> isMember.test(model.asStatement(p).getPredicate()))
298            .forEachRemaining(t -> {
299                if (t.getObject().isURI()) {
300                    members.add(t.getObject().getURI());
301                } else if (t.getObject().isLiteral()) {
302                    members.add(t.getObject().getLiteralValue().toString());
303                }
304            });
305
306        return members;
307    }
308
309    /**
310     *  A simple predicate for filtering out any non-foaf:member properties
311     */
312    final Function<List<URI>, Predicate<Property>> memberTestFromTypes = types -> {
313        final Set<URI> typeLookup = new HashSet<>(types);
314        return p -> !p.isAnon() &&
315            typeLookup.contains(FOAF_GROUP) && p.getURI().equals(FOAF_MEMBER_VALUE);
316    };
317
318    /**
319     *  A simple predicate for filtering out any non-acl triples.
320     */
321    static final Predicate<Property> isAclPredicate =
322         p -> !p.isAnon() && p.getNameSpace().startsWith(WEBAC_NAMESPACE_VALUE);
323
324    /**
325     *  This function reads a Fedora ACL resource and all of its acl:Authorization children.
326     *  The RDF from each child resource is put into a WebACAuthorization object, and the
327     *  full list is returned.
328     *
329     *  @param location the location of the ACL resource
330     *  @return a list of acl:Authorization objects
331     */
332    private List<WebACAuthorization> getAuthorizations(final String location) {
333
334        final Session internalSession = sessionFactory.getInternalSession();
335        final List<WebACAuthorization> authorizations = new ArrayList<>();
336        final IdentifierConverter<Resource, FedoraResource> translator =
337                new DefaultIdentifierTranslator(internalSession);
338        final Model model = createDefaultModel();
339
340        LOGGER.debug("Effective ACL: {}", location);
341
342        // Find the specified ACL resource
343
344        if (location.startsWith(FEDORA_INTERNAL_PREFIX)) {
345
346            final FedoraResource resource = nodeService.find(internalSession,
347                    location.substring(FEDORA_INTERNAL_PREFIX.length()));
348
349            // Read each child resource, filtering on acl:Authorization type, keeping only acl-prefixed triples.
350            resource.getChildren().forEachRemaining(child -> {
351                if (child.getTypes().contains(WEBAC_AUTHORIZATION)) {
352                    final Map<String, List<String>> aclTriples = new HashMap<>();
353                    child.getTriples(translator, PropertiesRdfContext.class)
354                        .filter(p -> isAclPredicate.test(model.asStatement(p).getPredicate()))
355                        .forEachRemaining(t -> {
356                            aclTriples.putIfAbsent(t.getPredicate().getURI(), new ArrayList<>());
357                            if (t.getObject().isURI()) {
358                                aclTriples.get(t.getPredicate().getURI()).add(
359                                    substringBeforeLast(t.getObject().getURI(), "/" + JCR_CONTENT));
360                            } else if (t.getObject().isLiteral()) {
361                                aclTriples.get(t.getPredicate().getURI()).add(
362                                    t.getObject().getLiteralValue().toString());
363                            }
364                        });
365                    // Create a WebACAuthorization object from the provided triples.
366                    LOGGER.debug("Adding acl:Authorization from {}", child.getPath());
367                    authorizations.add(createAuthorizationFromMap(aclTriples));
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, EMPTY),
377                    data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, EMPTY),
378                    data.getOrDefault(WEBAC_MODE_VALUE, EMPTY).stream()
379                                .map(URI::create).collect(Collectors.toList()),
380                    data.getOrDefault(WEBAC_ACCESSTO_VALUE, EMPTY),
381                    data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, EMPTY));
382    }
383
384    /**
385     * Recursively find the effective ACL as a URI along with the FedoraResource that points to it.
386     * This way, if the effective ACL is pointed to from a parent resource, the child will inherit
387     * any permissions that correspond to access to that parent. This ACL resource may or may not exist,
388     * and it may be external to the fedora repository.
389     */
390    private static Optional<Pair<URI, FedoraResource>> getEffectiveAcl(final FedoraResource resource) {
391        try {
392            final IdentifierConverter<Resource, FedoraResource> translator =
393                new DefaultIdentifierTranslator(resource.getNode().getSession());
394            final List<String> acls = new ArrayList<>();
395            final Model model = createDefaultModel();
396
397            resource.getTriples(translator, PropertiesRdfContext.class)
398                .filter(t -> model.asStatement(t).getPredicate().hasURI(WEBAC_ACCESS_CONTROL_VALUE))
399                .filter(t -> t.getObject().isURI())
400                .forEachRemaining(t -> {
401                    acls.add(t.getObject().getURI());
402                });
403
404            if (!acls.isEmpty()) {
405                if (acls.size() > 1) {
406                    LOGGER.warn("Found multiple ACLs defined for this node. Using: {}", acls.get(0));
407                }
408                return Optional.of(Pair.of(URI.create(acls.get(0)), resource));
409            } else if (resource.getNode().getDepth() == 0) {
410                LOGGER.debug("No ACLs defined on this node or in parent hierarchy");
411                return Optional.empty();
412            } else {
413                LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath());
414                return getEffectiveAcl(resource.getContainer());
415            }
416        } catch (final RepositoryException ex) {
417            LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage());
418            return Optional.empty();
419        }
420    }
421
422    private List<WebACAuthorization> getDefaultAuthorizations() {
423        final Map<String, List<String>> aclTriples = new HashMap<>();
424        final List<WebACAuthorization> authorizations = new ArrayList<>();
425
426        getDefaultAcl().listStatements().forEachRemaining(x -> {
427            if (isAclPredicate.test(x.getPredicate())) {
428                final Triple t = x.asTriple();
429                aclTriples.putIfAbsent(t.getPredicate().getURI(), new ArrayList<>());
430                if (t.getObject().isURI()) {
431                    aclTriples.get(t.getPredicate().getURI()).add(t.getObject().getURI());
432                } else if (t.getObject().isLiteral()) {
433                    aclTriples.get(t.getPredicate().getURI()).add(
434                        t.getObject().getLiteralValue().toString());
435                }
436            }
437        });
438
439        authorizations.add(createAuthorizationFromMap(aclTriples));
440
441        return authorizations;
442    }
443
444    private static Model getDefaultAcl() {
445        final String rootAcl = System.getProperty(ROOT_AUTHORIZATION_PROPERTY);
446        final Model model = createDefaultModel();
447
448        if (rootAcl != null && new File(rootAcl).isFile()) {
449            try {
450                LOGGER.debug("Getting root authorization from file: {}", rootAcl);
451                return model.read(rootAcl);
452            } catch (final JenaException ex) {
453                LOGGER.error("Error parsing root authorization file: {}", ex.getMessage());
454            }
455        }
456        try (final InputStream is = WebACRolesProvider.class.getResourceAsStream(ROOT_AUTHORIZATION_LOCATION)) {
457            LOGGER.debug("Getting root authorization from classpath: {}", ROOT_AUTHORIZATION_LOCATION);
458            return model.read(is, null, TTL.getName());
459        } catch (final IOException ex) {
460            LOGGER.error("Error reading root authorization file: {}", ex.getMessage());
461        } catch (final JenaException ex) {
462            LOGGER.error("Error parsing root authorization file: {}", ex.getMessage());
463        }
464        return createDefaultModel();
465    }
466}