/* * 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()); } } }