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}