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