001package com.identityworksllc.iiq.common.access;
002
003import com.identityworksllc.iiq.common.*;
004import org.apache.commons.logging.Log;
005import org.apache.commons.logging.LogFactory;
006import sailpoint.api.DynamicScopeMatchmaker;
007import sailpoint.api.Matchmaker;
008import sailpoint.authorization.Authorizer;
009import sailpoint.authorization.UnauthorizedAccessException;
010import sailpoint.object.*;
011import sailpoint.plugin.PluginContext;
012import sailpoint.rest.plugin.BasePluginResource;
013import sailpoint.server.Environment;
014import sailpoint.tools.GeneralException;
015import sailpoint.tools.Util;
016import sailpoint.web.UserContext;
017
018import java.util.*;
019import java.util.concurrent.ConcurrentHashMap;
020import java.util.function.Supplier;
021
022/**
023 * Static methods for implementing access checks. This is used directly by {@link ThingAccessUtils},
024 * but allows migration to this better interface.
025 */
026public final class AccessCheck {
027    /**
028     * The access check name used for an anonymous input
029     */
030    public static final String ANONYMOUS_THING = "anonymous";
031
032    /**
033     * The container object to hold the cached ThingAccessUtil results
034     */
035    public static final class SecurityResult implements Supplier<Optional<AccessCheckResponse>> {
036        /**
037         * The epoch millisecond timestamp when this object expires, one minute after creation
038         */
039        private final long expiration;
040
041        /**
042         * The actual cached result
043         */
044        private final Object result;
045
046        /**
047         * Store the result with an expiration time
048         * @param result The result to cache
049         */
050        public SecurityResult(AccessCheckResponse result) {
051            this.result = result;
052            this.expiration = System.currentTimeMillis() + (1000L * 60);
053        }
054
055        /**
056         * Returns the cached result
057         * @return The cached result
058         */
059        public Optional<AccessCheckResponse> get() {
060            if (this.result instanceof AccessCheckResponse && !this.isExpired()) {
061                return Optional.of((AccessCheckResponse) this.result);
062            } else {
063                return Optional.empty();
064            }
065        }
066
067        /**
068         * Returns true if the current epoch timestamp is later than the expiration date
069         * @return True if expired
070         */
071        private boolean isExpired() {
072            return System.currentTimeMillis() >= expiration;
073        }
074    }
075
076    /**
077     * The container object to identify the cached ThingAccessUtil inputs.
078     *
079     * NOTE: It is very important that this work properly across plugin
080     * classloader contexts, even if the plugin has its own version of
081     * ThingAccessUtils.
082     */
083    public static final class SecurityCacheToken {
084        /**
085         * The CommonSecurityConfig object associated with the cached result
086         */
087        private final Map<String, Object> commonSecurityConfig;
088
089        /**
090         * The version of the plugin cache to invalidate records whenever
091         * a new plugin is installed. This will prevent wacky class cast
092         * problems.
093         */
094        private final int pluginVersion;
095
096        /**
097         * The name of the source identity
098         */
099        private final String source;
100
101        /**
102         * The optional state map
103         */
104        private final Map<String, Object> state;
105
106        /**
107         * The name of the target identity
108         */
109        private final String target;
110
111        /**
112         * Constructs a new cache entry
113         * @param csc The security config
114         * @param source The source identity name
115         * @param target The target identity name
116         * @param state The state of the security operation
117         */
118        public SecurityCacheToken(CommonSecurityConfig csc, String source, String target, Map<String, Object> state) {
119            this.commonSecurityConfig = csc.toMap();
120            this.target = target;
121            this.source = source;
122            this.state = new HashMap<>();
123
124            if (state != null) {
125                this.state.putAll(state);
126            }
127
128            this.pluginVersion = Environment.getEnvironment().getPluginsCache().getVersion();
129        }
130
131        /**
132         * Constructs a new cache entry based on the input
133         * @param input The input object
134         * @throws GeneralException the errors
135         */
136        public SecurityCacheToken(AccessCheckInput input) throws GeneralException {
137            this(
138                    input.getConfiguration(),
139                    input.getUserContext().getLoggedInUserName(),
140                    (input.getTarget() == null || input.getTarget().getName() == null) ? "null" : input.getTarget().getName(),
141                    input.getState()
142            );
143        }
144
145        @Override
146        public boolean equals(Object o) {
147            if (this == o) return true;
148            if (o == null || getClass() != o.getClass()) return false;
149            SecurityCacheToken that = (SecurityCacheToken) o;
150            return this.pluginVersion == that.pluginVersion && Objects.equals(commonSecurityConfig, that.commonSecurityConfig) && Objects.equals(target, that.target) && Objects.equals(source, that.source) && Objects.equals(state, that.state);
151        }
152
153        @Override
154        public int hashCode() {
155            return Objects.hash(pluginVersion, commonSecurityConfig, target, source, state);
156        }
157    }
158
159    /**
160     * The cache key in CustomGlobal
161     */
162    private static final String CACHE_KEY = "idw.ThingAccessUtils.cache";
163
164    /**
165     * The logger
166     */
167    private static final Log log = LogFactory.getLog(AccessCheck.class);
168
169    /**
170     * Private constructor to prevent instantiation
171     */
172    private AccessCheck() {
173        /* Utility class cannot be instantiated */
174    }
175
176    /**
177     * Creates a native IIQ authorizer that performs a CommonSecurityConfig check
178     * @param config The configuration
179     * @return The authorizer
180     */
181    public static Authorizer createAuthorizer(CommonSecurityConfig config) {
182        return userContext -> {
183            AccessCheckInput input = new AccessCheckInput(userContext, config);
184            AccessCheckResponse response = AccessCheck.accessCheck(input);
185            if (!response.isAllowed()) {
186                log.debug("Access denied with messages: " + response.getMessages());
187                throw new UnauthorizedAccessException("Access denied");
188            }
189        };
190    }
191
192    /**
193     * Returns an allowed response if the logged in user can access the item based on
194     * the common configuration parameters.
195     *
196     * @param input The inputs to the access check
197     * @return True if the user has access to the thing based on the configuration
198     * @throws GeneralException if any check failures occur (this should be interpreted as "no access")
199     */
200    private static AccessCheckResponse accessCheckImpl(final AccessCheckInput input) throws GeneralException {
201        AccessCheckResponse result = new AccessCheckResponse();
202        UserContext pluginContext = input.getUserContext();
203
204        final Identity currentUser = pluginContext.getLoggedInUser();
205        final Identity target = (input.getTarget() != null) ? input.getTarget() : currentUser;
206        final String currentUserName = pluginContext.getLoggedInUserName();
207        final CommonSecurityConfig config = input.getConfiguration();
208        final String thingName = input.getThingName();
209
210        Configuration systemConfig = Configuration.getSystemConfig();
211        boolean beanshellGetsPluginContext = systemConfig.getBoolean("IIQCommon.ThingAccessUtils.beanshellGetsPluginContext", false);
212
213        if (log.isTraceEnabled()) {
214            log.trace("START: Checking access for subject = " + currentUser.getName() + ", target = " + target.getName() + ", thing = " + thingName + ", config = " + config);
215        }
216
217        if (config.isDisabled()) {
218            result.denyMessage("Access denied to " + thingName + " because the configuration is marked disabled");
219        }
220        if (result.isAllowed() && Utilities.isNotEmpty(config.getOneOf())) {
221            boolean anyMatch = false;
222            for(CommonSecurityConfig sub : config.getOneOf()) {
223                AccessCheckInput child = new AccessCheckInput(input, sub);
224                AccessCheckResponse childResponse = accessCheckImpl(child);
225                if (childResponse.isAllowed()) {
226                    anyMatch = true;
227                    break;
228                }
229            }
230            if (!anyMatch) {
231                result.denyMessage("Access denied to " + thingName + " because none of the items in the 'oneOf' list resolved to true");
232            }
233        }
234        if (result.isAllowed() && Utilities.isNotEmpty(config.getAllOf())) {
235            boolean allMatch = true;
236            for(CommonSecurityConfig sub : config.getAllOf()) {
237                AccessCheckInput child = new AccessCheckInput(input, sub);
238                AccessCheckResponse childResponse = accessCheckImpl(child);
239                if (!childResponse.isAllowed()) {
240                    allMatch = false;
241                    break;
242                }
243            }
244            if (!allMatch) {
245                result.denyMessage("Access denied to " + thingName + " because at least one of the items in the 'allOf' list resolved to 'deny'");
246            }
247        }
248        if (result.isAllowed() && Utilities.isNotEmpty(config.getNot())) {
249            boolean anyMatch = false;
250            for(CommonSecurityConfig sub : config.getNot()) {
251                AccessCheckInput child = new AccessCheckInput(input, sub);
252                AccessCheckResponse childResponse = accessCheckImpl(child);
253                if (childResponse.isAllowed()) {
254                    anyMatch = true;
255                    break;
256                }
257            }
258            if (anyMatch) {
259                result.denyMessage("Access denied to " + thingName + " because at least one of the items in the 'not' list resolved to 'allow'");
260            }
261        }
262        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getSettingOffSwitch()) && pluginContext instanceof PluginContext) {
263            boolean isDisabled = ((PluginContext) pluginContext).getSettingBool(config.getSettingOffSwitch());
264            if (isDisabled) {
265                result.denyMessage("Access denied to " + thingName + " because the feature " + config.getSettingOffSwitch() + " is disabled in plugin settings");
266            }
267        }
268        if (result.isAllowed() && config.getAccessCheckScript() != null && Util.isNotNullOrEmpty(config.getAccessCheckScript().getSource())) {
269            Script script = Utilities.getAsScript(config.getAccessCheckScript());
270            Map<String, Object> scriptArguments = new HashMap<>();
271            scriptArguments.put("subject", currentUser);
272            scriptArguments.put("target", target);
273            scriptArguments.put("requester", currentUser);
274            scriptArguments.put("identity", target);
275            scriptArguments.put("identityName", target.getName());
276            scriptArguments.put("manager", target.getManager());
277            scriptArguments.put("context", pluginContext.getContext());
278            scriptArguments.put("log", LogFactory.getLog(pluginContext.getClass()));
279            scriptArguments.put("state", input.getState());
280            if (beanshellGetsPluginContext && pluginContext instanceof BasePluginResource) {
281                scriptArguments.put("pluginContext", pluginContext);
282            } else {
283                scriptArguments.put("pluginContext", null);
284            }
285
286            Object output = pluginContext.getContext().runScript(script, scriptArguments);
287            // If the script returns a non-null value, it will be considered the authoritative
288            // response. No further checks will be done. If the output is null, the access
289            // checks will defer farther down.
290            if (output != null) {
291                boolean userAllowed = Util.otob(output);
292                if (!userAllowed) {
293                    result.denyMessage("Access denied to " + thingName + " because access check script returned false for subject user " + currentUserName);
294                }
295                return result;
296            }
297        }
298        if (result.isAllowed() && config.getAccessCheckRule() != null) {
299            Map<String, Object> scriptArguments = new HashMap<>();
300            scriptArguments.put("subject", currentUser);
301            scriptArguments.put("target", target);
302            scriptArguments.put("requester", currentUser);
303            scriptArguments.put("identity", target);
304            scriptArguments.put("identityName", target.getName());
305            scriptArguments.put("manager", target.getManager());
306            scriptArguments.put("context", pluginContext.getContext());
307            scriptArguments.put("log", LogFactory.getLog(pluginContext.getClass()));
308            scriptArguments.put("state", input.getState());
309            if (beanshellGetsPluginContext) {
310                scriptArguments.put("pluginContext", pluginContext);
311            } else {
312                scriptArguments.put("pluginContext", null);
313            }
314            if (log.isTraceEnabled()) {
315                log.trace("Running access check rule " + config.getAccessCheckRule().getName() + " for subject = " + currentUserName + ", target = " + target.getName());
316            }
317            Object output = pluginContext.getContext().runRule(config.getAccessCheckRule(), scriptArguments);
318            // If the rule returns a non-null value, it will be considered the authoritative
319            // response. No further checks will be done. If the output is null, the access
320            // checks will defer farther down.
321            if (output != null) {
322                boolean userAllowed = Util.otob(output);
323                if (!userAllowed) {
324                    result.denyMessage("Access denied to " + thingName + " because access check rule returned false for subject user " + currentUserName);
325                }
326                return result;
327            }
328        }
329        if (result.isAllowed() && !Util.isEmpty(config.getRequiredRights())) {
330            boolean userAllowed = false;
331            List<String> rights = config.getRequiredRights();
332            Collection<String> userRights = pluginContext.getLoggedInUserRights();
333            if (userRights != null) {
334                for(String right : Util.safeIterable(userRights)) {
335                    if (rights.contains(right)) {
336                        result.addMessage("Subject matched required SPRight: " + right);
337                        userAllowed = true;
338                        break;
339                    }
340                }
341            }
342            if (!userAllowed) {
343                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match any of the required rights " + rights);
344            }
345        }
346        if (result.isAllowed() && !Util.isEmpty(config.getRequiredCapabilities())) {
347            boolean userAllowed = false;
348            List<String> capabilities = config.getRequiredCapabilities();
349            List<Capability> loggedInUserCapabilities = pluginContext.getLoggedInUserCapabilities();
350            for(Capability cap : Util.safeIterable(loggedInUserCapabilities)) {
351                if (capabilities.contains(cap.getName())) {
352                    result.addMessage("Subject matched required capability: " + cap.getName());
353                    userAllowed = true;
354                    break;
355                }
356            }
357            if (!userAllowed) {
358                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match any of these required capabilities " + capabilities);
359            }
360        }
361        if (result.isAllowed() && !Util.isEmpty(config.getExcludedRights())) {
362            boolean userAllowed = true;
363            List<String> rights = config.getRequiredRights();
364            Collection<String> userRights = pluginContext.getLoggedInUserRights();
365            if (userRights != null) {
366                for(String right : Util.safeIterable(userRights)) {
367                    if (rights.contains(right)) {
368                        result.addMessage("Subject matched excluded SPRight: " + right);
369                        userAllowed = false;
370                        break;
371                    }
372                }
373            }
374            if (!userAllowed) {
375                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " matches one of these excluded SPRights: " + rights);
376            }
377        }
378        if (result.isAllowed() && !Util.isEmpty(config.getExcludedCapabilities())) {
379            boolean userAllowed = true;
380            List<String> capabilities = config.getRequiredCapabilities();
381            List<Capability> loggedInUserCapabilities = pluginContext.getLoggedInUserCapabilities();
382            for(Capability cap : Util.safeIterable(loggedInUserCapabilities)) {
383                if (capabilities.contains(cap.getName())) {
384                    result.addMessage("Subject matched excluded Capability: " + cap.getName());
385                    userAllowed = false;
386                    break;
387                }
388            }
389            if (!userAllowed) {
390                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " matches one of these excluded capabilities: " + capabilities);
391            }
392        }
393
394        if (result.isAllowed() && !Util.isEmpty(config.getExcludedWorkgroups())) {
395            List<String> workgroups = config.getExcludedWorkgroups();
396            boolean matchesWorkgroup = matchesAnyWorkgroup(currentUser, workgroups);
397            if (matchesWorkgroup) {
398                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " is a member of an excluded workgroup in " + workgroups);
399            }
400        }
401        if (result.isAllowed() && !Util.isEmpty(config.getRequiredWorkgroups())) {
402            List<String> workgroups = config.getRequiredWorkgroups();
403            boolean userAllowed = matchesAnyWorkgroup(currentUser, workgroups);
404            if (!userAllowed) {
405                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match any of the required workgroups " + workgroups);
406            }
407        }
408        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getAccessCheckFilter())) {
409            String filterString = config.getValidTargetFilter();
410            Filter compiledFilter = Filter.compile(filterString);
411
412            HybridObjectMatcher hom = new HybridObjectMatcher(pluginContext.getContext(), compiledFilter);
413
414            if (!hom.matches(currentUser)) {
415                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match the access check filter");
416            } else {
417                result.addMessage("Subject user matches filter: " + filterString);
418            }
419        }
420        if (result.isAllowed() && config.getAccessCheckSelector() != null) {
421            IdentitySelector selector = config.getAccessCheckSelector();
422            Matchmaker matchmaker = new Matchmaker(pluginContext.getContext());
423            if (!matchmaker.isMatch(selector, currentUser)) {
424                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match the access check selector");
425            } else {
426                result.addMessage("Subject user matches selector: " + selector.toXml());
427            }
428        }
429        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getMirrorRole())) {
430            String role = config.getMirrorRole();
431            Bundle bundle = pluginContext.getContext().getObject(Bundle.class, role);
432            if (bundle.getSelector() == null && !Util.isEmpty(bundle.getProfiles())) {
433                if (log.isDebugEnabled()) {
434                    log.debug("Running mirrorRole access check on an IT role; this may have performance concerns");
435                }
436            }
437            MatchUtilities matchUtilities = new MatchUtilities(pluginContext.getContext());
438            if (!matchUtilities.matches(currentUser, bundle)) {
439                result.denyMessage("Access denied to " + thingName + " because subject user " + currentUserName + " does not match the selector or profile on role " + bundle.getName());
440            } else {
441                result.addMessage("Subject user matches role criteria: " + bundle.getName());
442            }
443        }
444        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getMirrorQuicklinkPopulation())) {
445            String quicklinkPopulation = config.getMirrorQuicklinkPopulation();
446            if (Util.isNotNullOrEmpty(quicklinkPopulation)) {
447                DynamicScopeMatchmaker dynamicScopeMatchmaker = new DynamicScopeMatchmaker(pluginContext.getContext());
448                DynamicScope dynamicScope = pluginContext.getContext().getObject(DynamicScope.class, quicklinkPopulation);
449                boolean matchesDynamicScope = dynamicScopeMatchmaker.isMatch(dynamicScope, currentUser);
450                if (matchesDynamicScope) {
451                    result.addMessage("Subject user matches DynamicScope: " + quicklinkPopulation);
452                    DynamicScope.PopulationRequestAuthority populationRequestAuthority = dynamicScope.getPopulationRequestAuthority();
453                    if (populationRequestAuthority != null && !populationRequestAuthority.isAllowAll()) {
454                        matchesDynamicScope = dynamicScopeMatchmaker.isMember(currentUser, target, populationRequestAuthority);
455                        if (matchesDynamicScope) {
456                            result.addMessage("Target user matches DynamicScope: " + quicklinkPopulation);
457                        }
458                    }
459                }
460                if (!matchesDynamicScope) {
461                    result.denyMessage("Access denied to " + thingName + " because QuickLink population " + quicklinkPopulation + " does not match the subject and target");
462                }
463            }
464        }
465        if (result.isAllowed() && !Util.isEmpty(config.getValidTargetExcludedRights())) {
466            boolean userAllowed = true;
467            List<String> rights = config.getValidTargetExcludedRights();
468            Collection<String> userRights = target.getCapabilityManager().getEffectiveFlattenedRights();
469            if (userRights != null) {
470                for(String right : Util.safeIterable(userRights)) {
471                    if (rights.contains(right)) {
472                        result.addMessage("Target matched excluded right: " + right);
473                        userAllowed = false;
474                        break;
475                    }
476                }
477            }
478            if (!userAllowed) {
479                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " matches one or more of the excluded rights " + rights);
480            }
481        }
482        if (result.isAllowed() && !Util.isEmpty(config.getValidTargetExcludedCapabilities())) {
483            boolean userAllowed = true;
484            List<String> rights = config.getValidTargetExcludedCapabilities();
485            List<Capability> capabilities = target.getCapabilityManager().getEffectiveCapabilities();
486            if (capabilities != null) {
487                for(Capability capability : Util.safeIterable(capabilities)) {
488                    if (rights.contains(capability.getName())) {
489                        result.addMessage("Target matched excluded capability: " + capability.getName());
490                        userAllowed = false;
491                        break;
492                    }
493                }
494            }
495            if (!userAllowed) {
496                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " matches one or more of the excluded capabilities " + rights);
497            }
498        }
499
500        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getInvalidTargetFilter())) {
501            String filterString = config.getValidTargetFilter();
502            Filter compiledFilter = Filter.compile(filterString);
503
504            HybridObjectMatcher hom = new HybridObjectMatcher(pluginContext.getContext(), compiledFilter);
505
506            if (hom.matches(target)) {
507                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " matches the invalid target filter");
508            }
509        }
510        if (result.isAllowed() && !Util.isEmpty(config.getValidTargetWorkgroups())) {
511            List<String> workgroups = config.getValidTargetWorkgroups();
512            boolean userAllowed = matchesAnyWorkgroup(target, workgroups);
513            if (!userAllowed) {
514                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " does not match any of the required workgroups " + workgroups);
515            }
516        }
517        if (result.isAllowed() && !Util.isEmpty(config.getValidTargetCapabilities())) {
518            boolean userAllowed = false;
519            List<String> rights = config.getValidTargetCapabilities();
520            List<Capability> capabilities = target.getCapabilityManager().getEffectiveCapabilities();
521            if (capabilities != null) {
522                for(Capability capability : Util.safeIterable(capabilities)) {
523                    if (rights.contains(capability.getName())) {
524                        result.addMessage("Target matched capability: " + capability.getName());
525                        userAllowed = true;
526                    }
527                }
528            }
529            if (!userAllowed) {
530                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " does not match one or more of the included capabilities " + rights);
531            }
532        }
533        if (result.isAllowed() && config.getValidTargetSelector() != null) {
534            IdentitySelector selector = config.getValidTargetSelector();
535            Matchmaker matchmaker = new Matchmaker(pluginContext.getContext());
536            if (!matchmaker.isMatch(selector, target)) {
537                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " does not match the valid target selector");
538            }
539        }
540        if (result.isAllowed() && Util.isNotNullOrEmpty(config.getValidTargetFilter())) {
541            String filterString = config.getValidTargetFilter();
542            Filter compiledFilter = Filter.compile(filterString);
543
544            HybridObjectMatcher hom = new HybridObjectMatcher(pluginContext.getContext(), compiledFilter);
545
546            if (!hom.matches(target)) {
547                result.denyMessage("Access denied to " + thingName + " because target user " + target.getName() + " does not match the valid target filter");
548            }
549        }
550        if (log.isTraceEnabled()) {
551            String resultString = result.isAllowed() ? "ALLOWED access" : "DENIED access";
552            log.trace("FINISH: " + resultString + " for subject = " + currentUser.getName() + ", target = " + target.getName() + ", thing = " + thingName + ", result = " + result);
553        }
554        return result;
555    }
556
557    /**
558     * Returns an 'allowed' response if the logged in user can access the item based on the
559     * common configuration parameters.
560     *
561     * Results for the same CommonSecurityConfig, source, and target user will be cached for up to one minute
562     * unless the CommonSecurityConfig object has noCache set to true.
563     *
564     * @param input The input containing the configuration for the checkThingAccess utility
565     * @return True if the user has access to the thing based on the configuration
566     */
567    public static AccessCheckResponse accessCheck(AccessCheckInput input) {
568        if (input.getConfiguration() == null) {
569            throw new IllegalArgumentException("An access check must contain a CommonSecurityConfig");
570        }
571
572        if (input.getUserContext() == null) {
573            throw new IllegalArgumentException("An access check must specify a UserContext for accessing the IIQ context and the logged in user");
574        }
575
576        AccessCheckResponse result;
577        try {
578            if (!input.getConfiguration().isNoCache()) {
579                SecurityCacheToken cacheToken = new SecurityCacheToken(input);
580                Optional<AccessCheckResponse> cachedResult = getCachedResult(cacheToken);
581                if (cachedResult.isPresent()) {
582                    return cachedResult.get();
583                }
584                result = accessCheckImpl(input);
585                getCacheMap().put(cacheToken, new SecurityResult(result));
586            } else {
587                result = accessCheckImpl(input);
588            }
589        } catch(Exception e) {
590            result = new AccessCheckResponse();
591            result.denyMessage("Caught an exception evaluating criteria: " + e.getMessage());
592            log.error("Caught an exception evaluating access criteria to " + input.getThingName(), e);
593        }
594        return result;
595    }
596
597    /**
598     * An optional clear-cache method that can be used by plugin code
599     */
600    public static void clearCachedResults() {
601        ConcurrentHashMap<AccessCheck.SecurityCacheToken, AccessCheck.SecurityResult> cacheMap = getCacheMap();
602        cacheMap.clear();
603    }
604
605    /**
606     * Creates the cache map, which should be stored in CustomGlobal. If it does not exist,
607     * we create and store a new one. Since this is just for efficiency, we don't really
608     * care about synchronization.
609     *
610     * A new cache will be created whenever a new plugin is installed, incrementing the
611     * Environment's plugin version.
612     *
613     * @return The cache map
614     */
615    public static ConcurrentHashMap<SecurityCacheToken, SecurityResult> getCacheMap() {
616        String versionedKey = CACHE_KEY + "." + Utilities.getPluginVersion();
617        @SuppressWarnings("unchecked")
618        ConcurrentHashMap<SecurityCacheToken, SecurityResult> cacheMap = (ConcurrentHashMap<SecurityCacheToken, SecurityResult>) CustomGlobal.get(versionedKey);
619        if (cacheMap == null) {
620            cacheMap = new ConcurrentHashMap<>();
621            CustomGlobal.put(versionedKey, cacheMap);
622        }
623        return cacheMap;
624    }
625
626    /**
627     * Gets an optional cached result for the given cache token. An empty
628     * optional will be returned if there is no cached entry for the given token
629     * or if it has expired or if there is classloader weirdness.
630     *
631     * @param securityContext The security context
632     * @return The cached result, if one exists
633     */
634    private static Optional<AccessCheckResponse> getCachedResult(SecurityCacheToken securityContext) {
635        ConcurrentHashMap<SecurityCacheToken, SecurityResult> cacheMap = getCacheMap();
636        Supplier<Optional<AccessCheckResponse>> cachedEntry = cacheMap.get(securityContext);
637        if (cachedEntry == null) {
638            return Optional.empty();
639        } else {
640            return cachedEntry.get();
641        }
642    }
643
644    /**
645     * Returns true if the current user is a member of any of the given workgroups.
646     * Note that this check is NOT recursive and does not check whether a workgroup
647     * is a member of another workgroup.
648     *
649     * @param currentUser The user to check
650     * @param workgroups The workgroups to check
651     * @return true if the user is in the given workgroup
652     */
653    public static boolean matchesAnyWorkgroup(Identity currentUser, List<String> workgroups) {
654        boolean matchesWorkgroup = false;
655        List<Identity> userWorkgroups = currentUser.getWorkgroups();
656        if (userWorkgroups != null) {
657            for(Identity wg : userWorkgroups) {
658                String wgName = wg.getName();
659                if (workgroups.contains(wgName)) {
660                    matchesWorkgroup = true;
661                    break;
662                }
663            }
664        }
665        return matchesWorkgroup;
666    }
667}