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}