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 */ 065public class HybridObjectMatcher extends HybridReflectiveMatcher { 066 067 private static final Log logger = LogFactory.getLog(HybridObjectMatcher.class); 068 069 /** 070 * Enables the special object property equals behavior if true (default false) 071 */ 072 private final boolean allowObjectPropertyEquals; 073 074 /** 075 * The sailpoint context to use for querying if needed 076 */ 077 private final SailPointContext context; 078 079 /** 080 * The context object, which must be attached to the context (or entirely detached) 081 */ 082 private Object contextObject; 083 084 /** 085 * True if we should use tolerant paths (default true) 086 */ 087 private final boolean tolerantPaths; 088 089 /** 090 * Constructor for the basic case where defaults are used for allowObjectPropertyEquals (false) and tolerantPaths (true) 091 * @param context The Sailpoint context 092 * @param filter The filter to evaluate 093 */ 094 public HybridObjectMatcher(SailPointContext context, Filter filter) { 095 this(context, filter, false, true); 096 } 097 098 /** 099 * Constructor allowing the user to specify whether to allow object properties in equals 100 * @param context The Sailpoint context 101 * @param filter The filter to evaluate 102 * @param allowObjectPropertyEquals If true, object property comparisons will be allowed 103 */ 104 public HybridObjectMatcher(SailPointContext context, Filter filter, boolean allowObjectPropertyEquals) { 105 this(context, filter, allowObjectPropertyEquals, true); 106 } 107 108 /** 109 * Constructor allowing the user to specify all boolean options 110 * @param context The Sailpoint context 111 * @param filter The filter to evaluate 112 * @param allowObjectPropertyEquals If true, object property comparisons will be allowed 113 * @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 114 */ 115 public HybridObjectMatcher(SailPointContext context, Filter filter, boolean allowObjectPropertyEquals, boolean tolerantPaths) { 116 super(context, filter); 117 this.context = context; 118 this.allowObjectPropertyEquals = allowObjectPropertyEquals; 119 this.tolerantPaths = tolerantPaths; 120 } 121 122 123 /** 124 * Walk the property tree and also resolve certain types of objects nested. 125 * 126 * @param leaf The filter from which to get the information 127 * @param o The object to initially walk from 128 * @return The property value associated with the path 129 * @throws GeneralException If any lookup failures occur during path walking 130 */ 131 @Override 132 public Object getPropertyValue(Filter.LeafFilter leaf, Object o) throws GeneralException { 133 String propertyPath = leaf.getProperty(); 134 Object propertyValue = Utilities.getProperty(o, propertyPath, tolerantPaths); 135 // Magic conversion for comparing IDs to objects in Filters, 136 // like Filter.eq("manager.id", identityObject) or Filter.eq("manager", managerId) 137 if (leaf.getValue() != null) { 138 if (leaf.getValue() instanceof String && propertyValue instanceof SailPointObject) { 139 propertyValue = ((SailPointObject) propertyValue).getId(); 140 } else if (leaf.getValue() instanceof SailPointObject && propertyValue instanceof String) { 141 if (context != null) { 142 SailPointObject spoLeaf = (SailPointObject) leaf.getValue(); 143 SailPointObject spo = context.getObject(spoLeaf.getClass(), (String)propertyValue); 144 if (spo != null) { 145 propertyValue = spo; 146 } 147 } 148 } 149 } 150 return propertyValue; 151 } 152 153 /** 154 * Returns true if the properties of the input object satisfy this Matcher's Filter. 155 * 156 * Filter property names should be specified in the path syntax supported by {@link Utilities#getProperty(Object, String)}. 157 * 158 * @see HybridReflectiveMatcher#matches(Object) 159 */ 160 @Override 161 public boolean matches(Object o) throws GeneralException { 162 contextObject = o; 163 return super.matches(o); 164 } 165 166 /** 167 * Handles the 'containsAll' filter type. By default, defers to the superclass. 168 * 169 * If the normal behavior fails to match, and allowObjectPropertyEquals is true, the relevant 170 * property is retrieved and the containsAll operation repeated against it. 171 * 172 * @param filter The filter to evaluate 173 * @throws GeneralException on failures to read the attributes in question 174 */ 175 @Override 176 public void visitContainsAll(Filter.LeafFilter filter) throws GeneralException { 177 super.visitContainsAll(filter); 178 boolean result = this.evaluationStack.peek(); 179 if (!result) { 180 if (logger.isTraceEnabled()) { 181 Object actual = this.getPropertyValue(filter, this.objectToMatch); 182 logger.trace("Failed to match containsAll() on " + actual); 183 } 184 } 185 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 186 this.evaluationStack.pop(); 187 // Try it as a lookup on the context object 188 if (logger.isTraceEnabled()) { 189 logger.trace("Failed to match containsAll() in default mode; attempting to use " + filter.getValue() + " as a property of the target object " + this.contextObject); 190 } 191 String propertyPath = (String) filter.getValue(); 192 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 193 List<Object> propertyCollection = new ArrayList<>(); 194 if (propertyValue instanceof Collection) { 195 propertyCollection.addAll((Collection<?>) propertyValue); 196 } else if (propertyValue instanceof Object[]) { 197 propertyCollection.addAll(Arrays.asList((Object[]) propertyValue)); 198 } else { 199 propertyCollection.add(propertyValue); 200 } 201 Filter alteredFilter = Filter.containsAll(filter.getProperty(), propertyCollection); 202 JavaPropertyMatcher jpm = new JavaPropertyMatcher((Filter.LeafFilter) alteredFilter); 203 Object actual = this.getPropertyValue(filter, this.objectToMatch); 204 if (logger.isTraceEnabled()) { 205 logger.trace("Comparing " + actual + " with " + propertyCollection); 206 } 207 boolean matches = jpm.matches(actual); 208 if (matches) { 209 this.matchedValues = EntitlementCollection.mergeValues(filter.getProperty(), jpm.getMatchedValue(), this.matchedValues); 210 } 211 this.evaluationStack.push(matches); 212 } 213 } 214 215 /** 216 * Extends the OOTB behavior of 'equals' Filters for a couple of specific cases. 217 * 218 * First, if the object's property value is a Collection and the test parameter is 219 * a String or a Boolean, we check to see if the Collection contains the single 220 * value. This is the way Filters behave when translated to HQL, thanks to SQL 221 * joins, so we want to retain that behavior here. 222 * 223 * Second, if we have allowed object properties to be used on both sides of the 224 * 'equals' Filter, and there is still not a valid match, we attempt to evaluate 225 * the test argument as an object property and compare those. 226 * 227 * @param filter The filter to check for equality 228 * @throws GeneralException if an IIQ error occurs 229 */ 230 @Override 231 public void visitEQ(Filter.LeafFilter filter) throws GeneralException { 232 super.visitEQ(filter); 233 boolean result = this.evaluationStack.peek(); 234 235 if (!result) { 236 if (filter.getValue() instanceof String || filter.getValue() instanceof Boolean) { 237 evaluationStack.pop(); 238 if (logger.isTraceEnabled()) { 239 logger.trace("Failed to match eq() in default mode; attempting a Hibernate-like 'contains'"); 240 } 241 Object actual = this.getPropertyValue(filter, this.objectToMatch); 242 Object propertyValue = filter.getValue(); 243 244 boolean matches = false; 245 if (actual instanceof Collection) { 246 if (filter.isIgnoreCase()) { 247 matches = Utilities.caseInsensitiveContains((Collection<? extends Object>) actual, propertyValue); 248 } else { 249 matches = Util.nullSafeContains(new ArrayList<>((Collection<?>) actual), propertyValue); 250 } 251 } 252 evaluationStack.push(matches); 253 } 254 } 255 result = this.evaluationStack.peek(); 256 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 257 if (logger.isTraceEnabled()) { 258 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); 259 } 260 this.evaluationStack.pop(); 261 // Try it as a lookup on the context object 262 String propertyPath = (String) filter.getValue(); 263 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 264 Object actual = this.getPropertyValue(filter, this.objectToMatch); 265 266 if (actual != null && propertyValue != null) { 267 if (logger.isTraceEnabled()) { 268 logger.trace("Comparing " + actual + " with " + propertyValue); 269 } 270 Filter derivedFilter = Filter.eq(filter.getProperty(), propertyValue); 271 visitEQ((Filter.LeafFilter) derivedFilter); 272 } else { 273 evaluationStack.push(false); 274 } 275 } 276 } 277 278 /** 279 * Performs the 'in' Filter operation, checking whether the value of the given property 280 * is in the specified list of values. 281 * 282 * @param filter The 'in' Filter 283 * @throws GeneralException if anything goes wrong 284 */ 285 @Override 286 public void visitIn(Filter.LeafFilter filter) throws GeneralException { 287 super.visitIn(filter); 288 boolean result = this.evaluationStack.peek(); 289 if (!result) { 290 Object actual = this.getPropertyValue(filter, this.objectToMatch); 291 if (actual instanceof Collection && filter.getValue() instanceof Collection) { 292 if (logger.isTraceEnabled()) { 293 logger.trace("Failed to match in() in default mode; attempting to use a Hibernate-style contains"); 294 } 295 evaluationStack.pop(); 296 // This makes 'links.application.name.in({"HR"}) work 297 boolean matches = false; 298 for(Object item : (Collection<Object>)actual) { 299 if (logger.isTraceEnabled()) { 300 logger.trace("Checking whether " + item + " is in collection " + filter.getValue()); 301 } 302 JavaPropertyMatcher jpm = new JavaPropertyMatcher(filter); 303 matches = jpm.matches(item); 304 if (matches) { 305 this.matchedValues = EntitlementCollection.mergeValues(filter.getProperty(), jpm.getMatchedValue(), this.matchedValues); 306 break; 307 } 308 } 309 evaluationStack.push(matches); 310 } 311 } 312 result = this.evaluationStack.peek(); 313 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof Collection) { 314 this.evaluationStack.pop(); 315 // Try it as a lookup on the context object 316 Collection<?> filterValue = (Collection<?>)filter.getValue(); 317 if (filterValue != null) { 318 for(Object propertyPathObj : filterValue) { 319 String propertyPath = Utilities.safeString(propertyPathObj); 320 if (logger.isTraceEnabled()) { 321 logger.trace("Failed to match in() in default and contains mode; attempting to use " + propertyPath + " as a property of the target object " + this.contextObject); 322 } 323 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 324 List<Object> propertyCollection = new ArrayList<>(); 325 if (propertyValue instanceof Collection) { 326 propertyCollection.addAll((Collection<?>) propertyValue); 327 } else if (propertyValue instanceof Object[]) { 328 propertyCollection.addAll(Arrays.asList((Object[]) propertyValue)); 329 } else { 330 propertyCollection.add(propertyValue); 331 } 332 if (logger.isTraceEnabled()) { 333 Object actual = this.getPropertyValue(filter, this.objectToMatch); 334 logger.trace("Checking whether " + actual + " is in collection " + propertyCollection); 335 } 336 Filter alteredFilter = Filter.in(filter.getProperty(), propertyCollection); 337 visitIn((Filter.LeafFilter) alteredFilter); 338 result = this.evaluationStack.peek(); 339 if (result) { 340 break; 341 } else { 342 evaluationStack.pop(); 343 } 344 } 345 } else { 346 this.evaluationStack.push(false); 347 } 348 } 349 } 350 351 /** 352 * Performs a 'like' evaluation, which includes starts-with, contains, and ends-with. 353 * This forwards to the default implementation first, then attempts to evaluate against 354 * each item in the list, as Hibernate would do. 355 * 356 * @param filter The filter to evaluate 357 * @throws GeneralException if anything goes wrong 358 */ 359 @Override 360 public void visitLike(Filter.LeafFilter filter) throws GeneralException { 361 super.visitLike(filter); 362 boolean result = this.evaluationStack.peek(); 363 364 if (!result) { 365 if (filter.getValue() instanceof String) { 366 evaluationStack.pop(); 367 if (logger.isTraceEnabled()) { 368 logger.trace("Failed to match like() in default mode; attempting a Hibernate-like 'contains'"); 369 } 370 371 Object actual = this.getPropertyValue(filter, this.objectToMatch); 372 373 boolean matches = false; 374 if (actual instanceof Collection) { 375 for(Object value : ((Collection<?>) actual)) { 376 if (value instanceof String) { 377 String listItem = (String) value; 378 JavaPropertyMatcher jpm = new JavaPropertyMatcher(filter); 379 matches = jpm.matches(listItem); 380 } 381 382 if (matches) { 383 break; 384 } 385 } 386 } 387 evaluationStack.push(matches); 388 } 389 } 390 } 391 392 /** 393 * Performs a 'ne' (not equals) evaluation. 394 * 395 * @param filter The not-equals Filter to evaluate 396 * @throws GeneralException if anything fails 397 */ 398 @Override 399 public void visitNE(Filter.LeafFilter filter) throws GeneralException { 400 super.visitNE(filter); 401 boolean result = this.evaluationStack.peek(); 402 if (allowObjectPropertyEquals && !result && contextObject != null && filter.getValue() instanceof String) { 403 this.evaluationStack.pop(); 404 // Try it as a lookup on the context object 405 String propertyPath = (String) filter.getValue(); 406 Object propertyValue = Utilities.getProperty(contextObject, propertyPath, tolerantPaths); 407 Object actual = this.getPropertyValue(filter, this.objectToMatch); 408 if (!Sameness.isSame(propertyValue, actual, filter.isIgnoreCase())) { 409 evaluationStack.push(true); 410 } else { 411 evaluationStack.push(false); 412 } 413 } 414 } 415 416 /** 417 * Performs a subquery against the database. 418 * 419 * @see HybridReflectiveMatcher#visitSubquery 420 */ 421 @Override 422 public void visitSubquery(Filter.LeafFilter filter) throws GeneralException { 423 if (context != null) { 424 if (filter.getSubqueryClass() != null && SailPointObject.class.isAssignableFrom(filter.getSubqueryClass())) { 425 QueryOptions options = new QueryOptions(); 426 options.addFilter(filter.getSubqueryFilter()); 427 Class<? extends SailPointObject> subqueryClass = (Class<? extends SailPointObject>) filter.getSubqueryClass(); 428 List<Object> subqueryResults = new ArrayList<>(); 429 Iterator<Object[]> subqueryResultIterator = context.search(subqueryClass, options, Arrays.asList(filter.getSubqueryProperty())); 430 while (subqueryResultIterator.hasNext()) { 431 Object[] row = subqueryResultIterator.next(); 432 subqueryResults.add(row[0]); 433 } 434 if (subqueryResults.isEmpty()) { 435 this.evaluationStack.push(false); 436 } else { 437 // Filter.in contains magic, which means it can return a singular IN or 438 // an OR(IN, IN, IN...), depending on how many items are in the list 439 Filter inFilter = Filter.in(filter.getProperty(), subqueryResults); 440 if (inFilter instanceof Filter.LeafFilter) { 441 visitIn((Filter.LeafFilter) inFilter); 442 } else { 443 visitOr((Filter.CompositeFilter) inFilter); 444 } 445 } 446 } else { 447 throw new IllegalArgumentException("Subquery class must be a child of SailPointObject"); 448 } 449 } 450 } 451}