001package com.identityworksllc.iiq.common.plugin;
002
003import com.identityworksllc.iiq.common.AbstractBaseUtility;
004import com.identityworksllc.iiq.common.Sameness;
005import com.identityworksllc.iiq.common.Syslogger;
006import com.identityworksllc.iiq.common.ThingAccessUtils;
007import com.identityworksllc.iiq.common.TooManyResultsException;
008import com.identityworksllc.iiq.common.Utilities;
009import com.identityworksllc.iiq.common.logging.LogCapture;
010import com.identityworksllc.iiq.common.logging.SLogger;
011import com.identityworksllc.iiq.common.plugin.annotations.AuthorizeAll;
012import com.identityworksllc.iiq.common.plugin.annotations.AuthorizeAny;
013import com.identityworksllc.iiq.common.plugin.annotations.AuthorizedBy;
014import com.identityworksllc.iiq.common.plugin.annotations.NoReturnValue;
015import com.identityworksllc.iiq.common.plugin.annotations.ResponsesAllowed;
016import com.identityworksllc.iiq.common.plugin.vo.ExpandedDate;
017import com.identityworksllc.iiq.common.plugin.vo.RestObject;
018import org.apache.commons.logging.LogFactory;
019import org.springframework.core.annotation.AnnotationUtils;
020import sailpoint.api.Matchmaker;
021import sailpoint.api.Meter;
022import sailpoint.api.ObjectUtil;
023import sailpoint.api.SailPointContext;
024import sailpoint.api.logging.SyslogThreadLocal;
025import sailpoint.authorization.Authorizer;
026import sailpoint.authorization.UnauthorizedAccessException;
027import sailpoint.object.*;
028import sailpoint.rest.BaseResource;
029import sailpoint.rest.plugin.BasePluginResource;
030import sailpoint.tools.GeneralException;
031import sailpoint.tools.ObjectNotFoundException;
032import sailpoint.tools.Util;
033import sailpoint.tools.xml.AbstractXmlObject;
034
035import javax.faces.FactoryFinder;
036import javax.faces.component.UIViewRoot;
037import javax.faces.context.FacesContext;
038import javax.faces.context.FacesContextFactory;
039import javax.faces.lifecycle.Lifecycle;
040import javax.faces.lifecycle.LifecycleFactory;
041import javax.servlet.ServletContext;
042import javax.servlet.http.HttpServletRequest;
043import javax.servlet.http.HttpServletResponse;
044import javax.ws.rs.NotFoundException;
045import javax.ws.rs.container.ResourceInfo;
046import javax.ws.rs.core.Context;
047import javax.ws.rs.core.Response;
048import javax.ws.rs.core.Response.ResponseBuilder;
049import javax.ws.rs.core.Response.Status;
050import java.lang.reflect.Method;
051import java.text.SimpleDateFormat;
052import java.time.ZoneId;
053import java.time.format.TextStyle;
054import java.util.*;
055
056/**
057 * This class is the common base class for all IIQCommons-compliant plugin REST
058 * resources. It contains numerous enhancements over IIQ's original base plugin
059 * resource class, notably {@link #handle(PluginAction)}.
060 */
061@SuppressWarnings("unused")
062public abstract class BaseCommonPluginResource extends BasePluginResource implements CommonExtendedPluginContext {
063
064        /**
065         * The interface that must be implemented by any plugin action passed
066         * to {@link #handle(Authorizer, PluginAction)}. In most cases, you will
067         * implement this as a lambda expression.
068         */
069        @FunctionalInterface
070        public interface PluginAction {
071                /**
072                 * Executes the action
073                 * @return Any values to return to the client
074                 * @throws Exception if any failure occur
075                 */
076                Object execute() throws Exception;
077        }
078
079        /**
080         * A hack to set the current FacesContext object. The method to set the current
081         * instance is "protected", so it can only be accessed from within a subclass.
082         */
083        private static abstract class InnerFacesContext extends FacesContext {
084                /**
085                 * Sets the current Faces Context within the context of this class,
086                 * which allows us access to a protected superclass static method
087                 * @param facesContext The new FacesContext
088                 */
089                protected static void setFacesContextAsCurrentInstance(FacesContext facesContext) {
090                        FacesContext.setCurrentInstance(facesContext);
091                }
092        }
093
094        /**
095         * Transforms a date response into a known format
096         *
097         * @param response The date response
098         * @return if any failures occur
099         */
100        private static Response transformDate(Date response) {
101                return Response.ok().entity(new ExpandedDate(response)).build();
102        }
103        /**
104         * If true, logs will be captured in handle() and appended to any error messages
105         */
106        private final ThreadLocal<Boolean> captureLogs;
107        /**
108         * The constructed FacesContext if there is one
109         */
110        private final ThreadLocal<FacesContext> constructedContext;
111        /**
112         * If true, logs will be captured in handle() and logged to the usual logger output even if they would not normally be
113         */
114        private final ThreadLocal<Boolean> forwardLogs;
115        /**
116         * An enhanced logger available to all plugins
117         */
118        protected final SLogger log;
119        /**
120         * Log messages captured to return with any errors
121         */
122        private final ThreadLocal<List<String>> logMessages;
123        /**
124         * A plugin authorization checker, if present
125         */
126        private PluginAuthorizationCheck pluginAuthorizationCheck;
127        /**
128         * Information about what resource is about to be invoked
129         */
130        @Context
131        protected ResourceInfo resourceInfo;
132        /**
133         * The {@link HttpServletResponse} associated with the current invocation
134         */
135        @Context
136        protected HttpServletResponse response;
137        /**
138         * The servlet context associated with the current invocation
139         */
140        @Context
141        protected ServletContext servletContext;
142        
143        /**
144         * The plugin resource
145         */
146        public BaseCommonPluginResource() {
147                this.log = new SLogger(LogFactory.getLog(this.getClass()));
148                this.logMessages = new ThreadLocal<>();
149                this.captureLogs = new ThreadLocal<>();
150                this.forwardLogs = new ThreadLocal<>();
151                this.constructedContext = new ThreadLocal<>();
152        }
153
154        /**
155         * The plugin resource
156         * @param parent The parent resource, used to inherit IIQ configuration
157         */
158        public BaseCommonPluginResource(BaseResource parent) {
159                super(parent);
160                this.log = new SLogger(LogFactory.getLog(this.getClass()));
161                this.logMessages = new ThreadLocal<>();
162                this.captureLogs = new ThreadLocal<>();
163                this.forwardLogs = new ThreadLocal<>();
164                this.constructedContext = new ThreadLocal<>();
165        }
166
167        /**
168         * Authorizes the given endpoint class and method according to the custom
169         * {@link AuthorizedBy}, {@link AuthorizeAll}, or {@link AuthorizeAny} annotations
170         * present on it.
171         *
172         * If authorization fails, an {@link UnauthorizedAccessException} will be thrown.
173         * Otherwise the method will return silently.
174         *
175         * If the logged in user is null, authorization silently succeeds.
176         *
177         * @param endpointClass The endpoint class from {@link ResourceInfo}
178         * @param endpointMethod The endpoint method from {@link ResourceInfo}
179         * @throws UnauthorizedAccessException if authorization fails
180         * @throws GeneralException if any other system failures occur during authorization
181         */
182        private void authorize(Class<?> endpointClass, Method endpointMethod) throws UnauthorizedAccessException, GeneralException {
183                Identity me = super.getLoggedInUser();
184                if (me == null) {
185                        return;
186                }
187
188                // NOTE: The difference between get and find is that find searches up the class
189                // hierarchy and get searches only the local level.
190                AuthorizedBy authorizedBy = AnnotationUtils.getAnnotation(endpointMethod, AuthorizedBy.class);
191                AuthorizeAll authorizeAll = null;
192                AuthorizeAny authorizeAny = null;
193
194                if (authorizedBy == null) {
195                        authorizeAll = AnnotationUtils.getAnnotation(endpointMethod, AuthorizeAll.class);
196                        authorizeAny = AnnotationUtils.getAnnotation(endpointMethod, AuthorizeAny.class);
197                }
198
199                if (authorizedBy == null && authorizeAll == null && authorizeAny == null) {
200                        authorizedBy = AnnotationUtils.findAnnotation(endpointClass, AuthorizedBy.class);
201
202                        if (authorizedBy == null) {
203                                authorizeAll = AnnotationUtils.findAnnotation(endpointClass, AuthorizeAll.class);
204                                authorizeAny = AnnotationUtils.findAnnotation(endpointClass, AuthorizeAny.class);
205                        }
206                }
207
208                if (authorizedBy != null) {
209                        if (!isAuthorized(authorizedBy, me)) {
210                                throw new UnauthorizedAccessException("User is not authorized to access this endpoint");
211                        }
212                } else if (authorizeAll != null) {
213                        if (authorizeAny != null) {
214                                throw new GeneralException("BAD CONFIGURATION: Endpoint method " + endpointClass.getName() + "." + endpointMethod.getName() + " is attached to both @AuthorizeAll and @AuthorizeAny annotations");
215                        }
216                        for(AuthorizedBy ab : authorizeAll.value()) {
217                                if (!isAuthorized(ab, me)) {
218                                        throw new UnauthorizedAccessException("User is not authorized to access this endpoint");
219                                }
220                        }
221                } else if (authorizeAny != null) {
222                        boolean match = false;
223                        for(AuthorizedBy ab : authorizeAny.value()) {
224                                if (isAuthorized(ab, me)) {
225                                        match = true;
226                                        break;
227                                }
228                        }
229                        if (!match) {
230                                throw new UnauthorizedAccessException("User is not authorized to access this endpoint");
231                        }
232                }
233        }
234
235        /**
236         * Builds a new FacesContext based on the HTTP request and response. The value
237         * returned by this method will be cleaned up (released) automatically after
238         * running the action in {@link #handle(PluginAction)}.
239         *
240         * See here: <a href="https://myfaces.apache.org/wiki/core/user-guide/jsf-and-myfaces-howtos/backend/access-facescontext-from-servlet.html">Access FacesContext from Servlet</a>
241         *
242         * @return A new FacesContext
243         */
244        private FacesContext buildFacesContext()
245        {
246                FacesContextFactory contextFactory = (FacesContextFactory)FactoryFinder.getFactory(FactoryFinder.FACES_CONTEXT_FACTORY);
247                LifecycleFactory lifecycleFactory = (LifecycleFactory)FactoryFinder.getFactory(FactoryFinder.LIFECYCLE_FACTORY);
248                Lifecycle lifecycle = lifecycleFactory.getLifecycle(LifecycleFactory.DEFAULT_LIFECYCLE);
249                FacesContext facesContext = contextFactory.getFacesContext(this.servletContext, request, response, lifecycle);
250                InnerFacesContext.setFacesContextAsCurrentInstance(facesContext);
251                UIViewRoot view = facesContext.getApplication().getViewHandler().createView(facesContext, "/login");
252                facesContext.setViewRoot(view);
253                constructedContext.set(facesContext);
254                return facesContext;
255        }
256
257        /**
258         * Checks access to the given "thing" and throws an {@link UnauthorizedAccessException} if access is not granted.
259         *
260         * @param thingAccessConfig The thing access configuration map
261         * @throws GeneralException if the access check fails for reasons unrelated to unauthorized access (e.g. script failure)
262         */
263        protected void checkThingAccess(Map<String, Object> thingAccessConfig) throws GeneralException {
264                checkThingAccess(null, thingAccessConfig);
265        }
266
267
268        /**
269         * Checks access to the given "thing" and throws an {@link UnauthorizedAccessException} if access is not granted
270         *
271         * @param target The target Identity, if any
272         * @param thingAccessConfig The thing access configuration map
273         * @throws GeneralException if the access check fails for reasons unrelated to unauthorized access (e.g. script failure)
274         */
275        protected void checkThingAccess(Identity target, Map<String, Object> thingAccessConfig) throws GeneralException {
276                if (!ThingAccessUtils.checkThingAccess(this, target, thingAccessConfig)) {
277                        throw new UnauthorizedAccessException();
278                }
279        }
280
281        /**
282         * Allows successful responses to be customized by overriding this method. By
283         * default, simply returns the input Response object.
284         * <p>
285         * Customizations should generally begin by invoking {@link Response#fromResponse(Response)}.
286         *
287         * @param actionResult The output of the {@link PluginAction} implementation in handle()
288         * @param restResponse The API output
289         */
290        protected Response customizeResponse(Object actionResult, Response restResponse) {
291                return restResponse;
292        }
293
294        /**
295         * Gets the attributes of either a {@link Configuration} or {@link Custom} object
296         * with the given name, in that order. If no such object exists, an empty Attributes
297         * object will be returned.
298         *
299         * This method should never return null.
300         *
301         * @param name Configuration name
302         * @return The attributes of the configuration
303         * @throws GeneralException if any lookup failures occur
304         */
305        public Attributes<String, Object> getConfiguration(String name) throws GeneralException {
306                Configuration c1 = getContext().getObjectByName(Configuration.class, name);
307                if (c1 != null && c1.getAttributes() != null) {
308                        return c1.getAttributes();
309                }
310                Custom c2 = getContext().getObjectByName(Custom.class, name);
311                if (c2 != null && c2.getAttributes() != null) {
312                        return c2.getAttributes();
313                }
314                return new Attributes<>();
315        }
316
317        /**
318         * Gets the configuration setting from the default plugin Configuration object or else from the plugin settings
319         * @param settingName The setting to retrieve
320         * @return The setting value
321         * @see #getConfigurationName()
322         */
323        @Override
324        public boolean getConfigurationBool(String settingName) {
325                return new ExtendedPluginContextHelper(getPluginName(), getContext(), this, this::getConfigurationName).getConfigurationBool(settingName);
326        }
327
328        /**
329         * Gets the configuration setting from the default plugin Configuration object or else from the plugin settings
330         * @param settingName The setting to retrieve
331         * @return The setting value
332         * @see #getConfigurationName()
333         */
334        @Override
335        public int getConfigurationInt(String settingName) {
336                return new ExtendedPluginContextHelper(getPluginName(), getContext(), this, this::getConfigurationName).getConfigurationInt(settingName);
337        }
338
339        /**
340         * Returns the name of the Configuration object for this plugin. It defaults to
341         * `Plugin Configuration [plugin name]`, but a subclass may want to override it.
342         *
343         * The configuration object named here is used by the following methods:
344         *
345         * * {@link #getConfigurationObject(String)}
346         * * {@link #getConfigurationBool(String)}
347         * * {@link #getConfigurationString(String)}
348         * * {@link #getConfigurationInt(String)}
349         *
350         * @return The name of the plugin configuration
351         */
352        protected String getConfigurationName() {
353                return "Plugin Configuration " + getPluginName();
354        }
355        
356        /**
357         * Gets the given configuration setting as an Object
358         * @param settingName The setting to retrieve as an Object
359         * @return The object
360         * @see #getConfigurationName()
361         */
362        @Override
363        public <T> T getConfigurationObject(String settingName) {
364                return (T)new ExtendedPluginContextHelper(getPluginName(), getContext(), this, this::getConfigurationName).getConfigurationObject(settingName);
365        }
366        
367        /**
368         * Gets the configuration setting from the default plugin Configuration object or else from the plugin settings
369         * @param settingName The setting to retrieve
370         * @return The setting value
371         * @see #getConfigurationName()
372         */
373        @Override
374        public String getConfigurationString(String settingName) {
375                return new ExtendedPluginContextHelper(getPluginName(), getContext(), this, this::getConfigurationName).getConfigurationString(settingName);
376        }
377        
378        /**
379         * Returns a distinct object matching the given filter. If the results are not distinct
380         * (i.e. more than one result is returned), a 400 error will be thrown. If there are no
381         * matching objects, a 404 error will be thrown.
382         *
383         * @param <T> The class to search for
384         * @param cls The class to search for
385         * @param filter A Filter for use with searching
386         * @return The resulting object (unless an error occurs)
387         * @throws GeneralException if an error occurs, or if there are no results, or if there are too many results
388         */
389        protected <T extends SailPointObject> T getDistinctObject(Class<T> cls, Filter filter) throws GeneralException {
390                QueryOptions qo = new QueryOptions();
391                qo.add(filter);
392                qo.setResultLimit(2);
393                List<T> results = getContext().getObjects(cls, qo);
394                if (results == null || results.size() == 0) {
395                        throw new ObjectNotFoundException(cls, filter.toString());
396                } else if (results.size() > 1) {
397                        throw new TooManyResultsException(cls, filter.toString(), results.size());
398                }
399                return results.get(0);
400        }
401
402        /**
403         * Returns a distinct object of the given type matching the given filter. If the results are not distinct (i.e. more than one result is returned), a 400 error will be thrown. If there are no matching objects, a 404 error will be thrown.
404         * @param <T> The class to search for
405         * @param cls The class to search for
406         * @param filter A QueryOptions for use with searching
407         * @return The resulting object (unless an error occurs)
408         * @throws GeneralException if an error occurs, or if there are no results, or if there are too many results
409         */
410        protected <T extends SailPointObject> T getDistinctObject(Class<T> cls, QueryOptions filter) throws GeneralException {
411                List<T> results = getContext().getObjects(cls, filter);
412                if (results == null || results.size() == 0) {
413                        throw new ObjectNotFoundException(cls, filter.toString());
414                } else if (results.size() > 1) {
415                        throw new TooManyResultsException(cls, filter.toString(), results.size());
416                }
417                return results.get(0);
418        }
419        
420        /**
421         * Get the standard exception mapping for output to the REST API caller. By
422         * default this will include information about the exception, the quick key
423         * used to look it up in the syslog query UI, and any log messages if log
424         * capturing is enabled.
425         *
426         * Subclasses may override this to add behavior, but most API clients written
427         * by IDW are expecting this output.
428         *
429         * @param t The exception to map
430         * @return The resulting map
431         */
432        protected Map<String, Object> getExceptionMapping(Throwable t) {
433                Map<String, Object> responseMap = new HashMap<>();
434                responseMap.put("exception", t.getClass().getName());
435                responseMap.put("message", t.getMessage());
436                responseMap.put("quickKey", SyslogThreadLocal.get());
437                responseMap.put("logs", logMessages.get());
438                if (t.getCause() != null) {
439                        responseMap.put("parentException", t.getCause().getClass().getName());
440                        responseMap.put("parentMessage", t.getCause().getMessage());
441                }
442                return responseMap;
443        }
444        
445        /**
446         * Gets the current FacesContext if there is one or builds one if there is not.
447         * If the method needs to build a temporary FacesContext, it will be destroyed
448         * at the end of your `handle()` call to avoid memory leaks.
449         *
450         * @return A working FacesContext
451         */
452        protected FacesContext getFacesContext() {
453                FacesContext fc = FacesContext.getCurrentInstance();
454                if (fc != null) {
455                        return fc;
456                }
457                return buildFacesContext();
458        }
459        
460        /**
461         * A wrapper around {@link SailPointContext#getObject(Class, String)} that
462         * will throw a 404 exception if the search returns no records.
463         *
464         * @param <T> The class to search for
465         * @param cls The class to search for
466         * @param nameOrId The name or ID to search
467         * @return The object
468         * @throws ObjectNotFoundException if no results are found (results in a 404 in handle())
469         * @throws GeneralException if a search failure occurs
470         */
471        protected <T extends SailPointObject> T getObject(Class<T> cls, String nameOrId) throws GeneralException {
472                T object = getContext().getObject(cls, nameOrId);
473                if (object == null) {
474                        throw new ObjectNotFoundException(cls, nameOrId);
475                }
476                return object;
477        }
478        
479        /**
480         * A wrapper around {@link SailPointContext#getObject(Class, String)} that will throw a 404 exception if the search returns no records
481         * @param <T> The class to search for
482         * @param cls The class to search for
483         * @param nameOrId The name or ID to search
484         * @return The object
485         * @throws ObjectNotFoundException if no results are found (results in a 404 in handle())
486         * @throws GeneralException if a search failure occurs
487         */
488        protected <T extends SailPointObject> T getObjectById(Class<T> cls, String nameOrId) throws GeneralException {
489                T object = getContext().getObjectById(cls, nameOrId);
490                if (object == null) {
491                        throw new ObjectNotFoundException(cls, nameOrId);
492                }
493                return object;
494        }
495
496        /**
497         * A wrapper around {@link SailPointContext#getObject(Class, String)} that will throw a 404 exception if the search returns no records
498         * @param <T> The class to search for
499         * @param cls The class to search for
500         * @param nameOrId The name or ID to search
501         * @return The object
502         * @throws ObjectNotFoundException if no results are found (results in a 404 in handle())
503         * @throws GeneralException if a search failure occurs
504         */
505        protected <T extends SailPointObject> T getObjectByName(Class<T> cls, String nameOrId) throws GeneralException {
506                T object = getContext().getObjectByName(cls, nameOrId);
507                if (object == null) {
508                        throw new ObjectNotFoundException(cls, nameOrId);
509                }
510                return object;
511        }
512
513        /**
514         * Retrieves the {@link PluginAuthorizationCheck} previously set by a subclass.
515         * @return The configured PluginAuthorizationCheck, or null if none is set.
516         */
517        public PluginAuthorizationCheck getPluginAuthorizationCheck() {
518                return pluginAuthorizationCheck;
519        }
520
521        /**
522         * Safely retrieves the utility class in question. The utility class should implement a
523         * one-argument constructor taking a SailPointContext.
524         *
525         * @param <T> The utility type
526         * @param cls The class to retrieve
527         * @return An instance of the utility class
528         */
529        protected <T extends AbstractBaseUtility> T getUtility(Class<T> cls) {
530                try {
531                        return cls.getConstructor(SailPointContext.class).newInstance(getContext());
532                } catch(Exception e) {
533                        throw new IllegalArgumentException(cls.getName() + " does not appear to be a utility", e);
534                }
535        }
536
537        /**
538         * This method is responsible for executing the given action after checking the
539         * Authorizers. The action should be specified as a Java lambda expression.
540         *
541         * This method performs the following actions:
542         *
543         * 1) If log forwarding or capturing is enabled, switches it on for the current thread.
544         * 2) Starts a meter for checking performance of your action code.
545         * 3) Checks any Authorizers specified via the 'authorizer' parameter, class configuration, or an annotation.
546         * 4) Executes the provided {@link PluginAction}.
547         * 5) Transforms the return value into a {@link Response}, handling both object output and exceptional states.
548         * 6) Finalizes the Meters and log capturing.
549         *
550         * If any authorizer fails, a 403 Forbidden response will be returned.
551         *
552         * @param authorizer If not null, the given authorizer will run first. A {@link Status#FORBIDDEN} {@link Response} will be returned if the authorizer fails
553         * @param action The action to execute, which should return the output of this API endpoint.
554         *
555         * @return The JSON response
556         */
557        protected final Response handle(Authorizer authorizer, PluginAction action) {
558                final String _meterName = "pluginRest:" + request.getRequestURI();
559                boolean shouldMeter = shouldMeter(request);
560                try {
561                        if (forwardLogs.get() != null && forwardLogs.get()) {
562                                LogCapture.addLoggers(this.getClass().getName());
563                                LogCapture.startInterception(message -> log.warn("{0}  {1} {2} - {3}", message.getDate(), message.getLevel(), message.getSource(), message.getMessage()));
564                        } else if (captureLogs.get() != null && captureLogs.get()) {
565                                LogCapture.addLoggers(this.getClass().getName());
566                                LogCapture.startInterception();
567                        }
568                        if (shouldMeter) {
569                                Meter.enter(_meterName);
570                        }
571                        try {
572                                boolean hasReturnValue = true;
573                                List<Class<?>> allowedReturnTypes = null;
574                                try {
575                                        if (resourceInfo != null) {
576                                                Class<?> endpointClass = this.getClass();
577                                                Method endpointMethod = resourceInfo.getResourceMethod();
578                                                authorize(endpointClass, endpointMethod);
579                                                if (endpointClass.isAnnotationPresent(NoReturnValue.class) || endpointMethod.isAnnotationPresent(NoReturnValue.class)) {
580                                                        hasReturnValue = false;
581                                                }
582                                                List<Class<?>> allowedClasses = new ArrayList<>();
583                                                if (endpointClass.isAnnotationPresent(ResponsesAllowed.class)) {
584                                                        Class[] data = endpointClass.getAnnotation(ResponsesAllowed.class).value();
585                                                        if (data != null) {
586                                                                allowedClasses.addAll(Arrays.asList());
587                                                        }
588                                                }
589                                                if (endpointMethod.isAnnotationPresent(ResponsesAllowed.class)) {
590                                                        Class[] data = endpointMethod.getAnnotation(ResponsesAllowed.class).value();
591                                                        if (data != null) {
592                                                                allowedClasses.addAll(Arrays.asList());
593                                                        }
594                                                }
595
596                                                if (!allowedClasses.isEmpty()) {
597                                                        allowedReturnTypes = allowedClasses;
598                                                }
599                                        }
600                                        if (authorizer != null) {
601                                                // Method-level authorizer
602                                                authorize(authorizer);
603                                        }
604                                        if (pluginAuthorizationCheck != null) {
605                                                // Class-level default authorizer
606                                                pluginAuthorizationCheck.checkAccess();
607                                        }
608                                        if (this instanceof PluginAuthorizationCheck) {
609                                                ((PluginAuthorizationCheck) this).checkAccess();
610                                        }
611                                        if (this instanceof Authorizer) {
612                                                authorize((Authorizer)this);
613                                        }
614                                        Object actionResult = action.execute();
615
616                                        Response restResult = handleResult(actionResult, hasReturnValue, allowedReturnTypes);
617
618                                        return customizeResponse(actionResult, restResult);
619                                } catch(Exception e) {
620                                        // Log so that it makes it into the captured logs, if any exist
621                                        log.handleException(e);
622                                        Syslogger.logEvent(this.getClass(), "Error in REST API: " + e.getClass().getName(), e);
623                                        throw e;
624                                }
625                        } finally {
626                                logMessages.set(LogCapture.stopInterception());
627                                if (constructedContext.get() != null && !constructedContext.get().isReleased()) {
628                                        constructedContext.get().release();
629                                }
630                        }
631                } catch(UnauthorizedAccessException | SecurityException e) {
632                        return handleException(Response.status(Status.FORBIDDEN), e);
633                } catch(ObjectNotFoundException | NotFoundException e) {
634                        return handleException(Response.status(Status.NOT_FOUND), e);
635                } catch(IllegalArgumentException e) {
636                        return handleException(Response.status(Status.BAD_REQUEST), e);
637                } catch(Exception e) {
638                        return handleException(e);
639                } finally {
640                        if (shouldMeter) {
641                                Meter.exit(_meterName);
642                        }
643                        Meter.publishMeters();
644                        // Allow garbage collection
645                        forwardLogs.set(null);
646                        captureLogs.set(null);
647                        logMessages.set(null);
648                        constructedContext.set(null);
649                }
650        }
651
652        /**
653         * A wrapper method to handle plugin inputs. This is identical to invoking
654         * {@link #handle(Authorizer, PluginAction)} with a null Authorizer. See
655         * the documentation for that variant for more detail.
656         *
657         * Your {@link PluginAction} should be a Java lambda containing the actual
658         * business logic of your API endpoint. All method parameters will be available
659         * to it, as well as any class level attributes and effectively-final variables
660         * in the endpoint method.
661         *
662         * Example:
663         *
664         * ```
665         * {@literal @}GET
666         * {@literal @}Path("endpoint/path")
667         * public Response endpointMethod(@QueryParam("param") String parameter) {
668         *     return handle(() -> {
669         *         List<String> output = invokeBusinessLogicHere();
670         *
671         *         // Will be automatically transformed into a JSON response
672         *         return output;
673         *     });
674         * }
675         * ```
676         *
677         * @param action The action to execute after passing authorization checks
678         * @return A valid JAX-RS {@link Response} object depending on the output of the PluginAction
679         */
680        protected final Response handle(PluginAction action) {
681                return handle(null, action);
682        }
683
684        /**
685         * Handles an exception by logging it and returning a Response with exception details
686         * @param t The exception to handle
687         * @return A JAX-RS response appropriate to the exception
688         */
689        protected final Response handleException(ResponseBuilder responseBuilder, Throwable t) {
690                Map<String, Object> responseMap = getExceptionMapping(t);
691                return responseBuilder.entity(responseMap).build();
692        }
693
694        /**
695         * Handles an exception by logging it and returning a Response with exception details
696         * @param t The exception to handle
697         * @return A JAX-RS response appropriate to the exception
698         */
699        protected Response handleException(Throwable t) {
700                return handleException(Response.serverError(), t);
701        }
702
703        /**
704         * Handles the result value by wrapping recognized objects into a Response and returning
705         * that. Response entities are processed via IIQ's {@link sailpoint.rest.jaxrs.JsonMessageBodyWriter},
706         * which passes them through the Flexjson library.
707         *
708         * @param hasReturnValue True unless the method specifies {@literal '@'}{@link NoReturnValue}
709         * @param response The output returned from the body of the handle method
710         * @return The resulting Response object
711         * @throws GeneralException if any failures occur processing the response
712         */
713        private Response handleResult(Object response, boolean hasReturnValue, List<Class<?>> allowedReturnTypes) throws GeneralException {
714                if (hasReturnValue) {
715                        if (response instanceof Response) {
716                                return ((Response) response);
717                        } else if (response instanceof ErrorResponse) {
718                                // Special wrapper allowing methods to return a non-OK, but still non-exceptional response
719                                ErrorResponse<?> errorResponse = (ErrorResponse<?>) response;
720                                Response metaResponse = handleResult(errorResponse.getWrappedObject(), true, allowedReturnTypes);
721
722                                return Response.fromResponse(metaResponse).status(errorResponse.getResponseCode()).build();
723                        } else if (response instanceof Map || response instanceof Collection || response instanceof String || response instanceof Number || response instanceof Enum) {
724                                return Response.ok().entity(response).build();
725                        } else if (response instanceof Date) {
726                                return transformDate((Date) response);
727                        } else if (response instanceof AbstractXmlObject) {
728                                Map<String, Object> responseMap = new HashMap<>();
729                                responseMap.put("xml", ((AbstractXmlObject) response).toXml());
730                                if (response instanceof SailPointObject) {
731                                        SailPointObject spo = ((SailPointObject) response);
732                                        responseMap.put("id", spo.getId());
733                                        responseMap.put("type", ObjectUtil.getTheRealClass(spo).getSimpleName());
734                                        responseMap.put("name", spo.getName());
735                                }
736                                return Response.ok().entity(responseMap).build();
737                        } else if (response instanceof RestObject) {
738                                return Response.ok().entity(response).build();
739                        } else if (response instanceof Exception) {
740                                return handleException((Exception) response);
741                        }
742
743                        if (response != null) {
744                                if (!Util.isEmpty(allowedReturnTypes)) {
745                                        for (Class<?> type : allowedReturnTypes) {
746                                                if (Utilities.isAssignableFrom(type, response.getClass())) {
747                                                        return Response.ok().entity(response).build();
748                                                }
749                                        }
750                                }
751                        }
752
753                        // NOTE: It is plausible that 'response' is null here. This is the only way to
754                        // allow both null and non-null outputs from the same REST API method.
755                        if (isAllowedOutput(response)) {
756                                return Response.ok().entity(response).build();
757                        }
758
759                        if (response == null) {
760                                log.warn("REST API output is null, but null outputs were not explicitly allowed with @NoReturnValue or isAllowedOutput()");
761                        } else {
762                                log.warn("REST API output type is not recognized: " + response.getClass());
763                        }
764                }
765                return Response.ok().build();
766        }
767        
768        /**
769         * A method allowing subclasses to specify whether a particular output object that
770         * is not part of the default set can be serialized and returned. This can be used
771         * when you don't have control of the object to extend RestObject.
772         *
773         * A subclass should extend this method and return true if a particular object is
774         * of an expected and supported type.
775         *
776         * @param response The response object
777         * @return True if the object should be accepted as valid output
778         */
779        protected boolean isAllowedOutput(@SuppressWarnings("unused") Object response) {
780                return false;
781        }
782        
783        /**
784         * Checks the provided annotation to see whether the given Identity is authorized
785         * per that annotation's criteria. Only one criterion may be specified per annotation.
786         * If more than one is specified, the highest criterion on this list will be the one
787         * used:
788         *
789         *  * systemAdmin
790         *  * right
791         *  * rights
792         *  * capability
793         *  * capabilities
794         *  * authorizerClass
795         *  * attribute
796         *  * population
797         *  * authorizerRule
798         *
799         * @param annotation The annotation to check
800         * @param me The (non-null) identity to check
801         * @return True if the user is authorized by the annotation's criteria
802         * @throws GeneralException if any Sailpoint errors occur
803         * @throws NullPointerException if the provided Identity is null
804         */
805        protected final boolean isAuthorized(AuthorizedBy annotation, Identity me) throws GeneralException {
806                Objects.requireNonNull(me, "This method can only be used when someone is logged in");
807                boolean authorized = false;
808                Identity.CapabilityManager capabilityManager = me.getCapabilityManager();
809                List<Capability> caps = capabilityManager.getEffectiveCapabilities();
810                if (annotation.systemAdmin()) {
811                        authorized = Capability.hasSystemAdministrator(caps);
812                } else if (Util.isNotNullOrEmpty(annotation.right())) {
813                        authorized = capabilityManager.hasRight(annotation.right());
814                } else if (annotation.rightsList().length > 0) {
815                        for(String right : annotation.rightsList()) {
816                                if (capabilityManager.hasRight(right)) {
817                                        authorized = true;
818                                        break;
819                                }
820                        }
821                } else if (Util.isNotNullOrEmpty(annotation.capability())) {
822                        authorized = Capability.hasCapability(annotation.capability(), caps);
823                } else if (annotation.capabilitiesList().length > 0) {
824                        for(String capability : annotation.capabilitiesList()) {
825                                if (Capability.hasCapability(capability, caps)) {
826                                        authorized = true;
827                                        break;
828                                }
829                        }
830                } else if (!Authorizer.class.equals(annotation.authorizerClass())) {
831                        try {
832                                Class<? extends Authorizer> authorizerClass = annotation.authorizerClass();
833                                Authorizer authorizer = authorizerClass.getConstructor().newInstance();
834                                authorize(authorizer);
835                                authorized = true;
836                        } catch(GeneralException e) {
837                                /* Authorization failed, ignore this */
838                        } catch(Exception e) {
839                                log.warn("Error during authorizer construction", e);
840                        }
841                } else if (Util.isNotNullOrEmpty(annotation.attribute())) {
842                        String attributeName = annotation.attribute();
843                        Object attributeValue = me.getAttribute(attributeName);
844                        if (Util.isNotNullOrEmpty(annotation.attributeValue())) {
845                                String testValue = annotation.attributeValue();
846                                if (attributeValue instanceof List) {
847                                        @SuppressWarnings("unchecked")
848                                        List<Object> attributeValues = (List<Object>)attributeValue;
849                                        authorized = Util.nullSafeContains(attributeValues, testValue);
850                                }
851                                if (!authorized) {
852                                        if (Sameness.isSame(testValue, attributeValue, false)) {
853                                                authorized = true;
854                                        }
855                                }
856                        } else if (annotation.attributeValueIn().length > 0) {
857                                for(String testValue : annotation.attributeValueIn()) {
858                                        if (Sameness.isSame(testValue, attributeValue, false)) {
859                                                authorized = true;
860                                                break;
861                                        }
862                                }
863                        } else {
864                                throw new IllegalArgumentException("If an attribute is defined in an AuthorizedBy annotation, either an attributeValue or an attributeValueIn clause must also be present");
865                        }
866                } else if (Util.isNotNullOrEmpty(annotation.population())) {
867                        GroupDefinition population = getContext().getObject(GroupDefinition.class, annotation.population());
868                        if (population != null) {
869                                if (population.getFilter() != null) {
870                                        Matchmaker matcher = new Matchmaker(getContext());
871                                        IdentitySelector selector = new IdentitySelector();
872                                        selector.setPopulation(population);
873                                        authorized = matcher.isMatch(selector, me);
874                                } else {
875                                        log.warn("AuthorizedBy annotation specifies non-filter population " + annotation.population());
876                                }
877                        } else {
878                                log.warn("AuthorizedBy annotation specifies non-existent population " + annotation.population());
879                        }
880                } else if (Util.isNotNullOrEmpty(annotation.authorizerRule())) {
881                        Map<String, Object> ruleParams = new HashMap<>();
882                        ruleParams.put("resourceInfo", resourceInfo);
883                        ruleParams.put("identity", me);
884                        ruleParams.put("log", log);
885                        ruleParams.put("userContext", this);
886                        ruleParams.put("annotation", annotation);
887
888                        Rule theRule = getContext().getObject(Rule.class, annotation.authorizerRule());
889
890                        if (theRule != null) {
891                                Object output = getContext().runRule(theRule, ruleParams);
892                                authorized = Util.otob(output);
893                        } else {
894                                log.warn("Authorizer rule " + annotation.authorizerRule() + " not found");
895                        }
896                }
897                return authorized;
898        }
899
900        /**
901         * If true, we should capture logs
902         * @return True if we should capture logs
903         */
904        public Boolean isCaptureLogs() {
905                return captureLogs.get();
906        }
907
908        /**
909         * Should we forward logs to the regular log destination?
910         * @return If true, we should forward logs to the regular log destinations
911         */
912        public Boolean isForwardLogs() {
913                return forwardLogs.get();
914        }
915
916        /**
917         * Sets the log capture flag. If true, logs will be captured and attached to any
918         * error outputs. If the response is not an error, logs will not be returned.
919         *
920         * @param captureLogs The logs
921         */
922        public void setCaptureLogs(boolean captureLogs) {
923                this.captureLogs.set(captureLogs);
924        }
925
926        /**
927         * Sets the forward logs flag. If true, logs from other classes will be intercepted
928         * and forwarded to the Logger associated with this class.
929         *
930         * @param forwardLogs If true, we should forward logs
931         */
932        public void setForwardLogs(boolean forwardLogs) {
933                this.forwardLogs.set(forwardLogs);
934        }
935
936        /**
937         * Sets the plugin authorization checker.
938         *
939         * This method is intended to be used in the constructor of subclasses.
940         *
941         * @param pluginAuthorizationCheck The plugin authorization checker
942         */
943        public final void setPluginAuthorizationCheck(PluginAuthorizationCheck pluginAuthorizationCheck) {
944                this.pluginAuthorizationCheck = pluginAuthorizationCheck;
945        }
946
947        /**
948         * Returns true if we ought to meter API calls to this resource. Subclasses
949         * can override this method to do their own detection.
950         * @param request The inbound servlet request
951         */
952        protected boolean shouldMeter(HttpServletRequest request) {
953                return true;
954        }
955
956        /**
957         * Performs some validation against the input, throwing an IllegalArgumentException if
958         * the validation logic returns false.
959         * @param check The check to execute
960         * @throws IllegalArgumentException if the check fails (returns false) or throws an exception
961         */
962        protected final void validate(PluginValidationCheck check) throws IllegalArgumentException {
963                validate(null, check);
964        }
965
966        /**
967         * Performs some validation against the input, throwing an IllegalArgumentException if
968         * the validation logic returns false. The exception will contain the failure message by
969         * default.
970         *
971         * If the validation check itself throws an exception, the output is the same as if the
972         * check did not pass, except that the exception will be logged.
973         *
974         * @param failureMessage The failure message, or null to use a default
975         * @param check The check to execute
976         * @throws IllegalArgumentException if the check fails (returns false) or throws an exception
977         */
978        protected final void validate(String failureMessage, PluginValidationCheck check) throws IllegalArgumentException {
979                try {
980                        boolean result = check.test();
981                        if (!result) {
982                                if (Util.isNotNullOrEmpty(failureMessage)) {
983                                        throw new IllegalArgumentException(failureMessage);
984                                } else {
985                                        throw new IllegalArgumentException("Failed a validation check");
986                                }
987                        }
988                } catch(Exception e) {
989                        log.handleException(e);
990                        if (Util.isNotNullOrEmpty(failureMessage)) {
991                                throw new IllegalArgumentException(failureMessage, e);
992                        } else {
993                                throw new IllegalArgumentException(e);
994                        }
995                }
996        }
997}