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}