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