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