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