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 com.hp.hpl.jena.graph.NodeFactory.createURI;
029import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel;
030import static org.apache.jena.riot.Lang.TTL;
031import static org.fcrepo.auth.webac.URIConstants.FOAF_AGENT_VALUE;
032import static org.fcrepo.auth.webac.URIConstants.FOAF_GROUP;
033import static org.fcrepo.auth.webac.URIConstants.FOAF_MEMBER_VALUE;
034import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESS_CONTROL_VALUE;
035import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_CLASS_VALUE;
036import static org.fcrepo.auth.webac.URIConstants.WEBAC_ACCESSTO_VALUE;
037import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_CLASS_VALUE;
038import static org.fcrepo.auth.webac.URIConstants.WEBAC_AGENT_VALUE;
039import static org.fcrepo.auth.webac.URIConstants.WEBAC_AUTHORIZATION;
040import static org.fcrepo.auth.webac.URIConstants.WEBAC_MODE_VALUE;
041import static org.fcrepo.auth.webac.URIConstants.WEBAC_NAMESPACE_VALUE;
042import static org.fcrepo.kernel.api.RequiredRdfContext.PROPERTIES;
043import static org.fcrepo.kernel.modeshape.identifiers.NodeResourceConverter.nodeConverter;
044import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.isNonRdfSourceDescription;
045import static org.fcrepo.kernel.modeshape.utils.FedoraTypesUtils.getJcrNode;
046import static org.slf4j.LoggerFactory.getLogger;
047
048import java.io.File;
049import java.io.InputStream;
050import java.io.IOException;
051import java.net.URI;
052import java.util.ArrayList;
053import java.util.Collection;
054import java.util.HashMap;
055import java.util.HashSet;
056import java.util.List;
057import java.util.Map;
058import java.util.Optional;
059import java.util.Set;
060import java.util.function.Function;
061import java.util.function.Predicate;
062import java.util.stream.Stream;
063
064import javax.inject.Inject;
065import javax.jcr.ItemNotFoundException;
066import javax.jcr.Node;
067import javax.jcr.RepositoryException;
068import javax.jcr.Session;
069import javax.jcr.version.Version;
070import javax.jcr.version.VersionHistory;
071
072import org.fcrepo.auth.roles.common.AccessRolesProvider;
073import org.fcrepo.http.commons.session.SessionFactory;
074import org.fcrepo.kernel.api.exception.MalformedRdfException;
075import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
076import org.fcrepo.kernel.api.identifiers.IdentifierConverter;
077import org.fcrepo.kernel.api.models.FedoraResource;
078import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
079import org.fcrepo.kernel.api.services.NodeService;
080import org.fcrepo.kernel.modeshape.rdf.impl.DefaultIdentifierTranslator;
081
082import org.modeshape.jcr.value.Path;
083import org.slf4j.Logger;
084
085import com.hp.hpl.jena.graph.Triple;
086import com.hp.hpl.jena.rdf.model.Model;
087import com.hp.hpl.jena.rdf.model.Resource;
088import com.hp.hpl.jena.rdf.model.Statement;
089import com.hp.hpl.jena.shared.JenaException;
090
091/**
092 * @author acoburn
093 * @since 9/3/15
094 */
095public class WebACRolesProvider implements AccessRolesProvider {
096
097    public static final String ROOT_AUTHORIZATION_PROPERTY = "fcrepo.auth.webac.authorization";
098
099    public static final String GROUP_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.groupAgent.baseUri";
100
101    public static final String USER_AGENT_BASE_URI_PROPERTY = "fcrepo.auth.webac.userAgent.baseUri";
102
103    private static final Logger LOGGER = getLogger(WebACRolesProvider.class);
104
105    private static final String FEDORA_INTERNAL_PREFIX = "info:fedora";
106
107    private static final String ROOT_AUTHORIZATION_LOCATION = "/root-authorization.ttl";
108
109    private static final String JCR_VERSIONABLE_UUID_PROPERTY = "jcr:versionableUuid";
110
111    @Inject
112    private NodeService nodeService;
113
114    @Inject
115    private SessionFactory sessionFactory;
116
117    @Override
118    public void postRoles(final Node node, final Map<String, Set<String>> data) throws RepositoryException {
119        throw new UnsupportedOperationException("postRoles() is not implemented");
120    }
121
122    @Override
123    public void deleteRoles(final Node node) throws RepositoryException {
124        throw new UnsupportedOperationException("deleteRoles() is not implemented");
125    }
126
127    @Override
128    public Map<String, Collection<String>> findRolesForPath(final Path absPath, final Session session)
129            throws RepositoryException {
130        return getAgentRoles(locateResource(absPath, session));
131    }
132
133    private FedoraResource locateResource(final Path path, final Session session) {
134        try {
135            if (session.nodeExists(path.toString()) || path.isRoot()) {
136                LOGGER.debug("findRolesForPath: {}", path.getString());
137                final FedoraResource resource = nodeService.find(session, path.toString());
138
139                if (resource.hasType("nt:version")) {
140                    LOGGER.debug("{} is a version, getting the baseVersion", resource);
141                    return getBaseVersion(resource);
142                }
143                return resource;
144            }
145        } catch (final RepositoryException ex) {
146            throw new RepositoryRuntimeException(ex);
147        }
148        LOGGER.trace("Path: {} does not exist, checking parent", path.getString());
149        return locateResource(path.getParent(), session);
150    }
151
152    /**
153     * Get the versionable FedoraResource for this version resource
154     *
155     * @param resource the Version resource
156     * @return the base versionable resource or the version if not found.
157     */
158    private FedoraResource getBaseVersion(final FedoraResource resource) {
159        final Session internalSession = sessionFactory.getInternalSession();
160
161        try {
162            final VersionHistory base = ((Version) getJcrNode(resource)).getContainingHistory();
163            if (base.hasProperty(JCR_VERSIONABLE_UUID_PROPERTY)) {
164                final String versionUuid = base.getProperty(JCR_VERSIONABLE_UUID_PROPERTY).getValue().getString();
165                LOGGER.debug("versionableUuid : {}", versionUuid);
166                return nodeService.find(internalSession, internalSession.getNodeByIdentifier(versionUuid).getPath());
167            }
168        } catch (final ItemNotFoundException e) {
169            LOGGER.error("Node with jcr:versionableUuid not found : {}", e.getMessage());
170        } catch (final RepositoryException e) {
171            throw new RepositoryRuntimeException(e);
172        }
173        return resource;
174    }
175
176    @Override
177    public Map<String, Collection<String>> getRoles(final Node node, final boolean effective) {
178        try {
179            return getAgentRoles(nodeService.find(node.getSession(), node.getPath()));
180        } catch (final RepositoryException ex) {
181            throw new RepositoryRuntimeException(ex);
182        }
183    }
184
185    /**
186     *  For a given FedoraResource, get a mapping of acl:agent values to acl:mode values.
187     */
188    private Map<String, Collection<String>> getAgentRoles(final FedoraResource resource) {
189        LOGGER.debug("Getting agent roles for: {}", resource.getPath());
190
191        // Get the effective ACL by searching the target node and any ancestors.
192        final Optional<ACLHandle> effectiveAcl = getEffectiveAcl(
193                isNonRdfSourceDescription.test(getJcrNode(resource)) ?
194                    ((NonRdfSourceDescription)nodeConverter.convert(getJcrNode(resource))).getDescribedResource() :
195                    resource);
196
197        // Construct a list of acceptable acl:accessTo values for the target resource.
198        final List<String> resourcePaths = new ArrayList<>();
199        resourcePaths.add(FEDORA_INTERNAL_PREFIX + resource.getPath());
200
201        // Construct a list of acceptable acl:accessToClass values for the target resource.
202        final List<URI> rdfTypes = resource.getTypes();
203
204        // Add the resource location and types of the ACL-bearing parent,
205        // if present and if different than the target resource.
206        effectiveAcl
207            .map(aclHandle -> aclHandle.resource)
208            .filter(effectiveResource -> !effectiveResource.getPath().equals(resource.getPath()))
209            .ifPresent(effectiveResource -> {
210                resourcePaths.add(FEDORA_INTERNAL_PREFIX + effectiveResource.getPath());
211                rdfTypes.addAll(effectiveResource.getTypes());
212            });
213
214        // If we fall through to the system/classpath-based Authorization and it
215        // contains any acl:accessTo properties, it is necessary to add each ancestor
216        // path up the node hierarchy, starting at the resource location up to the
217        // root location. This way, the checkAccessTo predicate (below) can be properly
218        // created to match any acl:accessTo values that are part of the getDefaultAuthorization.
219        // This is not relevant if an effectiveAcl is present.
220        if (!effectiveAcl.isPresent()) {
221            resourcePaths.addAll(getAllPathAncestors(resource.getPath()));
222        }
223
224        // Create a function to check acl:accessTo, scoped to the given resourcePaths
225        final Predicate<WebACAuthorization> checkAccessTo = accessTo.apply(resourcePaths);
226
227        // Create a function to check acl:accessToClass, scoped to the given rdf:type values,
228        // but transform the URIs to Strings first.
229        final Predicate<WebACAuthorization> checkAccessToClass =
230            accessToClass.apply(rdfTypes.stream().map(URI::toString).collect(toList()));
231
232        // Read the effective Acl and return a list of acl:Authorization statements
233        final List<WebACAuthorization> authorizations = effectiveAcl
234                .map(auth -> getAuthorizations(auth.uri.toString()))
235                .orElseGet(() -> getDefaultAuthorizations());
236
237        // Filter the acl:Authorization statements so that they correspond only to statements that apply to
238        // the target (or acl-bearing ancestor) resource path or rdf:type.
239        // Then, assign all acceptable acl:mode values to the relevant acl:agent values: this creates a UNION
240        // of acl:modes for each particular acl:agent.
241        final Map<String, Collection<String>> effectiveRoles = new HashMap<>();
242        authorizations.stream()
243            .filter(checkAccessTo.or(checkAccessToClass))
244            .forEach(auth -> {
245                concat(auth.getAgents().stream(), dereferenceAgentClasses(auth.getAgentClasses()).stream())
246                    .forEach(agent -> {
247                        effectiveRoles.computeIfAbsent(agent, key -> new HashSet<>())
248                            .addAll(auth.getModes().stream().map(URI::toString).collect(toSet()));
249                    });
250            });
251
252        LOGGER.debug("Unfiltered ACL: {}", effectiveRoles);
253
254        return effectiveRoles;
255    }
256
257    /**
258     * Given a path (e.g. /a/b/c/d) retrieve a list of all ancestor paths.
259     * In this case, that would be a list of "/a/b/c", "/a/b", "/a" and "/".
260     */
261    private static List<String> getAllPathAncestors(final String path) {
262        final List<String> segments = asList(path.split("/"));
263        return range(1, segments.size())
264                .mapToObj(frameSize -> FEDORA_INTERNAL_PREFIX + "/" + String.join("/", segments.subList(1, frameSize)))
265                .collect(toList());
266    }
267
268    /**
269     *  This is a function for generating a Predicate that filters WebACAuthorizations according
270     *  to whether the given acl:accessToClass values contain any of the rdf:type values provided
271     *  when creating the predicate.
272     */
273    private static final Function<List<String>, Predicate<WebACAuthorization>> accessToClass = uris -> auth ->
274        uris.stream().anyMatch(uri -> auth.getAccessToClassURIs().contains(uri));
275
276    /**
277     *  This is a function for generating a Predicate that filters WebACAuthorizations according
278     *  to whether the given acl:accessTo values contain any of the target resource values provided
279     *  when creating the predicate.
280     */
281    private static final Function<List<String>, Predicate<WebACAuthorization>> accessTo = uris -> auth ->
282        uris.stream().anyMatch(uri -> auth.getAccessToURIs().contains(uri));
283
284    /**
285     *  This maps a Collection of acl:agentClass values to a List of agents.
286     *  Any out-of-domain URIs are silently ignored.
287     */
288    private List<String> dereferenceAgentClasses(final Collection<String> agentClasses) {
289        final Session internalSession = sessionFactory.getInternalSession();
290        final IdentifierConverter<Resource, FedoraResource> translator =
291                new DefaultIdentifierTranslator(internalSession);
292
293        final List<String> members = agentClasses.stream().flatMap(agentClass -> {
294            if (agentClass.startsWith(FEDORA_INTERNAL_PREFIX)) {
295                final FedoraResource resource = nodeService.find(
296                    internalSession, agentClass.substring(FEDORA_INTERNAL_PREFIX.length()));
297                return getAgentMembers(translator, resource);
298            } else if (agentClass.equals(FOAF_AGENT_VALUE)) {
299                return of(agentClass);
300            } else {
301                LOGGER.info("Ignoring agentClass: {}", agentClass);
302                return empty();
303            }
304        }).collect(toList());
305
306        if (LOGGER.isDebugEnabled() && !agentClasses.isEmpty()) {
307            LOGGER.debug("Found {} members in {} agentClass resources", members.size(), agentClasses.size());
308        }
309
310        return members;
311    }
312
313    /**
314     *  Given a FedoraResource, return a list of agents.
315     */
316    private static Stream<String> getAgentMembers(final IdentifierConverter<Resource, FedoraResource> translator,
317            final FedoraResource resource) {
318        return resource.getTriples(translator, PROPERTIES).filter(memberTestFromTypes.apply(resource.getTypes()))
319            .map(Triple::getObject).flatMap(WebACRolesProvider::nodeToStringStream);
320    }
321
322    /**
323     * Map a Jena Node to a Stream of Strings. Any non-URI, non-Literals map to an empty Stream,
324     * making this suitable to use with flatMap.
325     */
326    private static final Stream<String> nodeToStringStream(final com.hp.hpl.jena.graph.Node object) {
327        if (object.isURI()) {
328            return of(object.getURI());
329        } else if (object.isLiteral()) {
330            return of(object.getLiteralValue().toString());
331        } else {
332            return empty();
333        }
334    }
335
336    /**
337     *  A simple predicate for filtering out any non-foaf:member properties
338     */
339    private static final Function<List<URI>, Predicate<Triple>> memberTestFromTypes = types -> triple ->
340        types.contains(FOAF_GROUP) && triple.predicateMatches(createURI(FOAF_MEMBER_VALUE));
341
342    /**
343     *  A simple predicate for filtering out any non-acl triples.
344     */
345    private static final Predicate<Triple> hasAclPredicate = triple ->
346        triple.getPredicate().getNameSpace().equals(WEBAC_NAMESPACE_VALUE);
347
348    /**
349     *  This function reads a Fedora ACL resource and all of its acl:Authorization children.
350     *  The RDF from each child resource is put into a WebACAuthorization object, and the
351     *  full list is returned.
352     *
353     *  @param location the location of the ACL resource
354     *  @return a list of acl:Authorization objects
355     */
356    private List<WebACAuthorization> getAuthorizations(final String location) {
357
358        final Session internalSession = sessionFactory.getInternalSession();
359        final List<WebACAuthorization> authorizations = new ArrayList<>();
360        final IdentifierConverter<Resource, FedoraResource> translator =
361                new DefaultIdentifierTranslator(internalSession);
362
363        LOGGER.debug("Effective ACL: {}", location);
364
365        // Find the specified ACL resource
366
367        if (location.startsWith(FEDORA_INTERNAL_PREFIX)) {
368
369            final FedoraResource resource = nodeService.find(internalSession,
370                    location.substring(FEDORA_INTERNAL_PREFIX.length()));
371
372            // Read each child resource, filtering on acl:Authorization type, keeping only acl-prefixed triples.
373            resource.getChildren().forEach(child -> {
374                if (child.getTypes().contains(WEBAC_AUTHORIZATION)) {
375                    final Map<String, List<String>> aclTriples = new HashMap<>();
376                    child.getTriples(translator, PROPERTIES).filter(hasAclPredicate)
377                        .forEach(triple -> {
378                            final String predicate = triple.getPredicate().getURI();
379                            final List<String> values = aclTriples.computeIfAbsent(predicate,
380                                key -> new ArrayList<>());
381                            nodeToStringStream(triple.getObject()).forEach(values::add);
382                            if (predicate.equals(WEBAC_AGENT_VALUE)) {
383                                additionalAgentValues(triple.getObject()).forEach(values::add);
384                            }
385                        });
386                    // Create a WebACAuthorization object from the provided triples.
387                    LOGGER.debug("Adding acl:Authorization from {}", child.getPath());
388                    authorizations.add(createAuthorizationFromMap(aclTriples));
389                }
390            });
391        }
392        return authorizations;
393    }
394
395    private static WebACAuthorization createAuthorizationFromMap(final Map<String, List<String>> data) {
396        return new WebACAuthorization(
397                    data.getOrDefault(WEBAC_AGENT_VALUE, emptyList()),
398                    data.getOrDefault(WEBAC_AGENT_CLASS_VALUE, emptyList()),
399                    data.getOrDefault(WEBAC_MODE_VALUE, emptyList()).stream()
400                                .map(URI::create).collect(toList()),
401                    data.getOrDefault(WEBAC_ACCESSTO_VALUE, emptyList()),
402                    data.getOrDefault(WEBAC_ACCESSTO_CLASS_VALUE, emptyList()));
403    }
404
405    /**
406     * Recursively find the effective ACL as a URI along with the FedoraResource that points to it.
407     * This way, if the effective ACL is pointed to from a parent resource, the child will inherit
408     * any permissions that correspond to access to that parent. This ACL resource may or may not exist,
409     * and it may be external to the fedora repository.
410     */
411    static Optional<ACLHandle> getEffectiveAcl(final FedoraResource resource) {
412        try {
413            final IdentifierConverter<Resource, FedoraResource> translator =
414                new DefaultIdentifierTranslator(getJcrNode(resource).getSession());
415            final List<String> acls = resource.getTriples(translator, PROPERTIES)
416                    .filter(triple -> triple.getPredicate().equals(createURI(WEBAC_ACCESS_CONTROL_VALUE)))
417                    .map(triple -> {
418                        if (triple.getObject().isURI()) {
419                            return triple.getObject().getURI();
420                        }
421                        final String error = String.format("The value %s of the %s on this resource must be a URI",
422                                triple.getObject(), WEBAC_ACCESS_CONTROL_VALUE);
423                        LOGGER.error(error);
424                        throw new MalformedRdfException(error);
425                    }).collect(toList());
426
427            if (!acls.isEmpty()) {
428                if (acls.size() > 1) {
429                    LOGGER.warn("Found multiple ACLs defined for this node. Using: {}", acls.get(0));
430                }
431                return Optional.of(new ACLHandle(URI.create(acls.get(0)), resource));
432            } else if (getJcrNode(resource).getDepth() == 0) {
433                LOGGER.debug("No ACLs defined on this node or in parent hierarchy");
434                return Optional.empty();
435            } else {
436                LOGGER.trace("Checking parent resource for ACL. No ACL found at {}", resource.getPath());
437                return getEffectiveAcl(resource.getContainer());
438            }
439        } catch (final RepositoryException ex) {
440            LOGGER.debug("Exception finding effective ACL: {}", ex.getMessage());
441            return Optional.empty();
442        }
443    }
444
445    private static List<WebACAuthorization> getDefaultAuthorizations() {
446        final Map<String, List<String>> aclTriples = new HashMap<>();
447        final List<WebACAuthorization> authorizations = new ArrayList<>();
448
449        getDefaultAcl().listStatements().mapWith(Statement::asTriple).forEachRemaining(triple -> {
450            if (hasAclPredicate.test(triple)) {
451                final String predicate = triple.getPredicate().getURI();
452                final List<String> values = aclTriples.computeIfAbsent(predicate,
453                    key -> new ArrayList<>());
454                nodeToStringStream(triple.getObject()).forEach(values::add);
455                if (predicate.equals(WEBAC_AGENT_VALUE)) {
456                    additionalAgentValues(triple.getObject()).forEach(values::add);
457                }
458            }
459        });
460
461        authorizations.add(createAuthorizationFromMap(aclTriples));
462        return authorizations;
463    }
464
465    private static Stream<String> additionalAgentValues(final com.hp.hpl.jena.graph.Node object) {
466        final String groupBaseUri = System.getProperty(GROUP_AGENT_BASE_URI_PROPERTY);
467        final String userBaseUri = System.getProperty(USER_AGENT_BASE_URI_PROPERTY);
468
469        if (object.isURI()) {
470            final String uri = object.getURI();
471            if (userBaseUri != null && uri.startsWith(userBaseUri)) {
472                return of(uri.substring(userBaseUri.length()));
473            } else if (groupBaseUri != null && uri.startsWith(groupBaseUri)) {
474                return of(uri.substring(groupBaseUri.length()));
475            }
476        }
477        return empty();
478    }
479
480    private static Model getDefaultAcl() {
481        final String rootAcl = System.getProperty(ROOT_AUTHORIZATION_PROPERTY);
482        final Model model = createDefaultModel();
483
484        if (rootAcl != null && new File(rootAcl).isFile()) {
485            try {
486                LOGGER.debug("Getting root authorization from file: {}", rootAcl);
487                return model.read(rootAcl);
488            } catch (final JenaException ex) {
489                LOGGER.error("Error parsing root authorization file: {}", ex.getMessage());
490            }
491        }
492        try (final InputStream is = WebACRolesProvider.class.getResourceAsStream(ROOT_AUTHORIZATION_LOCATION)) {
493            LOGGER.debug("Getting root authorization from classpath: {}", ROOT_AUTHORIZATION_LOCATION);
494            return model.read(is, null, TTL.getName());
495        } catch (final IOException ex) {
496            LOGGER.error("Error reading root authorization file: {}", ex.getMessage());
497        } catch (final JenaException ex) {
498            LOGGER.error("Error parsing root authorization file: {}", ex.getMessage());
499        }
500        return createDefaultModel();
501    }
502}