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