ome.services.graphs.GraphPolicyRule.java Source code

Java tutorial

Introduction

Here is the source code for ome.services.graphs.GraphPolicyRule.java

Source

/*
 * Copyright (C) 2014-2015 University of Dundee & Open Microscopy Environment.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 */

package ome.services.graphs;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import ome.model.IObject;
import ome.services.graphs.GraphPolicy.Details;

import org.apache.commons.lang.mutable.MutableBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;

/**
 * A graph policy rule specifies a component of a {@link GraphPolicy}.
 * It is designed to be conveniently created using Spring by supplying configuration metadata to the bean container.
 * @author m.t.b.carroll@dundee.ac.uk
 * @since 5.1.0
 */
public class GraphPolicyRule {
    private static final Logger LOGGER = LoggerFactory.getLogger(GraphPolicyRule.class);

    private static final Pattern NEW_TERM_PATTERN = Pattern
            .compile("(\\w+\\:)?(\\!?[\\w]+)?(\\[\\!?[EDIO]+\\])?(\\{\\!?[iroa]+\\})?(\\/\\!?[udon]+)?(\\;\\S+)?");
    private static final Pattern PREDICATE_PATTERN = Pattern.compile("\\;(\\w+)\\=([^\\;]+)(\\;\\S+)?");
    private static final Pattern EXISTING_TERM_PATTERN = Pattern.compile("(\\w+)");
    private static final Pattern CHANGE_PATTERN = Pattern
            .compile("(\\w+\\:)(\\[[EDIO\\-]\\])?(\\{[iroa]\\})?(\\/n)?");

    private List<String> matches = Collections.emptyList();
    private List<String> changes = Collections.emptyList();
    private String errorMessage = null;

    /**
     * @param matches the match conditions for this policy rule, comma-separated
     */
    public void setMatches(String matches) {
        this.matches = ImmutableList.copyOf(matches.split(",\\s*"));
    }

    /**
     * @param changes the changes caused by this policy rule, comma-separated
     */
    public void setChanges(String changes) {
        this.changes = ImmutableList.copyOf(changes.split(",\\s*"));
    }

    /**
     * @param message the error message triggered by this policy rule
     */
    public void setError(String message) {
        errorMessage = message;
    }

    @Override
    public String toString() {
        final String trigger = Joiner.on(", ").join(matches);
        final String consequence;

        if (errorMessage == null) {
            consequence = "to " + Joiner.on(", ").join(changes);
        } else {
            consequence = "is error: " + errorMessage;
        }

        return trigger + ' ' + consequence;
    }

    /**
     * Matches model object instances term on either side of a link among objects.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static interface TermMatch {
        /**
         * If this matches the given term.
         * Does not adjust {@code namedTerms} or {@code isCheckAllPermissions} unless the match succeeds,
         * in which case sets {@code isCheckAllPermissions} to {@code false} if
         * {@code details.isCheckPermissions == false}.
         * @param predicates the predicate matchers that may be named in policy rules
         * @param namedTerms the name dictionary of matched terms (updated by this method)
         * @param isCheckAllPermissions if permissions are to be checked for all of the matched objects (updated by this method)
         * @param details the details of the term
         * @return if the term matches
         * @throws GraphException if the match attempt could not be completed
         */
        boolean isMatch(Map<String, GraphPolicyRulePredicate> predicates, Map<String, Details> namedTerms,
                MutableBoolean isCheckAllPermissions, Details details) throws GraphException;
    }

    /**
     * {@inheritDoc}
     * Matches an existing named term.
     */
    private static class ExistingTermMatch implements TermMatch {
        final String termName;

        /**
         * Construct an existing term match.
         * @param termName the name of the existing term
         */
        ExistingTermMatch(String termName) {
            this.termName = termName;
        }

        @Override
        public boolean isMatch(Map<String, GraphPolicyRulePredicate> predicates, Map<String, Details> namedTerms,
                MutableBoolean isCheckAllPermissions, Details details) {
            return details.equals(namedTerms.get(termName));
        }
    }

    /**
     * {@inheritDoc}
     * May define a new named term.
     */
    private static class NewTermMatch implements TermMatch {
        private static Set<GraphPolicy.Action> ONLY_EXCLUDE = Collections.singleton(GraphPolicy.Action.EXCLUDE);
        private static Set<GraphPolicy.Action> ALL_ACTIONS = EnumSet.allOf(GraphPolicy.Action.class);
        private static Set<GraphPolicy.Orphan> ALL_ORPHANS = EnumSet.allOf(GraphPolicy.Orphan.class);

        private final String termName;
        private final Class<? extends IObject> requiredClass;
        private final Class<? extends IObject> prohibitedClass;
        private final Collection<GraphPolicy.Action> permittedActions;
        private final Collection<GraphPolicy.Orphan> permittedOrphans;
        private final Set<GraphPolicy.Ability> requiredAbilities;
        private final Set<GraphPolicy.Ability> prohibitedAbilities;
        private final Boolean isCheckPermissions;
        private final Map<String, String> predicateArguments;

        /**
         * Construct a new term match. All arguments may be {@code null}.
         * @param termName the name of the term, so as to allow references to it
         * @param requiredClass a class of which the object may be an instance
         * @param prohibitedClass a class of which the object may not be an instance
         * @param permittedActions the actions permitted for the object (assumed to be only {@link GraphPolicy.Action#EXCLUDE}
         * if {@code permittedOrphans} is non-{@code null})
         * @param permittedOrphans the orphan statuses permitted for the object
         * @param requiredAbilities the abilities that the user must have to operate upon the object
         * @param prohibitedAbilities the abilities that the user must not have to operate upon the object
         * @param isCheckPermissions if permissions are being checked for the object, may be {@code null}
         * @param predicateArguments arguments that must satisfy named predicates, may be {@code null}
         */
        NewTermMatch(String termName, Class<? extends IObject> requiredClass,
                Class<? extends IObject> prohibitedClass, Collection<GraphPolicy.Action> permittedActions,
                Collection<GraphPolicy.Orphan> permittedOrphans, Collection<GraphPolicy.Ability> requiredAbilities,
                Collection<GraphPolicy.Ability> prohibitedAbilities, Boolean isCheckPermissions,
                Map<String, String> predicateArguments) {
            this.termName = termName;
            this.requiredClass = requiredClass;
            this.prohibitedClass = prohibitedClass;
            if (permittedOrphans == null) {
                if (permittedActions == null) {
                    this.permittedActions = ALL_ACTIONS;
                } else {
                    this.permittedActions = ImmutableSet.copyOf(permittedActions);
                }
                this.permittedOrphans = ALL_ORPHANS;
            } else {
                this.permittedActions = ONLY_EXCLUDE;
                this.permittedOrphans = ImmutableSet.copyOf(permittedOrphans);
            }
            if (requiredAbilities == null) {
                this.requiredAbilities = ImmutableSet.of();
            } else {
                this.requiredAbilities = ImmutableSet.copyOf(requiredAbilities);
            }
            if (prohibitedAbilities == null) {
                this.prohibitedAbilities = ImmutableSet.of();
            } else {
                this.prohibitedAbilities = ImmutableSet.copyOf(prohibitedAbilities);
            }
            this.isCheckPermissions = isCheckPermissions;
            this.predicateArguments = predicateArguments;
        }

        @Override
        public boolean isMatch(Map<String, GraphPolicyRulePredicate> predicates, Map<String, Details> namedTerms,
                MutableBoolean isCheckAllPermissions, Details details) throws GraphException {
            final Class<? extends IObject> subjectClass = details.subject.getClass();
            final boolean previousIsCheckAllPermissions = isCheckAllPermissions.booleanValue();
            if (previousIsCheckAllPermissions && !details.isCheckPermissions) {
                /* note that this match causes a permissions override to be applied to changes */
                isCheckAllPermissions.setValue(false);
            }
            if ((requiredClass == null || requiredClass.isAssignableFrom(subjectClass))
                    && (prohibitedClass == null || !prohibitedClass.isAssignableFrom(subjectClass))
                    && permittedActions.contains(details.action)
                    && (details.action != GraphPolicy.Action.EXCLUDE || permittedOrphans.contains(details.orphan))
                    && Sets.difference(requiredAbilities, details.permissions).isEmpty()
                    && Sets.intersection(prohibitedAbilities, details.permissions).isEmpty()
                    && (isCheckPermissions == null || isCheckPermissions == details.isCheckPermissions)) {
                if (predicateArguments != null) {
                    for (final Entry<String, String> predicateArgument : predicateArguments.entrySet()) {
                        final String predicateName = predicateArgument.getKey();
                        final String predicateValue = predicateArgument.getValue();
                        final GraphPolicyRulePredicate predicate = predicates.get(predicateName);
                        if (predicate == null) {
                            throw new GraphException("unknown predicate: " + predicateName);
                        }
                        if (!predicate.isMatch(details, predicateValue)) {
                            return false;
                        }
                    }
                }
                if (termName == null) {
                    return true;
                } else {
                    /* check the named term against the dictionary of such terms */
                    final Details oldDetails = namedTerms.get(termName);
                    if (oldDetails == null) {
                        namedTerms.put(termName, details);
                        return true;
                    } else if (oldDetails.equals(details)) {
                        return true;
                    }
                }
            }
            isCheckAllPermissions.setValue(previousIsCheckAllPermissions);
            return false;
        }
    }

    /**
     * Matches relationships between a pair of linked model object instance terms.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static class RelationshipMatch {
        private final TermMatch leftTerm;
        private final TermMatch rightTerm;
        private final String propertyName;
        private final Boolean notNullable;
        private final Boolean sameOwner;

        /**
         * Construct a new relationship match.
         * @param leftTerm the match for the left term (the object doing the linking)
         * @param rightTerm the match for the right term (the linked object)
         * @param propertyName the name of the property of the left term that has the right term as its value
         * @param notNullable if the property is not nullable (or {@code null} if either is permitted)
         * @param sameOwner if the two terms must have the same owner
         * ({@code null} if it doesn't matter, {@code false} if they must differ)
         */
        RelationshipMatch(TermMatch leftTerm, TermMatch rightTerm, String propertyName, Boolean notNullable,
                Boolean sameOwner) {
            this.leftTerm = leftTerm;
            this.rightTerm = rightTerm;
            this.propertyName = propertyName == null ? null : '.' + propertyName;
            this.notNullable = notNullable;
            this.sameOwner = sameOwner;
        }

        /**
         * If this matches the given relationship.
         * Does not adjust {@code namedTerms} or {@code isCheckAllPermissions} unless the match succeeds,
         * in which case sets {@code isCheckAllPermissions} to {@code false} if
         * {@code leftDetails.isCheckPermissions && rightDetails.isCheckPermissions == false}.
         * @param predicates the predicate matchers that may be named in policy rules
         * @param namedTerms the name dictionary of matched terms (to be updated by this method)
         * @param isCheckAllPermissions if permissions are to be checked for all of the matched objects
         * @param leftDetails the details of the left term, holding the property
         * @param rightDetails the details of the right term, being a value of the property
         * @param classProperty the name of the declaring class and property
         * @param notNullable if the property is not nullable
         * @return if the relationship matches
         * @throws GraphException if the match attempt could not be completed
         */
        boolean isMatch(Map<String, GraphPolicyRulePredicate> predicates, Map<String, Details> namedTerms,
                MutableBoolean isCheckAllPermissions, Details leftDetails, Details rightDetails,
                String classProperty, boolean notNullable) throws GraphException {
            if ((this.sameOwner != null && leftDetails.ownerId != null && rightDetails.ownerId != null
                    && this.sameOwner != leftDetails.ownerId.equals(rightDetails.ownerId))
                    || (this.notNullable != null && this.notNullable != notNullable)
                    || (this.propertyName != null && !classProperty.endsWith(propertyName))) {
                return false;
            }
            final Map<String, Details> newNamedTerms = new HashMap<String, Details>(namedTerms);
            final MutableBoolean newIsCheckAllPermissions = new MutableBoolean(
                    isCheckAllPermissions.booleanValue());
            final boolean isMatch = leftTerm.isMatch(predicates, newNamedTerms, newIsCheckAllPermissions,
                    leftDetails)
                    && rightTerm.isMatch(predicates, newNamedTerms, newIsCheckAllPermissions, rightDetails);
            if (isMatch) {
                namedTerms.putAll(newNamedTerms);
                isCheckAllPermissions.setValue(newIsCheckAllPermissions.booleanValue());
            }
            return isMatch;
        }

        /**
         * @return the name of the existing left term required for this relationship match,
         * or {@code null} if the left term is not an existing term
         */
        String getExistingLeftTerm() {
            return leftTerm instanceof ExistingTermMatch ? ((ExistingTermMatch) leftTerm).termName : null;
        }

        /**
         * @return the name of the existing right term required for this relationship match,
         * or {@code null} if the right term is not an existing term
         */
        String getExistingRightTerm() {
            return rightTerm instanceof ExistingTermMatch ? ((ExistingTermMatch) rightTerm).termName : null;
        }
    }

    /**
     * Matches conditions available via {@link GraphPolicy#isCondition(String)}.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static class ConditionMatch {
        final boolean set;
        final String name;

        /**
         * Construct a new condition match.
         * @param set if the condition should be set
         * @param name the name of the condition
         */
        ConditionMatch(boolean set, String name) {
            this.set = set;
            this.name = name;
        }
    }

    /**
     * A change to effect if a rule's matchers match.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static class Change {
        private final String namedTerm;
        private final GraphPolicy.Action action;
        private final GraphPolicy.Orphan orphan;
        private final boolean isOverridePermissions;

        /**
         * Construct a change instance.
         * @param namedTerm the term to affect
         * @param action the effect to have on the action, {@code null} for no effect
         * @param orphan the effect to have on the orphan status, {@code null} for no effect
         * @param isOverridePermissions if permissions checking should be overridden
         */
        Change(String namedTerm, GraphPolicy.Action action, GraphPolicy.Orphan orphan,
                boolean isOverridePermissions) {
            this.namedTerm = namedTerm;
            this.action = action;
            this.orphan = orphan;
            this.isOverridePermissions = isOverridePermissions;
        }

        /**
         * Effect the change.
         * @param namedTerms the name dictionary of matched terms
         * @return the details of the changed term
         * @throws GraphException if the named term is not defined in the matching
         */
        Details toChanged(Map<String, Details> namedTerms) throws GraphException {
            final Details details = namedTerms.get(namedTerm);
            if (details == null) {
                throw new GraphException("policy rule: reference to unknown term " + namedTerm);
            }
            if (action != null) {
                details.action = action;
            }
            if (orphan != null) {
                details.orphan = orphan;
            }
            if (isOverridePermissions) {
                details.isCheckPermissions = false;
            }
            return details;
        }

        /**
         * @return if this change actually affects the term's action or orphan status
         */
        boolean isEffectiveChange() {
            return action != null || orphan != null;
        }
    }

    /**
     * A policy rule with matchers and changes that can now be applied having been parsed from the text-based configuration.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static class ParsedPolicyRule {
        final String asString;
        final List<TermMatch> termMatchers;
        final List<RelationshipMatch> relationshipMatchers;
        final List<ConditionMatch> conditionMatchers;
        final List<Change> changes;
        final String errorMessage;

        /**
         * Construct a policy rule.
         * @param asString a String representation of this rule,
         * recognizably corresponding to its original text-based configuration.
         * @param termMatchers the term matchers that must apply if the changes are to be applied
         * @param relationshipMatchers the relationship matchers that must apply if the changes are to be applied
         * @param conditionMatchers the condition matchers that must apply if the changes are to be applied
         * @param changes the effects of this rule, guarded by the matchers
         */
        ParsedPolicyRule(String asString, List<TermMatch> termMatchers,
                List<RelationshipMatch> relationshipMatchers, List<ConditionMatch> conditionMatchers,
                List<Change> changes) {
            this.asString = asString;
            this.termMatchers = termMatchers;
            this.relationshipMatchers = relationshipMatchers;
            this.conditionMatchers = conditionMatchers;
            this.changes = changes;
            this.errorMessage = null;
        }

        /**
         * Construct a policy rule.
         * @param asString a String representation of this rule,
         * recognizably corresponding to its original text-based configuration.
         * @param termMatchers the term matchers that must apply if the changes are to be applied
         * @param relationshipMatchers the relationship matchers that must apply if the changes are to be applied
         * @param conditionMatchers the condition matchers that must apply if the changes are to be applied
         * @param changes the effects of this rule, guarded by the matchers
         */
        ParsedPolicyRule(String asString, List<TermMatch> termMatchers,
                List<RelationshipMatch> relationshipMatchers, List<ConditionMatch> conditionMatchers,
                String errorMessage) {
            this.asString = asString;
            this.termMatchers = termMatchers;
            this.relationshipMatchers = relationshipMatchers;
            this.conditionMatchers = conditionMatchers;
            this.changes = Collections.emptyList();
            this.errorMessage = errorMessage;
        }
    }

    /**
     * Parse a term match from a textual representation.
     * @param graphPathBean the graph path bean
     * @param term some text
     * @return the term match parsed from the text
     * @throws GraphException if the parse failed
     */
    private static TermMatch parseTermMatch(GraphPathBean graphPathBean, String term) throws GraphException {
        /* determine if new or existing term */

        final Matcher existingTermMatcher = EXISTING_TERM_PATTERN.matcher(term);

        if (existingTermMatcher.matches()) {
            return new ExistingTermMatch(existingTermMatcher.group(1));
        }

        final Matcher newTermMatcher = NEW_TERM_PATTERN.matcher(term);
        if (!newTermMatcher.matches()) {
            throw new GraphException("failed to parse match term " + term);
        }

        /* note parse results */

        final String termName;
        final Class<? extends IObject> requiredClass;
        final Class<? extends IObject> prohibitedClass;
        final Collection<GraphPolicy.Action> permittedActions;
        final Collection<GraphPolicy.Orphan> permittedOrphans;
        final Collection<GraphPolicy.Ability> requiredAbilities;
        final Collection<GraphPolicy.Ability> prohibitedAbilities;
        Boolean isCheckPermissions = null;
        final Map<String, String> predicateArguments;

        /* parse term name, if any */

        final String termNameGroup = newTermMatcher.group(1);
        if (termNameGroup == null) {
            termName = null;
        } else {
            termName = termNameGroup.substring(0, termNameGroup.length() - 1);
        }

        /* parse class name, if any */

        final String classNameGroup = newTermMatcher.group(2);
        if (classNameGroup == null) {
            requiredClass = null;
            prohibitedClass = null;
        } else if (classNameGroup.charAt(0) == '!') {
            requiredClass = null;
            prohibitedClass = graphPathBean.getClassForSimpleName(classNameGroup.substring(1));
            if (prohibitedClass == null) {
                throw new GraphException("unknown class named in " + term);
            }
        } else {
            requiredClass = graphPathBean.getClassForSimpleName(classNameGroup);
            prohibitedClass = null;
            if (requiredClass == null) {
                throw new GraphException("unknown class named in " + term);
            }
        }

        /* parse actions, if any */

        final String actionGroup = newTermMatcher.group(3);
        if (actionGroup == null) {
            permittedActions = null;
        } else {
            final EnumSet<GraphPolicy.Action> actions = EnumSet.noneOf(GraphPolicy.Action.class);
            boolean invert = false;
            for (final char action : actionGroup.toCharArray()) {
                if (action == 'E') {
                    actions.add(GraphPolicy.Action.EXCLUDE);
                } else if (action == 'D') {
                    actions.add(GraphPolicy.Action.DELETE);
                } else if (action == 'I') {
                    actions.add(GraphPolicy.Action.INCLUDE);
                } else if (action == 'O') {
                    actions.add(GraphPolicy.Action.OUTSIDE);
                } else if (action == '!') {
                    invert = true;
                }
            }
            permittedActions = invert ? EnumSet.complementOf(actions) : actions;
        }

        /* parse orphans, if any */

        final String orphanGroup = newTermMatcher.group(4);
        if (orphanGroup == null) {
            permittedOrphans = null;
        } else {
            final EnumSet<GraphPolicy.Orphan> orphans = EnumSet.noneOf(GraphPolicy.Orphan.class);
            boolean invert = false;
            for (final char orphan : orphanGroup.toCharArray()) {
                if (orphan == 'i') {
                    orphans.add(GraphPolicy.Orphan.IRRELEVANT);
                } else if (orphan == 'r') {
                    orphans.add(GraphPolicy.Orphan.RELEVANT);
                } else if (orphan == 'o') {
                    orphans.add(GraphPolicy.Orphan.IS_LAST);
                } else if (orphan == 'a') {
                    orphans.add(GraphPolicy.Orphan.IS_NOT_LAST);
                } else if (orphan == '!') {
                    invert = true;
                }
            }
            permittedOrphans = invert ? EnumSet.complementOf(orphans) : orphans;
        }

        /* parse abilities, if any; also permissions checking */

        final String abilityGroup = newTermMatcher.group(5);
        if (abilityGroup == null) {
            requiredAbilities = null;
            prohibitedAbilities = null;
        } else {
            final EnumSet<GraphPolicy.Ability> abilities = EnumSet.noneOf(GraphPolicy.Ability.class);
            boolean required = true;
            for (final char ability : abilityGroup.toCharArray()) {
                if (ability == 'u') {
                    abilities.add(GraphPolicy.Ability.UPDATE);
                } else if (ability == 'd') {
                    abilities.add(GraphPolicy.Ability.DELETE);
                } else if (ability == 'o') {
                    abilities.add(GraphPolicy.Ability.OWN);
                } else if (ability == 'n') {
                    isCheckPermissions = !required;
                } else if (ability == '!') {
                    required = false;
                }
            }
            if (required) {
                requiredAbilities = abilities;
                prohibitedAbilities = null;
            } else {
                requiredAbilities = null;
                prohibitedAbilities = abilities;
            }
        }

        /* parse named predicate arguments, if any */

        if (newTermMatcher.group(6) == null) {
            predicateArguments = null;
        } else {
            predicateArguments = new HashMap<String, String>();
            String remainingPredicates = newTermMatcher.group(6);
            while (remainingPredicates != null) {
                final Matcher predicateMatcher = PREDICATE_PATTERN.matcher(remainingPredicates);
                if (!predicateMatcher.matches()) {
                    throw new GraphException("failed to parse predicates suffixing match term " + term);
                }
                predicateArguments.put(predicateMatcher.group(1), predicateMatcher.group(2));
                remainingPredicates = predicateMatcher.group(3);
            }
        }

        /* construct new term match */

        return new NewTermMatch(termName, requiredClass, prohibitedClass, permittedActions, permittedOrphans,
                requiredAbilities, prohibitedAbilities, isCheckPermissions, predicateArguments);

    }

    /**
     * Parse a relationship match from a textual representation.
     * @param graphPathBean the graph path bean
     * @param leftTerm the first <q>word</q> of text
     * @param equals the second <q>word</q> of text
     * @param rightTerm the third <q>word</q> of text
     * @return the relationship match parsed from the text
     * @throws GraphException if the parse failed
     */
    private static RelationshipMatch parseRelationshipMatch(GraphPathBean graphPathBean, String leftTerm,
            String equals, String rightTerm) throws GraphException {
        final Boolean sameOwner;
        final int slash = equals.indexOf('/');
        if (slash < 0) {
            sameOwner = null;
        } else {
            sameOwner = equals.endsWith("/o");
            equals = equals.substring(0, slash);
        }
        final Boolean notNullable;
        if ("=".equals(equals)) {
            notNullable = null;
        } else if ("==".equals(equals)) {
            notNullable = Boolean.TRUE;
        } else if ("=?".equals(equals)) {
            notNullable = Boolean.FALSE;
        } else {
            throw new GraphException(Joiner.on(' ').join("failed to parse match", leftTerm, equals, rightTerm));
        }
        if (rightTerm.indexOf('.') > 0) {
            final String forSwap = rightTerm;
            rightTerm = leftTerm;
            leftTerm = forSwap;
        }
        final String propertyName;
        final int periodIndex = leftTerm.indexOf('.');
        if (periodIndex > 0) {
            propertyName = leftTerm.substring(periodIndex + 1);
            leftTerm = leftTerm.substring(0, periodIndex);
        } else {
            propertyName = null;
        }
        final TermMatch leftTermMatch = parseTermMatch(graphPathBean, leftTerm);
        final TermMatch rightTermMatch = parseTermMatch(graphPathBean, rightTerm);
        return new RelationshipMatch(leftTermMatch, rightTermMatch, propertyName, notNullable, sameOwner);
    }

    /**
     * Parse a change from a textual representation.
     * @param change some text
     * @return the change parsed from the text
     * @throws GraphException if the parse failed
     */
    private static Change parseChange(String change) throws GraphException {
        final Matcher matcher = CHANGE_PATTERN.matcher(change);
        if (!matcher.matches()) {
            throw new GraphException("failed to parse change " + change);
        }

        final String termName;
        final GraphPolicy.Action action;
        final GraphPolicy.Orphan orphan;
        final boolean isOverridePermissions;

        /* parse term name */

        final String termNameGroup = matcher.group(1);
        termName = termNameGroup.substring(0, termNameGroup.length() - 1);

        /* parse actions, if any */

        if (matcher.group(2) == null) {
            action = null;
        } else {
            switch (matcher.group(2).charAt(1)) {
            case 'E':
                action = GraphPolicy.Action.EXCLUDE;
                break;
            case 'D':
                action = GraphPolicy.Action.DELETE;
                break;
            case 'I':
                action = GraphPolicy.Action.INCLUDE;
                break;
            case 'O':
                action = GraphPolicy.Action.OUTSIDE;
                break;
            default:
                action = null;
                break;
            }
        }

        /* parse orphans, if any */

        if (matcher.group(3) == null) {
            orphan = null;
        } else {
            switch (matcher.group(3).charAt(1)) {
            case 'i':
                orphan = GraphPolicy.Orphan.IRRELEVANT;
                break;
            case 'r':
                orphan = GraphPolicy.Orphan.RELEVANT;
                break;
            case 'o':
                orphan = GraphPolicy.Orphan.IS_LAST;
                break;
            case 'a':
                orphan = GraphPolicy.Orphan.IS_NOT_LAST;
                break;
            default:
                orphan = null;
                break;
            }
        }

        /* parse permissions override, if any */

        if (matcher.group(4) == null) {
            isOverridePermissions = false;
        } else {
            switch (matcher.group(4).charAt(1)) {
            case 'n':
                isOverridePermissions = true;
                break;
            default:
                isOverridePermissions = false;
                break;
            }
        }

        return new Change(termName, action, orphan, isOverridePermissions);
    }

    /**
     * Convert the text-based rules as specified in the configuration metadata into a policy applicable in
     * model object graph traversal.
     * (A more advanced effort could construct an efficient decision tree, but that optimization may be premature.)
     * @param graphPathBean the graph path bean
     * @param rules the rules to apply
     * @return a policy for graph traversal by {@link GraphTraversal}
     * @throws GraphException if the text-based rules could not be parsed
     */
    public static GraphPolicy parseRules(GraphPathBean graphPathBean, Collection<GraphPolicyRule> rules)
            throws GraphException {
        final List<ParsedPolicyRule> policyRules = new ArrayList<ParsedPolicyRule>();
        for (final GraphPolicyRule policyRule : rules) {
            final List<TermMatch> termMatches = new ArrayList<TermMatch>();
            final List<RelationshipMatch> relationshipMatches = new ArrayList<RelationshipMatch>();
            final List<ConditionMatch> conditionMatches = new ArrayList<ConditionMatch>();
            for (final String match : policyRule.matches) {
                final String[] words = match.trim().split("\\s+");
                if (words.length == 1) {
                    final String word = words[0];
                    if (word.startsWith("$")) {
                        conditionMatches.add(new ConditionMatch(true, word.substring(1)));
                    } else if (word.startsWith("!$")) {
                        conditionMatches.add(new ConditionMatch(false, word.substring(2)));
                    } else {
                        termMatches.add(parseTermMatch(graphPathBean, word));
                    }
                } else if (words.length == 3) {
                    relationshipMatches.add(parseRelationshipMatch(graphPathBean, words[0], words[1], words[2]));
                } else {
                    throw new GraphException("failed to parse match " + match);
                }
            }
            if (policyRule.errorMessage == null) {
                final List<Change> changes = new ArrayList<Change>();
                for (final String change : policyRule.changes) {
                    changes.add(parseChange(change.trim()));
                }
                policyRules.add(new ParsedPolicyRule(policyRule.toString(), termMatches, relationshipMatches,
                        conditionMatches, changes));
            } else {
                policyRules.add(new ParsedPolicyRule(policyRule.toString(), termMatches, relationshipMatches,
                        conditionMatches, policyRule.errorMessage));
            }
        }
        return new CleanGraphPolicy(policyRules);
    }

    /**
     * A clean instance of a graph policy implementing the parsed rules.
     * @author m.t.b.carroll@dundee.ac.uk
     * @since 5.1.0
     */
    private static class CleanGraphPolicy extends GraphPolicy {
        private final ImmutableList<ParsedPolicyRule> policyRulesChange;
        private final ImmutableList<ParsedPolicyRule> policyRulesError;
        private final Set<String> conditions = new HashSet<String>();

        /**
         * Construct a clean instance of a graph policy.
         * @param policyRules the parsed policy rules
         */
        CleanGraphPolicy(List<ParsedPolicyRule> policyRules) {
            final ImmutableList.Builder<ParsedPolicyRule> policyRulesChangeBuilder = ImmutableList.builder();
            final ImmutableList.Builder<ParsedPolicyRule> policyRulesErrorBuilder = ImmutableList.builder();

            for (final ParsedPolicyRule policyRule : policyRules) {
                if (policyRule.errorMessage == null) {
                    policyRulesChangeBuilder.add(policyRule);
                } else {
                    policyRulesErrorBuilder.add(policyRule);
                }
            }

            this.policyRulesChange = policyRulesChangeBuilder.build();
            this.policyRulesError = policyRulesErrorBuilder.build();
        }

        /**
         * Construct a clean instance of a graph policy.
         * @param policyRulesChange the parsed policy rules whose consequence is graph node state changes
         * @param policyRulesError the parsed policy rules whose consequence is an error condition
         */
        private CleanGraphPolicy(ImmutableList<ParsedPolicyRule> policyRulesChange,
                ImmutableList<ParsedPolicyRule> policyRulesError) {
            this.policyRulesChange = policyRulesChange;
            this.policyRulesError = policyRulesError;
        }

        @Override
        public GraphPolicy getCleanInstance() {
            return new CleanGraphPolicy(policyRulesChange, policyRulesError);
        }

        @Override
        public void setCondition(String name) {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("set graph policy condition: " + name);
            }
            conditions.add(name);
        }

        @Override
        public boolean isCondition(String name) {
            return conditions.contains(name);
        }

        @Override
        public Set<Details> review(Map<String, Set<Details>> linkedFrom, Details rootObject,
                Map<String, Set<Details>> linkedTo, Set<String> notNullable, boolean isErrorRules)
                throws GraphException {
            final Set<Details> changedObjects = new HashSet<Details>();
            for (final ParsedPolicyRule policyRule : isErrorRules ? policyRulesError : policyRulesChange) {
                boolean conditionsSatisfied = true;
                for (final ConditionMatch matcher : policyRule.conditionMatchers) {
                    if (matcher.set != isCondition(matcher.name)) {
                        conditionsSatisfied = false;
                        break;
                    }
                }
                if (conditionsSatisfied) {
                    if (policyRule.termMatchers.size() + policyRule.relationshipMatchers.size() == 1) {
                        reviewWithSingleMatch(linkedFrom, rootObject, linkedTo, notNullable, policyRule,
                                changedObjects);
                    } else {
                        reviewWithManyMatches(linkedFrom, rootObject, linkedTo, notNullable, policyRule,
                                changedObjects);
                    }
                }
            }
            return changedObjects;
        }

        /**
         * If there is only a single match, the policy rule may apply multiple times to the root object,
         * through applying to multiple properties or to collection properties.
         * @param linkedFrom details of the objects linking to the root object, by property
         * @param rootObject details of the root objects
         * @param linkedTo details of the objects linked by the root object, by property
         * @param notNullable which properties are not nullable
         * @param policyRule the policy rule to consider applying
         * @param changedObjects the set of details of objects that result from applied changes
         * @throws GraphException if a term named for a change is not defined in the matching
         */
        private void reviewWithSingleMatch(Map<String, Set<Details>> linkedFrom, Details rootObject,
                Map<String, Set<Details>> linkedTo, Set<String> notNullable, ParsedPolicyRule policyRule,
                Set<Details> changedObjects) throws GraphException {
            final SortedMap<String, Details> namedTerms = new TreeMap<String, Details>();
            final MutableBoolean isCheckAllPermissions = new MutableBoolean(true);
            if (!policyRule.termMatchers.isEmpty()) {
                /* apply the term matchers */
                final Set<Details> allTerms = GraphPolicy.allObjects(linkedFrom.values(), rootObject,
                        linkedTo.values());
                for (final TermMatch matcher : policyRule.termMatchers) {
                    for (final Details object : allTerms) {
                        if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, object)) {
                            recordChanges(policyRule, changedObjects, namedTerms,
                                    isCheckAllPermissions.booleanValue());
                            namedTerms.clear();
                            isCheckAllPermissions.setValue(true);
                        }
                    }
                }
            }
            /* apply the relationship matchers */
            for (final RelationshipMatch matcher : policyRule.relationshipMatchers) {
                /* consider the root object as the linked object */
                for (final Entry<String, Set<Details>> dataPerProperty : linkedFrom.entrySet()) {
                    final String classProperty = dataPerProperty.getKey();
                    final boolean isNotNullable = notNullable.contains(classProperty);
                    for (final Details linkerObject : dataPerProperty.getValue()) {
                        if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, linkerObject, rootObject,
                                classProperty, isNotNullable)) {
                            recordChanges(policyRule, changedObjects, namedTerms,
                                    isCheckAllPermissions.booleanValue());
                            namedTerms.clear();
                            isCheckAllPermissions.setValue(true);
                        }
                    }
                }
                /* consider the root object as the linker object */
                for (final Entry<String, Set<Details>> dataPerProperty : linkedTo.entrySet()) {
                    final String classProperty = dataPerProperty.getKey();
                    final boolean isNotNullable = notNullable.contains(classProperty);
                    for (final Details linkedObject : dataPerProperty.getValue()) {
                        if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, rootObject, linkedObject,
                                classProperty, isNotNullable)) {
                            recordChanges(policyRule, changedObjects, namedTerms,
                                    isCheckAllPermissions.booleanValue());
                            namedTerms.clear();
                            isCheckAllPermissions.setValue(true);
                        }
                    }
                }
            }
        }

        /**
         * If there are multiple matches, the policy rule may apply only once to the root object.
         * Terms named in any of the matches may be used in any of the changes.
         * @param linkedFrom details of the objects linking to the root object, by property
         * @param rootObject details of the root objects
         * @param linkedTo details of the objects linked by the root object, by property
         * @param notNullable which properties are not nullable
         * @param policyRule the policy rule to consider applying
         * @param changedObjects the set of details of objects that result from applied changes
         * @throws GraphException if a term named for a change is not defined in the matching
         */
        private void reviewWithManyMatches(Map<String, Set<Details>> linkedFrom, Details rootObject,
                Map<String, Set<Details>> linkedTo, Set<String> notNullable, ParsedPolicyRule policyRule,
                Set<Details> changedObjects) throws GraphException {
            final SortedMap<String, Details> namedTerms = new TreeMap<String, Details>();
            final MutableBoolean isCheckAllPermissions = new MutableBoolean(true);
            final Set<TermMatch> unmatchedTerms = new HashSet<TermMatch>(policyRule.termMatchers);
            final Set<Details> allTerms = unmatchedTerms.isEmpty() ? Collections.<Details>emptySet()
                    : GraphPolicy.allObjects(linkedFrom.values(), rootObject, linkedTo.values());
            final Set<RelationshipMatch> unmatchedRelationships = new HashSet<RelationshipMatch>(
                    policyRule.relationshipMatchers);
            /* try all the matchers against all the terms */
            do {
                final int namedTermCount = namedTerms.size();
                Iterator<TermMatch> unmatchedTermIterator;
                Iterator<RelationshipMatch> unmatchedRelationshipIterator;
                /* apply the term matchers */
                unmatchedTermIterator = unmatchedTerms.iterator();
                while (unmatchedTermIterator.hasNext()) {
                    final TermMatch matcher = unmatchedTermIterator.next();
                    for (final Details object : allTerms) {
                        if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, object)) {
                            unmatchedTermIterator.remove();
                        }
                    }
                }
                /* consider the root object as the linked object */
                for (final Entry<String, Set<Details>> dataPerProperty : linkedFrom.entrySet()) {
                    final String classProperty = dataPerProperty.getKey();
                    final boolean isNotNullable = notNullable.contains(classProperty);
                    for (final Details linkerObject : dataPerProperty.getValue()) {
                        unmatchedTermIterator = unmatchedTerms.iterator();
                        while (unmatchedTermIterator.hasNext()) {
                            final TermMatch matcher = unmatchedTermIterator.next();
                            if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, linkerObject)) {
                                unmatchedTermIterator.remove();
                            }
                        }
                        unmatchedRelationshipIterator = unmatchedRelationships.iterator();
                        while (unmatchedRelationshipIterator.hasNext()) {
                            final RelationshipMatch matcher = unmatchedRelationshipIterator.next();
                            if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, linkerObject,
                                    rootObject, classProperty, isNotNullable)) {
                                unmatchedRelationshipIterator.remove();
                            }
                        }
                    }
                }
                /* consider the root object as the linker object */
                for (final Entry<String, Set<Details>> dataPerProperty : linkedTo.entrySet()) {
                    final String classProperty = dataPerProperty.getKey();
                    final boolean isNotNullable = notNullable.contains(classProperty);
                    for (final Details linkedObject : dataPerProperty.getValue()) {
                        unmatchedTermIterator = unmatchedTerms.iterator();
                        while (unmatchedTermIterator.hasNext()) {
                            final TermMatch matcher = unmatchedTermIterator.next();
                            if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, linkedObject)) {
                                unmatchedTermIterator.remove();
                            }
                        }
                        unmatchedRelationshipIterator = unmatchedRelationships.iterator();
                        while (unmatchedRelationshipIterator.hasNext()) {
                            final RelationshipMatch matcher = unmatchedRelationshipIterator.next();
                            if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, rootObject,
                                    linkedObject, classProperty, isNotNullable)) {
                                unmatchedRelationshipIterator.remove();
                            }
                        }
                    }
                }
                /* match relationships among existing terms without any property link via the root object */
                unmatchedRelationshipIterator = unmatchedRelationships.iterator();
                while (unmatchedRelationshipIterator.hasNext()) {
                    final RelationshipMatch matcher = unmatchedRelationshipIterator.next();
                    if (matcher.propertyName != null || matcher.notNullable != null)
                        continue;
                    final String leftTermName = matcher.getExistingLeftTerm();
                    if (leftTermName == null)
                        continue;
                    final String rightTermName = matcher.getExistingRightTerm();
                    if (rightTermName == null)
                        continue;
                    final Details leftDetails = namedTerms.get(leftTermName);
                    if (leftDetails == null)
                        continue;
                    final Details rightDetails = namedTerms.get(rightTermName);
                    if (rightDetails == null)
                        continue;
                    if (matcher.isMatch(predicates, namedTerms, isCheckAllPermissions, leftDetails, rightDetails,
                            null, false)) {
                        unmatchedRelationshipIterator.remove();
                    }
                }
                if (unmatchedTerms.isEmpty() && unmatchedRelationships.isEmpty()) {
                    /* success, all matched */
                    recordChanges(policyRule, changedObjects, namedTerms, isCheckAllPermissions.booleanValue());
                    return;
                } else if (namedTerms.size() == namedTermCount) {
                    /* failure, all matchers will fail on retry */
                    return;
                }
            } while (true);
        }

        /**
         * Effect the changes.
         * @param policyRule the policy rule that is now to be effected
         * @param changedObjects the objects affected by the policy rules (to be updated by this method)
         * @param namedTerms the name dictionary of matched terms
         * @param isCheckAllPermissions if permissions are to be checked for all of the matched objects
         * @throws GraphException if a term to change is one not named among the policy rule's matchers,
         * or if the policy rule's consequence is itself an error condition
         */
        private static void recordChanges(ParsedPolicyRule policyRule, Set<Details> changedObjects,
                Map<String, Details> namedTerms, boolean isCheckAllPermissions) throws GraphException {
            final StringBuffer logMessage;
            if (LOGGER != null && LOGGER.isDebugEnabled()) {
                /* log applicable rule match and old status of terms */
                logMessage = new StringBuffer();
                logMessage.append("matched ");
                logMessage.append(policyRule.asString);
                logMessage.append(", where ");
                for (final Entry<String, Details> namedTerm : namedTerms.entrySet()) {
                    logMessage.append(namedTerm.getKey());
                    logMessage.append(" is ");
                    logMessage.append(namedTerm.getValue());
                    logMessage.append(", ");
                }
            } else {
                /* not logging rule matches */
                logMessage = null;
            }
            if (policyRule.errorMessage != null) {
                /* throw the error that is this rule's consequence */
                String message = policyRule.errorMessage;
                for (final Entry<String, Details> namedTerm : namedTerms.entrySet()) {
                    /* expand each named term to its actual match */
                    final String termName = namedTerm.getKey();
                    final IObject termMatch = namedTerm.getValue().subject;
                    message = message.replace("{" + termName + "}",
                            termMatch.getClass().getSimpleName() + '[' + termMatch.getId() + ']');
                }
                if (logMessage != null) {
                    /* log error rule match */
                    logMessage.append("error thrown");
                    LOGGER.debug(logMessage.toString());
                }
                throw new GraphException(message);
            }
            /* note the new changes to the terms */
            final Map<Change, Details> changedTerms = new HashMap<Change, Details>();
            for (final Change change : policyRule.changes) {
                changedTerms.put(change, change.toChanged(namedTerms));
            }
            /* a permissions override on any match propagates to all truly changed terms */
            if (!isCheckAllPermissions) {
                for (final Entry<Change, Details> changedTerm : changedTerms.entrySet()) {
                    if (changedTerm.getKey().isEffectiveChange()) {
                        changedTerm.getValue().isCheckPermissions = false;
                    }
                }
            }
            if (logMessage != null) {
                /* log new status of terms */
                logMessage.append("making ");
                logMessage.append(Joiner.on(", ").join(changedTerms.values()));
                LOGGER.debug(logMessage.toString());
            }
            changedObjects.addAll(changedTerms.values());
        }
    }
}