001package com.identityworksllc.iiq.common;
002
003import org.apache.commons.logging.Log;
004import org.apache.commons.logging.LogFactory;
005import sailpoint.api.Differencer;
006import sailpoint.api.SailPointContext;
007import sailpoint.object.*;
008import sailpoint.tools.GeneralException;
009import sailpoint.tools.Pair;
010import sailpoint.tools.Util;
011
012import java.util.*;
013import java.util.function.Consumer;
014import java.util.function.Function;
015import java.util.function.Predicate;
016import java.util.stream.Collectors;
017
018/**
019 * An API to reimplement the Differencer to increase its reliability with various
020 * Sailpoint objects.
021 *
022 * Most notably:
023 *
024 *  (1) LinkSnapshots will be compared correctly. That is, if the user has more than one
025 *      account on the same Application, the correct LinkSnapshot will be returned, rather
026 *      than simply returning the first one. Only if there are no matches by Native Identity
027 *      will the first LinkSnapshot be returned.
028 *
029 *  (2) The Sameness class will be used to compare changes. This will account for a few
030 *      differences in type and order that are not accounted for out-of-box.
031 *
032 *  (3) Changes solely in String case will be ignored in most cases.
033 *
034 *  (4) Old and new values will be sorted, for easier comparison.
035 *
036 * TODO: Policy violation differences
037 * TODO: Javadocs!
038 */
039public class BetterDifferencer {
040
041    /**
042     * A container for holding a pair of links judged by this tool to be the same
043     * Link in two different snapshot contexts
044     */
045    private static class LinkPair {
046        private LinkSnapshot ls1;
047        private LinkSnapshot ls2;
048
049        public LinkPair() {
050            /* Empty */
051        }
052
053        public LinkPair(LinkSnapshot ls1, LinkSnapshot ls2) {
054            this.ls1 = ls1;
055            this.ls2 = ls2;
056        }
057
058        public LinkSnapshot getLs1() {
059            return ls1;
060        }
061
062        public LinkSnapshot getLs2() {
063            return ls2;
064        }
065
066        public void setLs1(LinkSnapshot ls1) {
067            this.ls1 = ls1;
068        }
069
070        public void setLs2(LinkSnapshot ls2) {
071            this.ls2 = ls2;
072        }
073    }
074
075    /**
076     * A set of applications where the comparison ought to be case-insensitive
077     */
078    private final Set<String> caseInsensitiveApplications;
079
080    /**
081     * A set of fields on which the comparison should be done case-insensitively
082     */
083    private final Set<String> caseInsensitiveFields;
084    private final SailPointContext context;
085
086    /**
087     * If true, we should guess when there is a plausible account rename. This is
088     * only possible if the user had exactly one account before and exactly one
089     * account after.
090     */
091    private boolean guessRenames;
092    private final Log log;
093    /**
094     * A map from the old to new name of a renamed application
095     */
096    private final Map<String, String> renamedApplications;
097
098    /**
099     * Constructs a new BetterDifferencer with the given IIQ context
100     * @param context The IIQ context to use for lookups
101     */
102    public BetterDifferencer(SailPointContext context) {
103        this.log = LogFactory.getLog(this.getClass());
104        this.context = Objects.requireNonNull(context);
105        this.caseInsensitiveFields = new HashSet<>();
106        this.caseInsensitiveApplications = new HashSet<>();
107        this.renamedApplications = new HashMap<>();
108    }
109
110    /**
111     * Adds the added and removed values to the Difference object by comparing the old
112     * and new values.
113     *
114     * @param difference The Difference object to populate
115     * @param oldValue The old value, which may be a collection or a single-valued object
116     * @param newValue The new value, which may be a collection or a single-valued object
117     */
118    private void addAddedRemovedValues(Difference difference, Object oldValue, Object newValue) {
119        List<Object> oldCollection = new ArrayList<>();
120        List<Object> newCollection = new ArrayList<>();
121
122        if (oldValue instanceof Collection) {
123            oldCollection.addAll((Collection<?>)oldValue);
124        } else if (oldValue != null) {
125            oldCollection.add(oldValue);
126        }
127
128        if (newValue instanceof Collection) {
129            newCollection.addAll((Collection<?>)newValue);
130        } else if (newValue != null) {
131            newCollection.add(newValue);
132        }
133
134        List<String> removedValues = oldCollection.stream().filter(o -> !Utilities.caseInsensitiveContains(newCollection, o)).filter(Objects::nonNull).map(String::valueOf).collect(Collectors.toList());
135        List<String> addedValues = newCollection.stream().filter(o -> !Utilities.caseInsensitiveContains(oldCollection, o)).filter(Objects::nonNull).map(String::valueOf).collect(Collectors.toList());
136
137        if (!addedValues.isEmpty()) {
138            difference.setAddedValues(addedValues);
139        }
140        if (!removedValues.isEmpty()) {
141            difference.setRemovedValues(removedValues);
142        }
143    }
144
145    /**
146     * Adds a particular application name as case-insensitive
147     * @param application The application name
148     */
149    public void addCaseInsensitiveApplication(String application) {
150        this.caseInsensitiveApplications.add(application);
151    }
152
153    /**
154     * Adds a particular field on a particular applicaiton as case-insensitive
155     * @param application The application name
156     * @param field The field name
157     */
158    public void addCaseInsensitiveField(String application, String field) {
159        caseInsensitiveFields.add(application + ": " + field);
160    }
161
162    /**
163     * Adds a 'before' and 'after' to the rename map, which is used to find
164     * pairs of Links and also to figure out which Application's schema to use
165     * for difference detection.
166     *
167     * @param oldName The old name
168     * @param newName The new name
169     */
170    public void addRenamedApplication(String oldName, String newName) {
171        this.renamedApplications.put(oldName, newName);
172    }
173
174    /**
175     * Handles the situation where the previous Link is null or has no attributes
176     * (usually, the current Link is brand new). All attributes are added as differences
177     * with only a 'new' value.
178     *
179     * The parameters to this method are all functional, allowing the various places
180     * this is used to customize the behavior as needed.
181     *
182     * @param differenceConsumer The callback to which each difference is passed
183     * @param isMultiValued A predicate that returns true if the given attribute (by name) is multi-valued
184     * @param getDisplayName A predicate that returns the display name of the given attribute (by name)
185     * @param afterAttributes The actual map of object attributes
186     */
187    private void allNew(Consumer<Difference> differenceConsumer, Predicate<String> isMultiValued, Function<String, String> getDisplayName, Attributes<String, Object> afterAttributes) {
188        for (String key : afterAttributes.getKeys()) {
189            Object newValue = afterAttributes.get(key);
190            Difference difference = new Difference();
191            difference.setAttribute(key);
192            difference.setNewValue(Utilities.safeString(newValue));
193            difference.setMulti(isMultiValued.test(key));
194            difference.setDisplayName(getDisplayName.apply(key));
195            if (newValue instanceof Collection) {
196                addAddedRemovedValues(difference, null, newValue);
197            }
198            differenceConsumer.accept(difference);
199        }
200    }
201
202    /**
203     * Handles the situation where the previous Link is null or has no attributes
204     * (usually, the current Link has been deleted). All attributes are added as differences
205     * with only an 'old' value.
206     *
207     * The parameters to this method are all functional, allowing the various places
208     * this is used to customize the behavior as needed.
209     *
210     * @param differenceConsumer The callback to which each difference is passed
211     * @param isMultiValued A predicate that returns true if the given attribute (by name) is multi-valued
212     * @param getDisplayName A predicate that returns the display name of the given attribute (by name)
213     * @param beforeAttributes The actual map of object attributes
214     */
215    private void allOld(Consumer<Difference> differenceConsumer, Predicate<String> isMultiValued, Function<String, String> getDisplayName, Attributes<String, Object> beforeAttributes) {
216        for (String key : beforeAttributes.getKeys()) {
217            Object oldValue = beforeAttributes.get(key);
218            Difference difference = new Difference();
219            difference.setAttribute(key);
220            difference.setOldValue(Utilities.safeString(oldValue));
221            difference.setMulti(isMultiValued.test(key));
222            difference.setDisplayName(getDisplayName.apply(key));
223            if (oldValue instanceof Collection) {
224                addAddedRemovedValues(difference, oldValue, null);
225            }
226            differenceConsumer.accept(difference);
227        }
228    }
229
230    /**
231     * Diffs the two snapshots and returns an IdentityDifference object containing all
232     * of the attribute, link, and role differences.
233     *
234     * TODO support permissions and policy violations
235     *
236     * @param before The before snapshot
237     * @param after The after snapshot
238     * @return Any differences between the two snapshots
239     * @throws GeneralException if any failures occur
240     */
241    public IdentityDifference diff(IdentitySnapshot before, IdentitySnapshot after) throws GeneralException {
242        IdentityDifference differences = new IdentityDifference();
243        diffIdentityAttributes(differences, before, after);
244        List<LinkPair> linkPairs = findLinkPairs(before, after);
245        for(LinkPair pair : linkPairs) {
246            String context;
247            if (pair.ls2 != null) {
248                context = IdentityDifference.generateContext(pair.ls2.getApplicationName(), pair.ls2.getNativeIdentity());
249            } else {
250                context = IdentityDifference.generateContext(pair.ls1.getApplicationName(), pair.ls1.getNativeIdentity());
251            }
252            diffLinks(differences, context, pair.ls1, pair.ls2);
253        }
254
255        // TODO I really need to figure out a good way to do role differences
256        // because there's so much more in the BundleSnapshot and the RoleAssignmentSnapshot
257        // that isn't easily captured by the Difference class. Notably, the associated
258        // entitlements and role targets.
259        List<String> rolesBefore = Utilities.safeStream(before.getBundles()).map(BundleSnapshot::getName).sorted().collect(Collectors.toList());
260        List<String> rolesAfter = Utilities.safeStream(after.getBundles()).map(BundleSnapshot::getName).sorted().collect(Collectors.toList());
261        Difference rolesDifference = Difference.diff(rolesBefore, rolesAfter, 4000, true);
262        if (rolesDifference != null) {
263            differences.addBundleDifference(rolesDifference);
264        }
265
266        List<String> assignmentsBefore = Utilities.safeStream(before.getAssignedRoles()).map(RoleAssignmentSnapshot::getName).sorted().collect(Collectors.toList());
267        List<String> assignmentsAfter = Utilities.safeStream(after.getAssignedRoles()).map(RoleAssignmentSnapshot::getName).sorted().collect(Collectors.toList());
268        Difference assignmentDifference = Difference.diff(assignmentsBefore, assignmentsAfter, 4000, true);
269        if (assignmentDifference != null) {
270            differences.addAssignedRoleDifference(assignmentDifference);
271        }
272        return differences;
273    }
274
275    /**
276     * Performs a diff of the two attribute maps provided, calling the supplied function hooks
277     * as needed to handle the details.
278     *
279     * @param beforeAttributes The attributes from "before"
280     * @param afterAttributes The attributes from "after"
281     * @param exclusions Any attributes to exclude from consideration
282     * @param isMultiValued A function that returns true if the given attribute is multi-valued
283     * @param getDisplayName A function that returns the display name of the given attribute
284     * @param differenceConsumer A handler to consume any Difference objects produced
285     */
286    private void diff(String application, Attributes<String, Object> beforeAttributes, Attributes<String, Object> afterAttributes, List<String> exclusions, Predicate<String> isMultiValued, Function<String, String> getDisplayName, Consumer<Difference> differenceConsumer) {
287        Set<String> onlyBefore = new HashSet<>();
288        Set<String> onlyAfter = new HashSet<>();
289        Set<String> both = new HashSet<>();
290        if (beforeAttributes == null) {
291            allNew(differenceConsumer, isMultiValued, getDisplayName, afterAttributes);
292        } else if (afterAttributes == null) {
293            allOld(differenceConsumer, isMultiValued, getDisplayName, beforeAttributes);
294        } else {
295            for (String key : beforeAttributes.keySet()) {
296                if (!afterAttributes.containsKey(key)) {
297                    onlyBefore.add(key);
298                } else {
299                    both.add(key);
300                }
301            }
302            for (String key : afterAttributes.keySet()) {
303                if (!beforeAttributes.containsKey(key)) {
304                    onlyAfter.add(key);
305                } else {
306                    both.add(key);
307                }
308            }
309            for(String exclusion : Util.safeIterable(exclusions)) {
310                onlyBefore.remove(exclusion);
311                onlyAfter.remove(exclusion);
312                both.remove(exclusion);
313            }
314            for (String key : both) {
315                Object oldValue = beforeAttributes.get(key);
316                Object newValue = afterAttributes.get(key);
317                if (!Differencer.objectsEqual(oldValue, newValue, true)) {
318                    boolean ignoreCase = false;
319                    if (caseInsensitiveApplications.contains(application)) {
320                        ignoreCase = true;
321                    } else {
322                        String field = application + ": " + key;
323                        if (caseInsensitiveFields.contains(field)) {
324                            ignoreCase = true;
325                        }
326                    }
327                    if (!Sameness.isSame(oldValue, newValue, ignoreCase)) {
328                        Difference difference = new Difference();
329                        difference.setAttribute(key);
330                        difference.setNewValue(Utilities.safeString(newValue));
331                        difference.setOldValue(Utilities.safeString(oldValue));
332                        difference.setMulti(isMultiValued.test(key));
333                        difference.setDisplayName(getDisplayName.apply(key));
334                        if (oldValue instanceof Collection || newValue instanceof Collection) {
335                            addAddedRemovedValues(difference, oldValue, newValue);
336                        }
337                        differenceConsumer.accept(difference);
338                    }
339                }
340            }
341            for (String key : onlyBefore) {
342                Object oldValue = beforeAttributes.get(key);
343                Difference difference = new Difference();
344                difference.setAttribute(key);
345                difference.setOldValue(Utilities.safeString(oldValue));
346                difference.setMulti(isMultiValued.test(key));
347                difference.setDisplayName(getDisplayName.apply(key));
348                if (oldValue instanceof Collection) {
349                    addAddedRemovedValues(difference, oldValue, null);
350                }
351                differenceConsumer.accept(difference);
352            }
353            for (String key : onlyAfter) {
354                Object newValue = afterAttributes.get(key);
355                Difference difference = new Difference();
356                difference.setAttribute(key);
357                difference.setNewValue(Utilities.safeString(newValue));
358                difference.setMulti(isMultiValued.test(key));
359                difference.setDisplayName(getDisplayName.apply(key));
360                if (newValue instanceof Collection) {
361                    addAddedRemovedValues(difference, null, newValue);
362                }
363                differenceConsumer.accept(difference);
364            }
365        }
366    }
367
368    private void diffIdentityAttributes(IdentityDifference difference, IdentitySnapshot before, IdentitySnapshot after) {
369        Attributes<String, Object> beforeAttributes = before.getAttributes();
370        Attributes<String, Object> afterAttributes = after.getAttributes();
371        final ObjectConfig identityAttributes = Identity.getObjectConfig();
372
373        diff(
374                ProvisioningPlan.APP_IIQ,
375                beforeAttributes,
376                afterAttributes,
377                null,
378                a -> nullSafeObjectAttribute(identityAttributes, a).isMulti(),
379                a -> nullSafeObjectAttribute(identityAttributes, a).getDisplayName(),
380                difference::addAttributeDifference
381        );
382    }
383
384    /**
385     * Detects the differences in the given LinkSnapshots and stores them in the
386     * IdentityDifference container
387     *
388     * @param differences The object into which differences ar added
389     * @param contextName The context name (the native ID)
390     * @param beforeLink The link before the change
391     * @param afterLink The link after the change
392     * @throws GeneralException if anything goes wrong
393     */
394    private void diffLinks(IdentityDifference differences, String contextName, LinkSnapshot beforeLink, LinkSnapshot afterLink) throws GeneralException {
395        List<String> exclusions = Arrays.asList("directPermissions", "targetPermissions");
396        Application application = null;
397        if (beforeLink != null) {
398            application = context.getObject(Application.class, beforeLink.getApplication());
399
400            if (application == null) {
401                String renameMaybe = this.renamedApplications.get(beforeLink.getApplication());
402                if (Util.isNotNullOrEmpty(renameMaybe)) {
403                    application = context.getObject(Application.class, renameMaybe);
404                }
405            }
406        }
407        if (application == null && afterLink != null) {
408            application = context.getObject(Application.class, afterLink.getApplication());
409
410            if (application == null) {
411                String renameMaybe = this.renamedApplications.get(afterLink.getApplication());
412                if (Util.isNotNullOrEmpty(renameMaybe)) {
413                    application = context.getObject(Application.class, renameMaybe);
414                }
415            }
416        }
417        if (application == null) {
418            // The application has probably been deleted. We can't do anything
419            // here because we need the application schema to continue.
420            // TODO Figure out if there's a way to fake the schema?
421            log.debug("Unable to find an application");
422            if (beforeLink != null) {
423                log.debug("Before application is " + beforeLink.getApplication());
424            }
425            if (afterLink != null) {
426                log.debug("After application is " + afterLink.getApplication());
427            }
428            return;
429        }
430        boolean ignoreCase = false;
431        if (caseInsensitiveApplications.contains(application.getName()) || application.isCaseInsensitive()) {
432            ignoreCase = true;
433        }
434        Schema accountSchema = application.getAccountSchema();
435        Attributes<String, Object> beforeAttributes = new Attributes<>();
436        if (beforeLink != null && beforeLink.getAttributes() != null) {
437            beforeAttributes.putAll(beforeLink.getAttributes());
438        }
439        Attributes<String, Object> afterAttributes = new Attributes<>();
440        if (afterLink != null && afterLink.getAttributes() != null) {
441            afterAttributes.putAll(afterLink.getAttributes());
442        }
443        List<Difference> linkDifferences = new ArrayList<>();
444        diff(
445                application.getName(),
446                beforeAttributes,
447                afterAttributes,
448                exclusions,
449                a -> nullSafeObjectAttribute(accountSchema, a).isMultiValued(),
450                a -> nullSafeObjectAttribute(accountSchema, a).getDisplayName(),
451                d -> {
452                    d.setContext(contextName);
453                    linkDifferences.add(d);
454                }
455        );
456
457        // Check the rename case
458        String beforeNativeIdentity = null;
459        String afterNativeIdentity = null;
460        if (beforeLink != null) {
461            beforeNativeIdentity = beforeLink.getNativeIdentity();
462        }
463        if (afterLink != null) {
464            afterNativeIdentity = afterLink.getNativeIdentity();
465        }
466
467        if (!Differencer.objectsEqual(beforeNativeIdentity, afterNativeIdentity, true)) {
468            Difference niDifference = new Difference();
469            niDifference.setAttribute("nativeIdentity");
470            niDifference.setContext(contextName);
471            niDifference.setOldValue(beforeNativeIdentity);
472            niDifference.setNewValue(afterNativeIdentity);
473            linkDifferences.add(niDifference);
474        }
475
476        differences.addLinkDifferences(linkDifferences);
477
478        List<Permission> beforePermissions = getPermissions(beforeLink);
479        List<Permission> afterPermissions = getPermissions(afterLink);
480
481        Collections.sort(beforePermissions, Comparator.comparing(Permission::getTarget).thenComparing(Permission::getRights));
482        Collections.sort(afterPermissions, Comparator.comparing(Permission::getTarget).thenComparing(Permission::getRights));
483
484        diffPermissions(beforeLink, afterLink, beforePermissions, afterPermissions, ignoreCase, differences::add);
485    }
486
487    /**
488     * Diffs the sorted permissions lists between the two link snapshots
489     * @param beforeLink The 'before' Link
490     * @param afterLink The 'after' Link
491     * @param beforePermissions The 'before' permissions, sorted
492     * @param afterPermissions The 'after' permissions, sorted
493     * @param ignoreCase If true, case will be ignored for comparison of target and rights
494     * @param differenceConsumer Differences will be passed to this callback for processing
495     */
496    private void diffPermissions(LinkSnapshot beforeLink, LinkSnapshot afterLink, List<Permission> beforePermissions, List<Permission> afterPermissions, boolean ignoreCase, Consumer<PermissionDifference> differenceConsumer) {
497        List<Pair<Permission, Permission>> changes = new ArrayList<>();
498        List<Permission> newPermissions = new ArrayList<>();
499        if (afterPermissions != null) {
500            // Copy so we can remove them as we match
501            newPermissions.addAll(afterPermissions);
502        }
503        List<Permission> oldPermissions = new ArrayList<>();
504        for(Permission p1 : Util.safeIterable(beforePermissions)) {
505            Permission p2 = findPermission(p1, newPermissions, ignoreCase);
506            if (p2 == null) {
507                oldPermissions.add(p1);
508            } else {
509                newPermissions.remove(p2);
510                Pair<Permission, Permission> pair = new Pair<>(p1, p2);
511                changes.add(pair);
512            }
513        }
514        for(Permission p1 : oldPermissions) {
515            PermissionDifference permissionDifference = new PermissionDifference();
516            permissionDifference.setRights(p1.getRights());
517            permissionDifference.setTarget(p1.getTarget());
518            permissionDifference.setApplication(beforeLink.getApplicationName());
519            permissionDifference.setRemoved(true);
520            differenceConsumer.accept(permissionDifference);
521        }
522        for(Pair<Permission, Permission> pair : changes) {
523            // Would be nice to be able to track before/after here, but PermissionDifference
524            // only tracks the after
525            Permission p2 = pair.getSecond();
526            PermissionDifference permissionDifference = new PermissionDifference();
527            permissionDifference.setRights(p2.getRights());
528            permissionDifference.setTarget(p2.getTarget());
529            permissionDifference.setApplication(afterLink.getApplicationName());
530            differenceConsumer.accept(permissionDifference);
531        }
532        for(Permission p2 : newPermissions) {
533            PermissionDifference permissionDifference = new PermissionDifference();
534            permissionDifference.setRights(p2.getRights());
535            permissionDifference.setTarget(p2.getTarget());
536            permissionDifference.setApplication(afterLink.getApplicationName());
537            differenceConsumer.accept(permissionDifference);
538        }
539    }
540
541    /**
542     * Finds the a LinkSnapshot in the given list that matches the target by app,
543     * instance, and nativeIdentity
544     *
545     * @param snapshots The list of LinkSnapshots to search
546     * @param target The target to find
547     * @return the discovered LinkSnapshot, or null if none
548     */
549    private LinkSnapshot findLink(List<LinkSnapshot> snapshots, LinkSnapshot target) {
550        for(LinkSnapshot link : Util.safeIterable(snapshots)) {
551            if (Differencer.objectsEqual(link.getApplicationName(), target.getApplicationName(), true) && Differencer.objectsEqual(link.getNativeIdentity(), target.getNativeIdentity(), true) && Differencer.objectsEqual(link.getInstance(), target.getInstance(), true)) {
552                return link;
553            }
554        }
555
556        String translatedName = this.renamedApplications.get(target.getApplicationName());
557        if (Util.isNotNullOrEmpty(translatedName)) {
558            for(LinkSnapshot link : Util.safeIterable(snapshots)) {
559                if (Differencer.objectsEqual(link.getApplicationName(), translatedName, true) && Differencer.objectsEqual(link.getNativeIdentity(), target.getNativeIdentity(), true) && Differencer.objectsEqual(link.getInstance(), target.getInstance(), true)) {
560                    return link;
561                }
562            }
563        }
564
565        return null;
566    }
567
568    /**
569     * Finds pairs of LinkSnapshots in the 'before' and 'after' snapshots by comparing
570     * them by identifier, native ID, or other matching methods.
571     *
572     * @param before The previous Identity Snapshot
573     * @param after The current Identity Snapshot
574     * @return The list of LinkPair objects
575     */
576    private List<LinkPair> findLinkPairs(IdentitySnapshot before, IdentitySnapshot after) {
577        List<LinkPair> pairs = new ArrayList<>();
578        List<LinkSnapshot> beforeLinks = safeCopy(before.getLinks());
579        List<LinkSnapshot> afterLinks = safeCopy(after.getLinks());
580
581        Iterator<LinkSnapshot> beforeIterator = beforeLinks.iterator();
582        while(beforeIterator.hasNext()) {
583            LinkSnapshot ls1 = beforeIterator.next();
584            LinkSnapshot ls2 = findLink(afterLinks, ls1);
585            if (ls2 != null) {
586                LinkPair pair = new LinkPair(ls1, ls2);
587                afterLinks.remove(ls2);
588                beforeIterator.remove();
589                pairs.add(pair);
590            }
591        }
592
593        if (shouldGuessRenames() && !beforeLinks.isEmpty() && !afterLinks.isEmpty()) {
594            // Match by Application only for renames
595            Iterator<LinkSnapshot> beforeIterator2 = beforeLinks.iterator();
596            while(beforeIterator2.hasNext()) {
597                LinkSnapshot ls1 = beforeIterator2.next();
598                List<LinkSnapshot> candidates = findLinksBlindly(afterLinks, ls1);
599                if (candidates.size() == 1) {
600                    // We found just one, assume it's a rename
601                    LinkSnapshot ls2 = candidates.get(0);
602                    if (looksLikeRename(ls1, ls2)) {
603                        LinkPair pair = new LinkPair(ls1, ls2);
604                        afterLinks.remove(ls2);
605                        beforeIterator2.remove();
606                        pairs.add(pair);
607                    }
608                } else if (candidates.size() > 1) {
609                    // See if there are any that look like a rename (i.e. match by all other attributes except nativeIdentity)
610                    for(LinkSnapshot candidate : candidates) {
611                        if (looksLikeRename(ls1, candidate)) {
612                            LinkSnapshot ls2 = candidates.get(0);
613                            LinkPair pair = new LinkPair(ls1, ls2);
614                            afterLinks.remove(ls2);
615                            beforeIterator2.remove();
616                            pairs.add(pair);
617                            break;
618                        }
619                    }
620                }
621            }
622        }
623        if (!afterLinks.isEmpty()) {
624            // New accounts
625            for(LinkSnapshot ls : afterLinks) {
626                LinkPair pair = new LinkPair(null, ls);
627                pairs.add(pair);
628            }
629        }
630        if (!beforeLinks.isEmpty()) {
631            // Deleted accounts
632            for(LinkSnapshot ls : beforeLinks) {
633                LinkPair pair = new LinkPair(ls, null);
634                pairs.add(pair);
635            }
636        }
637
638        return pairs;
639    }
640
641    /**
642     * Finds any LinkSnapshots in the given list that matches the target by app
643     * only, without checking the native identity. This is a last resort if the
644     * account has been renamed.
645     *
646     * @param snapshots The list of LinkSnapshots to search
647     * @param target The target to find
648     * @return the discovered LinkSnapshot, or null if none
649     */
650    private List<LinkSnapshot> findLinksBlindly(List<LinkSnapshot> snapshots, LinkSnapshot target) {
651        List<LinkSnapshot> results = new ArrayList<>();
652        for(LinkSnapshot link : Util.safeIterable(snapshots)) {
653            if (Differencer.objectsEqual(link.getApplicationName(), target.getApplicationName(), true)) {
654                results.add(link);
655            }
656        }
657        return results;
658    }
659
660    /**
661     * Finds the permission in the list matching p1. Permissions will be matched with decreasing specificity:
662     *
663     *  1) [Target, Rights, Annotation]
664     *  2) [Target, Rights]
665     *  3) [Target]
666     *
667     * Matching will be done using the {@link Sameness} class, meaning that list values will be compared independent of order (and possibly case).
668     *
669     * @param p1 The permission to match
670     * @param afterPermissions The list from which permissions will be matched
671     * @param ignoreCase If true, case will be ignored in comparison
672     * @return The matching Permission object from the list, or null
673     */
674    private Permission findPermission(Permission p1, List<Permission> afterPermissions, boolean ignoreCase) {
675        Optional<Permission> permission = Utilities.safeStream(afterPermissions).filter(p2 -> Sameness.isSame(p2.getTarget(), p1.getTarget(), ignoreCase)).filter(p2 -> Sameness.isSame(p2.getRightsList(), p1.getRightsList(), ignoreCase)).filter(p2 -> Util.nullSafeEq(p1.getAnnotation(), p2.getAnnotation())).findFirst();
676        if (permission.isPresent()) {
677            return permission.get();
678        }
679        permission = Utilities.safeStream(afterPermissions).filter(p2 -> Sameness.isSame(p2.getTarget(), p1.getTarget(), ignoreCase)).filter(p2 -> Sameness.isSame(p2.getRightsList(), p1.getRightsList(), ignoreCase)).findFirst();
680        if (permission.isPresent()) {
681            return permission.get();
682        }
683        permission = Utilities.safeStream(afterPermissions).filter(p2 -> Sameness.isSame(p2.getTarget(), p1.getTarget(), ignoreCase)).findFirst();
684        return permission.orElse(null);
685    }
686
687    /**
688     * Gets the Permission objects from the given LinkSnapshot. These are stored in two
689     * places: directPermissions and targetPermissions.
690     *
691     * @param link The link object
692     * @return The permissions, if any
693     */
694    @SuppressWarnings("unchecked")
695    private List<Permission> getPermissions(LinkSnapshot link) {
696        List<Permission> permissions = new ArrayList<>();
697        if (link == null || link.getAttributes() == null) {
698            return permissions;
699        }
700        Attributes<String, Object> attributes = link.getAttributes();
701        if (attributes != null) {
702            List<Permission> perms = (List<Permission>)attributes.get("directPermissions");
703            if (perms != null) {
704                permissions.addAll(perms);
705            }
706
707            perms = (List<Permission>)attributes.get("targetPermissions");
708            if (perms != null) {
709                permissions.addAll(perms);
710            }
711        }
712        return permissions;
713    }
714
715    /**
716     * This looks like a rename if we differ only in nativeIdentity or if the Link IDs match
717     * @param ls1 The LinkSnapshot to check
718     * @param ls2 The other LinkSnapshot to check
719     * @return True if this looks like a rename
720     */
721    private boolean looksLikeRename(LinkSnapshot ls1, LinkSnapshot ls2) {
722        if (Differencer.objectsEqual(ls1.getId(), ls2.getId(), false)) {
723            return true;
724        }
725        if (!Differencer.objectsEqual(ls1.getNativeIdentity(), ls2.getNativeIdentity(), true)) {
726            return Difference.equal(ls1.getAttributes(), ls2.getAttributes());
727        }
728        return false;
729    }
730
731    private AttributeDefinition nullSafeObjectAttribute(Schema source, String name) {
732        AttributeDefinition attr = source.getAttributeDefinition(name);
733        if (attr == null) {
734            attr = new AttributeDefinition();
735        }
736        return attr;
737    }
738
739    private ObjectAttribute nullSafeObjectAttribute(ObjectConfig source, String name) {
740        ObjectAttribute attr = source.getObjectAttribute(name);
741        if (attr == null) {
742            attr = new ObjectAttribute();
743        }
744        return attr;
745    }
746
747    /**
748     * Creates a shallow copy of a possibly null list. If the list is null, an
749     * empty list will be returned.
750     *
751     * @param source The source list to copy
752     * @param <T> The type tag of the list
753     * @return A shallow copy of the list, or an empty list if the original is null
754     */
755    private <T> List<T> safeCopy(List<T> source) {
756        List<T> copy = new ArrayList<>();
757        if (source != null) {
758            copy.addAll(source);
759        }
760        return copy;
761    }
762
763    /**
764     * Sets the 'guess renames' flag to true. If true, the BetterDifferencer will attempt
765     * to guess which Link corresponds to the one in the previous snapshot.
766     *
767     * @param guessRenames The flag to set
768     */
769    public void setGuessRenames(boolean guessRenames) {
770        this.guessRenames = guessRenames;
771    }
772
773    /**
774     * @return The value of the 'guess renames' flag
775     */
776    public boolean shouldGuessRenames() {
777        return guessRenames;
778    }
779}