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