001package com.identityworksllc.iiq.common; 002 003import org.apache.commons.logging.Log; 004import org.apache.commons.logging.LogFactory; 005import sailpoint.api.SailPointContext; 006import sailpoint.object.EntitlementCollection; 007import sailpoint.object.Filter; 008import sailpoint.object.QueryOptions; 009import sailpoint.object.SailPointObject; 010import sailpoint.search.HybridReflectiveMatcher; 011import sailpoint.search.JavaPropertyMatcher; 012import sailpoint.tools.GeneralException; 013import sailpoint.tools.Util; 014 015import java.util.*; 016 017/** 018 * This class implements an extension to SailPoint's most advanced matcher, the {@link HybridReflectiveMatcher}, 019 * permitting in-memory matching of arbitrary SailPointObjects. It can consume an arbitrary Filter, like all 020 * other {@link sailpoint.search.Matcher} implementations, but supports a greater variety of filters than any 021 * OOTB Matcher implementation. 022 * 023 * The match semantics for this class are intended to be close to Sailpoint's Hibernate filters. For example, 024 * collection-valued properties will match single values they contain using a {@link Filter#eq(String, Object)} 025 * filter. This means can still do <code>links.application.name == "Active Directory"</code>. (This requires 026 * special handling because 'links.application.name' resolves to a List, which is not equal to a String. In 027 * Hibernate, it resolves to an 'exists' SQL query. In this class, it is treated as a 'list contains'.) 028 * 029 * The property lookup in this class uses {@link Utilities#getProperty(Object, String)}, allowing any semantics 030 * of that method's path syntax. Indexed variables may be used to get entries within list or map properties, 031 * such as <code>affiliations[0]</code>. Null references are also gracefully ignored without error. 032 * 033 * SailPoint's filters have a syntax limitation around array indexing, e.g. links[0], and the Filter.compile() 034 * API requires valid identifiers at every point in a path. This means that a path like 'links[0]' will fail 035 * to compile with an exception. To work around this, you can pass an index in your path as '_X', e.g. 'links._0.name' 036 * for index 0. However, If you build the Filter in code, such as with <code>Filter.eq</code>, you can safely use 037 * 'forbidden' syntax like links[0]. 038 * 039 * ---- 040 * 041 * If a database lookup is needed, such as a subquery, it will be done transparently (this is the "hybrid" aspect 042 * and it mostly delegates to the superclass), but they are avoided if possible. Subquery filters are restricted 043 * to database-friendly properties, as they always need to perform a live query. Collection-criteria filters may 044 * or may not have such a restriction, depending on the collection. 045 * 046 * IMPORTANT: The state in memory may differ from database state, such as if changes have not been committed, 047 * so use caution when invoking hybrid queries. 048 * 049 * {@link Filter#collectionCondition(String, Filter)} is NOT supported and will throw an exception. 050 * 051 * ---- 052 * 053 * If you invoke the constructor with 'allowObjectPropertyEquals', then special self-referential behavior 054 * is enabled in 'eq', 'in', 'ne', and 'containsAll' Filters. If the equality check fails against the literal value, 055 * the matcher will assume that the value is a property on the original context object. That property's value 056 * is retrieved and the comparison is repeated. This isn't something that will come up often, but there is no 057 * substitute when it does. 058 * 059 * ---- 060 * 061 * IMPORTANT IMPORTANT IMPORTANT!!! MAINTAINER NOTE: 062 * Do not modify this class unless you know what you are doing, because this matcher sits behind FakeContext, 063 * which itself is behind the offline IIQCommon tests. You may break the offline tests. Verify everything. 064 * 065 * @author Devin Rosenbauer 066 * @author Instrumental Identity 067 */ 068public class HybridObjectMatcher extends HybridReflectiveMatcher { 069 070 private static final Log logger = LogFactory.getLog(HybridObjectMatcher.class); 071 072 /** 073 * Enables the special object property equals behavior if true (default false) 074 */ 075 private final boolean allowObjectPropertyEquals; 076 077 /** 078 * The sailpoint context to use for querying if needed 079 */ 080 private final SailPointContext context; 081 082 /** 083 * The context object, which must be attached to the context (or entirely detached) 084 */ 085 private Object contextObject; 086 087 /** 088 * True if we should use tolerant paths (default true) 089 */ 090 private final boolean tolerantPaths; 091 092 /** 093 * Constructor for the basic case where defaults are used for allowObjectPropertyEquals (false) and tolerantPaths (true) 094 * @param context The Sailpoint context 095 * @param filter The filter to evaluate 096 */ 097 public HybridObjectMatcher(SailPointContext context, Filter filter) { 098 this(context, filter, false, true); 099 } 100 101 /** 102 * Constructor allowing the user to specify whether to allow object properties in equals 103 * @param context The Sailpoint context 104 * @param filter The filter to evaluate 105 * @param allowObjectPropertyEquals If true, object property comparisons will be allowed 106 */ 107 public HybridObjectMatcher(SailPointContext context, Filter filter, boolean allowObjectPropertyEquals) { 108 this(context, filter, allowObjectPropertyEquals, true); 109 } 110 111 /** 112 * Constructor allowing the user to specify all boolean options 113 * @param context The Sailpoint context 114 * @param filter The filter to evaluate 115 * @param allowObjectPropertyEquals If true, object property comparisons will be allowed 116 * @param tolerantPaths If true, walking paths resulting in an exception (e.g., can't read an object from the database) partway down the path will return null instead 117 */ 118 public HybridObjectMatcher(SailPointContext context, Filter filter, boolean allowObjectPropertyEquals, boolean tolerantPaths) { 119 super(context, filter); 120 this.context = context; 121 this.allowObjectPropertyEquals = allowObjectPropertyEquals; 122 this.tolerantPaths = tolerantPaths; 123 } 124 125 126 /** 127 * Walk the property tree and also resolve certain types of objects nested. 128 * 129 * @param leaf The filter from which to get the information 130 * @param o The object to initially walk from 131 * @return The property value associated with the path 132 * @throws GeneralException If any lookup failures occur during path walking 133 */ 134 @Override 135 public Object getPropertyValue(Filter.LeafFilter leaf, Object o) throws GeneralException { 136 String propertyPath = leaf.getProperty(); 137 Object propertyValue = Utilities.getProperty(o, propertyPath, tolerantPaths); 138 // Magic conversion for comparing IDs to objects in Filters, 139 // like Filter.eq("manager.id", identityObject) or Filter.eq("manager", managerId) 140 if (leaf.getValue() != null) { 141 if (leaf.getValue() instanceof String && propertyValue instanceof SailPointObject) { 142 propertyValue = ((SailPointObject) propertyValue).getId(); 143 } else if (leaf.getValue() instanceof SailPointObject && propertyValue instanceof String) { 144 if (context != null) { 145 SailPointObject spoLeaf = (SailPointObject) leaf.getValue(); 146 SailPointObject spo = context.getObject(spoLeaf.getClass(), (String)propertyValue); 147 if (spo != null) { 148 propertyValue = spo; 149 } 150 } 151 } 152 } 153 return propertyValue; 154 } 155 156 /** 157 * Returns true if the properties of the input object satisfy this Matcher's Filter. 158 * 159 * Filter property names should be specified in the path syntax supported by {@link Utilities#getProperty(Object, String)}. 160 * 161 * @see HybridReflectiveMatcher#matches(Object) 162 */ 163 @Override 164 public boolean matches(Object o) throws GeneralException { 165 contextObject = o; 166 return super.matches(o); 167 } 168 169 /** 170 * Handles the 'containsAll' filter type. By default, defers to the superclass. 171 * 172 * If the normal behavior fails to match, and allowObjectPropertyEquals is true, the relevant 173 * property is retrieved and the containsAll operation repeated against it. 174 * 175 * @param filter The filter to evaluate 176 * @throws GeneralException on failures to read the attributes in question 177 */ 178 @Override 179 public void visitContainsAll(Filter.LeafFilter filter) throws GeneralException { 180 super.visitContainsAll(filter); 181 boolean result = this.evaluationStack.peek(); 182 if (!result) { 183 if (logger.isTraceEnabled()) { 184 Object actual = this.getPropertyValue(filter, this.objectToMatch); 185 logger.trace("Failed to match containsAll() on " + actual); 186 } 187 } 188 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 189 this.evaluationStack.pop(); 190 // Try it as a lookup on the context object 191 if (logger.isTraceEnabled()) { 192 logger.trace("Failed to match containsAll() in default mode; attempting to use " + filter.getValue() + " as a property of the target object " + this.contextObject); 193 } 194 String propertyPath = (String) filter.getValue(); 195 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 196 List<Object> propertyCollection = new ArrayList<>(); 197 if (propertyValue instanceof Collection) { 198 propertyCollection.addAll((Collection<?>) propertyValue); 199 } else if (propertyValue instanceof Object[]) { 200 propertyCollection.addAll(Arrays.asList((Object[]) propertyValue)); 201 } else { 202 propertyCollection.add(propertyValue); 203 } 204 Filter alteredFilter = Filter.containsAll(filter.getProperty(), propertyCollection); 205 JavaPropertyMatcher jpm = new JavaPropertyMatcher((Filter.LeafFilter) alteredFilter); 206 Object actual = this.getPropertyValue(filter, this.objectToMatch); 207 if (logger.isTraceEnabled()) { 208 logger.trace("Comparing " + actual + " with " + propertyCollection); 209 } 210 boolean matches = jpm.matches(actual); 211 if (matches) { 212 this.matchedValues = EntitlementCollection.mergeValues(filter.getProperty(), jpm.getMatchedValue(), this.matchedValues); 213 } 214 this.evaluationStack.push(matches); 215 } 216 } 217 218 /** 219 * Extends the OOTB behavior of 'equals' Filters for a couple of specific cases. 220 * 221 * First, if the object's property value is a Collection and the test parameter is 222 * a String or a Boolean, we check to see if the Collection contains the single 223 * value. This is the way Filters behave when translated to HQL, thanks to SQL 224 * joins, so we want to retain that behavior here. 225 * 226 * Second, if we have allowed object properties to be used on both sides of the 227 * 'equals' Filter, and there is still not a valid match, we attempt to evaluate 228 * the test argument as an object property and compare those. 229 * 230 * @param filter The filter to check for equality 231 * @throws GeneralException if an IIQ error occurs 232 */ 233 @Override 234 public void visitEQ(Filter.LeafFilter filter) throws GeneralException { 235 super.visitEQ(filter); 236 boolean result = this.evaluationStack.peek(); 237 238 if (!result) { 239 if (filter.getValue() instanceof String || filter.getValue() instanceof Boolean) { 240 evaluationStack.pop(); 241 if (logger.isTraceEnabled()) { 242 logger.trace("Failed to match eq() in default mode; attempting a Hibernate-like 'contains'"); 243 } 244 Object actual = this.getPropertyValue(filter, this.objectToMatch); 245 Object propertyValue = filter.getValue(); 246 247 boolean matches = false; 248 if (actual instanceof Collection) { 249 if (filter.isIgnoreCase()) { 250 matches = Utilities.caseInsensitiveContains((Collection<? extends Object>) actual, propertyValue); 251 } else { 252 matches = Util.nullSafeContains(new ArrayList<>((Collection<?>) actual), propertyValue); 253 } 254 } 255 evaluationStack.push(matches); 256 } 257 } 258 result = this.evaluationStack.peek(); 259 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 260 if (logger.isTraceEnabled()) { 261 logger.trace("Failed to match eq() in default and contains mode; attempting to use " + filter.getValue() + " as a property of the target object " + this.contextObject); 262 } 263 this.evaluationStack.pop(); 264 // Try it as a lookup on the context object 265 String propertyPath = (String) filter.getValue(); 266 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 267 Object actual = this.getPropertyValue(filter, this.objectToMatch); 268 269 if (actual != null && propertyValue != null) { 270 if (logger.isTraceEnabled()) { 271 logger.trace("Comparing " + actual + " with " + propertyValue); 272 } 273 Filter derivedFilter = Filter.eq(filter.getProperty(), propertyValue); 274 visitEQ((Filter.LeafFilter) derivedFilter); 275 } else { 276 evaluationStack.push(false); 277 } 278 } 279 } 280 281 /** 282 * Performs the 'in' Filter operation, checking whether the value of the given property 283 * is in the specified list of values. 284 * 285 * @param filter The 'in' Filter 286 * @throws GeneralException if anything goes wrong 287 */ 288 @Override 289 public void visitIn(Filter.LeafFilter filter) throws GeneralException { 290 super.visitIn(filter); 291 boolean result = this.evaluationStack.peek(); 292 if (!result) { 293 Object actual = this.getPropertyValue(filter, this.objectToMatch); 294 if (actual instanceof Collection && filter.getValue() instanceof Collection) { 295 if (logger.isTraceEnabled()) { 296 logger.trace("Failed to match in() in default mode; attempting to use a Hibernate-style contains"); 297 } 298 evaluationStack.pop(); 299 // This makes 'links.application.name.in({"HR"}) work 300 boolean matches = false; 301 for(Object item : (Collection<Object>)actual) { 302 if (logger.isTraceEnabled()) { 303 logger.trace("Checking whether " + item + " is in collection " + filter.getValue()); 304 } 305 JavaPropertyMatcher jpm = new JavaPropertyMatcher(filter); 306 matches = jpm.matches(item); 307 if (matches) { 308 this.matchedValues = EntitlementCollection.mergeValues(filter.getProperty(), jpm.getMatchedValue(), this.matchedValues); 309 break; 310 } 311 } 312 evaluationStack.push(matches); 313 } 314 } 315 result = this.evaluationStack.peek(); 316 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof Collection) { 317 this.evaluationStack.pop(); 318 // Try it as a lookup on the context object 319 Collection<?> filterValue = (Collection<?>)filter.getValue(); 320 if (filterValue != null) { 321 for(Object propertyPathObj : filterValue) { 322 String propertyPath = Utilities.safeString(propertyPathObj); 323 if (logger.isTraceEnabled()) { 324 logger.trace("Failed to match in() in default and contains mode; attempting to use " + propertyPath + " as a property of the target object " + this.contextObject); 325 } 326 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 327 List<Object> propertyCollection = new ArrayList<>(); 328 if (propertyValue instanceof Collection) { 329 propertyCollection.addAll((Collection<?>) propertyValue); 330 } else if (propertyValue instanceof Object[]) { 331 propertyCollection.addAll(Arrays.asList((Object[]) propertyValue)); 332 } else { 333 propertyCollection.add(propertyValue); 334 } 335 if (logger.isTraceEnabled()) { 336 Object actual = this.getPropertyValue(filter, this.objectToMatch); 337 logger.trace("Checking whether " + actual + " is in collection " + propertyCollection); 338 } 339 Filter alteredFilter = Filter.in(filter.getProperty(), propertyCollection); 340 visitIn((Filter.LeafFilter) alteredFilter); 341 result = this.evaluationStack.peek(); 342 if (result) { 343 break; 344 } else { 345 evaluationStack.pop(); 346 } 347 } 348 } else { 349 this.evaluationStack.push(false); 350 } 351 } 352 } 353 354 /** 355 * Performs a 'like' evaluation, which includes starts-with, contains, and ends-with. 356 * This forwards to the default implementation first, then attempts to evaluate against 357 * each item in the list, as Hibernate would do. 358 * 359 * @param filter The filter to evaluate 360 * @throws GeneralException if anything goes wrong 361 */ 362 @Override 363 public void visitLike(Filter.LeafFilter filter) throws GeneralException { 364 super.visitLike(filter); 365 boolean result = this.evaluationStack.peek(); 366 367 if (!result) { 368 if (filter.getValue() instanceof String) { 369 evaluationStack.pop(); 370 if (logger.isTraceEnabled()) { 371 logger.trace("Failed to match like() in default mode; attempting a Hibernate-like 'contains'"); 372 } 373 374 Object actual = this.getPropertyValue(filter, this.objectToMatch); 375 376 boolean matches = false; 377 if (actual instanceof Collection) { 378 for(Object value : ((Collection<?>) actual)) { 379 if (value instanceof String) { 380 String listItem = (String) value; 381 JavaPropertyMatcher jpm = new JavaPropertyMatcher(filter); 382 matches = jpm.matches(listItem); 383 } 384 385 if (matches) { 386 break; 387 } 388 } 389 } 390 evaluationStack.push(matches); 391 } 392 } 393 } 394 395 /** 396 * Performs a 'ne' (not equals) evaluation. 397 * 398 * @param filter The not-equals Filter to evaluate 399 * @throws GeneralException if anything fails 400 */ 401 @Override 402 public void visitNE(Filter.LeafFilter filter) throws GeneralException { 403 super.visitNE(filter); 404 boolean result = this.evaluationStack.peek(); 405 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 406 this.evaluationStack.pop(); 407 // Try it as a lookup on the context object 408 String propertyPath = (String) filter.getValue(); 409 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 410 Object actual = this.getPropertyValue(filter, this.objectToMatch); 411 if (!Sameness.isSame(propertyValue, actual, filter.isIgnoreCase())) { 412 evaluationStack.push(true); 413 } else { 414 evaluationStack.push(false); 415 } 416 } 417 } 418 419 /** 420 * Performs a subquery against the database. 421 * 422 * @see HybridReflectiveMatcher#visitSubquery 423 */ 424 @Override 425 public void visitSubquery(Filter.LeafFilter filter) throws GeneralException { 426 if (context != null) { 427 if (filter.getSubqueryClass() != null && SailPointObject.class.isAssignableFrom(filter.getSubqueryClass())) { 428 QueryOptions options = new QueryOptions(); 429 options.addFilter(filter.getSubqueryFilter()); 430 Class<? extends SailPointObject> subqueryClass = (Class<? extends SailPointObject>) filter.getSubqueryClass(); 431 List<Object> subqueryResults = new ArrayList<>(); 432 Iterator<Object[]> subqueryResultIterator = context.search(subqueryClass, options, Arrays.asList(filter.getSubqueryProperty())); 433 while (subqueryResultIterator.hasNext()) { 434 Object[] row = subqueryResultIterator.next(); 435 subqueryResults.add(row[0]); 436 } 437 if (subqueryResults.isEmpty()) { 438 this.evaluationStack.push(false); 439 } else { 440 // Filter.in contains magic, which means it can return a singular IN or 441 // an OR(IN, IN, IN...), depending on how many items are in the list 442 Filter inFilter = Filter.in(filter.getProperty(), subqueryResults); 443 if (inFilter instanceof Filter.LeafFilter) { 444 visitIn((Filter.LeafFilter) inFilter); 445 } else { 446 visitOr((Filter.CompositeFilter) inFilter); 447 } 448 } 449 } else { 450 throw new IllegalArgumentException("Subquery class must be a child of SailPointObject"); 451 } 452 } 453 } 454}