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