Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Authorization Denied Handlers for Method Security #14712

Merged
merged 2 commits into from
Apr 3, 2024

Conversation

marcusdacoregio
Copy link
Contributor

@marcusdacoregio marcusdacoregio commented Mar 8, 2024

For this feature, we have at least 2 options:

  1. Create a DeniedHandlerMethodInterceptor that intercepts AccessDeniedException thrown from methods annotated with @DeniedHandler:
  • The AuthorizationManagerAfterMethodInterceptor would have to throw a more contextual AccessDeniedException, something like MethodInvocationResultAccessDeniedException so we can have access to the result object that resulted in an exception to pass it to the MethodAccessDeniedHandler.
  • When the @Pre/PostFilter filters non-collection types, it would have to throw an AccessDeniedException for not authorized objects instead of just returning null so DeniedHandlerMethodInterceptor can catch it.
  1. Add the logic inside AuthorizationManagerAfterMethodInterceptor, AuthorizationManagerBeforeMethodInterceptor and DefaultMethodSecurityExpressionHandler:
  • The MethodSecurityEvaluationContext would have to expose the Method in order to access it from DefaultMethodSecurityExpressionHandler
  • Don't need to throw an exception if the @DeniedHandler is present
  • Don't need to create contextual AccessDeniedException, those classes have access to both MethodInvocation and the result.

@marcusdacoregio marcusdacoregio self-assigned this Mar 8, 2024
Copy link
Contributor

@jzheaux jzheaux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together, @marcusdacoregio! I've left some feedback inline.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class has been copied from spring-security-web. We would probably want to deprecate it in spring-security-web and recommend usages to use the class from spring-security-core

@marcusdacoregio
Copy link
Contributor Author

marcusdacoregio commented Mar 13, 2024

@rwinch @jzheaux I've pushed a new commit that uses a HandleAccessDeniedMethodInterceptor to handle denied exceptions thrown from the method security annotations. This won't work with @PreFilter and @PostFilter because they do not throw such exceptions and I don't know if changing its semantics right now would be the best choice.

One concern is that the MethodAccessDeniedHandler is using the Object type for deniedObject, this will make users have to be aware of the possible types and it forces the use of the instanceof operator. To aliviate the problem, I provided AbstractMethodInvocationAccessDeniedHandler with two type-safe methods that can be overriden. Any suggestions how can this be improved?

Another concern is that exceptions can be expensive and it can have a significant impact on application performance (although I haven't done any benchmarking yet). Consider the following class:

class User {

  // ...

  @PreAuthorize("hasRole('ADMIN')")
  @HandleAccessDenied(FirstNameFallbackHandler.class)
  public String getName() {
    return this.name;
  }

  @PreAuthorize("hasRole('ADMIN')")
  @HandleAccessDenied
  public String getAddress() {
    return this.address;
  }

  @PreAuthorize("hasRole('ADMIN')")
  @HandleAccessDenied
  public String getPhoneNumber() {
    return this.phoneNumber;
  }
 
}

If we were to use those getters, 3 access denied exceptions would have to be constructed (if it is not an admin) just to be handled by HandleAccessDeniedMethodInterceptor. For me, it sounds better not to create an exception and handle the access denied at the moment we validate the security expression. What do you think?

@marcusdacoregio
Copy link
Contributor Author

After talking to @rwinch and @jzheaux we decided to support filtering non-collection types only in @PreAuthorize and @PostAuthorize for now, this have some advatanges:

  • Allow us to know exactly if the access denied happened on the annotation expression itself
  • An exception is not instantiated if there is a handler (an exception can still be thrown by the handler)
  • Better use of generics by accepting MethodAccessDeniedHandler<MethodInvocation> for @PreAuthorize and MethodAccessDeniedHandler<MethodInvocationResult> for @PostAuthorize
  • Since Method Security now supports Meta Annotations (Add Meta-annotation Parameter Support #14480), users can create their own annotation and do not depend on a Spring Security on their business code.

Copy link
Member

@rwinch rwinch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @marcusdacoregio This is really taking shape! I've provided some comments below for your consideration

import org.springframework.security.access.AccessDeniedException;
import org.springframework.util.Assert;

public class DecisionAwareAccessDeniedException extends AccessDeniedException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider naming this AuthorizationDeniedException to align with the AuthorizationDecision contained within it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or AuthorizationDecisionException. AuthenticationException implies that authentication failed, e.g. AuthenticationFailedException is a little redundant.


public DecisionAwareAccessDeniedException(String msg, AuthorizationDecision decision) {
super(msg);
Assert.notNull(decision, "decision cannot be null");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably verify decision.isGranted() == false

private Object handleOrThrow(MethodInvocation mi, AuthorizationDecision decision) {
MethodAccessDeniedHandler<MethodInvocation> handler = this.deniedHandlerResolver
.resolvePreInvocation(mi.getMethod());
if (handler == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could change the MethodAccessDeniedHandlerResolver to always return non-null result. The default implementation would be throw new AccessDeniedException("Access Denied");

private void attemptAuthorization(MethodInvocation mi) {
public void setApplicationContext(ApplicationContext applicationContext) {
Assert.notNull(applicationContext, "applicationContext cannot be null");
this.deniedHandlerResolver = new DefaultMethodAccessDeniedHandlerResolver(applicationContext);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be somewhat error prone if we later add a setter method for the deniedHandlerResolver as it might override an explicitly set denied handler. We should probably only set it here if the handler is the default by assigning the default to a static instance and then checking if it is ==).

private static final MethodAccessDeniedHandlerResolver DEFAULT_HANDLER_RESOLVER = ...

private MethodAccessDeniedHandlerResolver deniedHandlerResolver = DEFAULT_HANDLER_RESOLVER;

public void setApplicationContext(ApplicationContext applicationContext) {
     // ...
    if (this.deniedHandlerResolver == DEFAULT_HANDLER_RESOLVER) {
         this.deniedHandlerResolver = new DefaultMethodAccessDeniedHandlerResolver(applicationContext);
    }
}

public MethodAccessDeniedHandler<MethodInvocationResult> resolvePostInvocation(Method method) {
PostAuthorize postAuthorize = AuthorizationAnnotationUtils.findUniqueAnnotation(method, PostAuthorize.class);
if (postAuthorize == null) {
return null;
Copy link
Member

@rwinch rwinch Mar 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In isolation, postAuthorize might return null, but in the context we are using this method, this should never happen because this method is never invoked unless a PostAuthorize was found for the authorization expression to be evaluated. The validation is necessary with this design since this can be invoked in isolation, but it isn't ideal that we know there should be a guarantee to have a non-null PostAuthorize for our use cases, but we must account for it because the APIs are separate.

PreAuthorize is in a very similar situation.

I believe this along with the performance / consistency considerations is a reason to change the design

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.Assert;

final class DefaultMethodAccessDeniedHandlerResolver implements MethodAccessDeniedHandlerResolver {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way that this is currently implemented the PreAuthorize and PostAuthorize annotations are looked up twice. The first is in PreAuthorizeAuthorizationManager and PostAuthorizeAuthorizationManager using PreAuthorizeExpressionAttributeRegistry and PostAuthorizeExpressionAttributeRegistry respectively. Using additional reflection to lookup methods is unnecessary overhead for something that is likely going to be invoked a lot.

The other concern is that the way in which the annotation is resolved for the MethodAccessDeniedHandler and the expression is different. I haven't looked through the code, but it is possible that the resolution is inconsistent. If it isn't already inconsistent, it is possible they will become inconsistent.


import org.aopalliance.intercept.MethodInvocation;

interface MethodAccessDeniedHandlerResolver {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should remove this interface in favor of encapsulating the logic of looking up the user provided MethodAccessDeniedHandler in a framework provided MethodAccessDeniedHandler. The framework provided MethodAccessDeniedHandler would receive as an argument the information from PreAuthorize or the PostAuthorize annotation that was used to create the expression. This means that the annotation only needs to be resolved once (improves performance) and we can guarantee that there is consistency in resolving the expression and the MethodAccessDeniedHandler to delegate to.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think that this is being used anymore and I think that it can be removed.

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorizationDecision;

public interface MethodAccessDeniedHandler<T> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider the name MethodAuthorizationDecisionHandler to better align with the argument that is being passed in.

public interface MethodAccessDeniedHandler<T> {

@Nullable
Object handle(T deniedObject, AuthorizationDecision decision);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider a method name that makes it more clear that it can replace the deniedObject. I wonder if this is more of a post processor of sorts in which the class name and the method name could be updated to include postProcess. We'd want the interface documented that it is allowed to throw an AccessDeniedException (likely though it would prefer a DecisionAwareAccessDeniedException since there is an AuthorizationDecision available)

@marcusdacoregio
Copy link
Contributor Author

marcusdacoregio commented Mar 20, 2024

@rwinch @jzheaux the PR has been updated based on our last discussion.

  • Rob mentioned that it would be nice if we could create a new interface as a replacement for AuthorizationDecision so we can have all the benefits of using an interface instead of a class in the APIs. I create the AuthorizationResult interface and made AuthorizationDecision implement it.
  • MethodAccessDeniedHandler has been renamed to AuthorizationDeniedPostProcessor.
  • PreAuthorizeExpressionAttributeRegistry and PostAuthorizeExpressionAttributeRegistry create an ExpressionAttribute containing the postProcessorClass instead of resolving it in the method interceptors.
  • The PostProcessableAuthorizationDecision implements ResolvableTypeProvider that allow us to verify its generics at runtime, so we can know whether the post processor class can post process the type that we expect. See the logic here. I could use some ideas on how to improve that.

Copy link
Member

@rwinch rwinch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've provided feedback below

import org.springframework.security.authorization.AuthorizationException;
import org.springframework.security.authorization.AuthorizationResult;

public class DefaultPostInvocationAuthorizationDeniedPostProcessor
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of naming it Default, perhaps consider naming it something around how it behaves.

import org.springframework.security.access.AccessDeniedException;
import org.springframework.util.Assert;

public class AuthorizationException extends AccessDeniedException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider naming this AuthorizationDeniedException to align with AccessDeniedException and include the information that it used when Authorization was denied.

import org.springframework.security.authorization.AuthorizationResult;

public class DefaultPostInvocationAuthorizationDeniedPostProcessor
implements AuthorizationDeniedPostProcessor<MethodInvocationResult> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the class were generic, then DefaultPreInvocationAuthorizationDeniedPostProcessor could be removed. Consider having a static method similar to Collections.empty() which allows type safe way to provide an implementation of AuthorizationDeniedPostProcessor

Copy link
Contributor Author

@marcusdacoregio marcusdacoregio Mar 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t think the class can be generic because it is used inside the PreAuthorize and PostAuthorize and annotation’s attributes must be constants. We need an actual implementation of the concrete type to use


import org.aopalliance.intercept.MethodInvocation;

interface MethodAccessDeniedHandlerResolver {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think that this is being used anymore and I think that it can be removed.

@marcusdacoregio marcusdacoregio added in: core An issue in spring-security-core type: enhancement A general enhancement labels Mar 28, 2024
@marcusdacoregio marcusdacoregio marked this pull request as ready for review March 28, 2024 17:36
@marcusdacoregio marcusdacoregio changed the title Allow filtering non-collection types Add Authorization Denied Handlers for Method Security Apr 1, 2024
Copy link
Contributor

@jzheaux jzheaux left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks awesome, @marcusdacoregio! I've left some minor feedback inline.

@marcusdacoregio marcusdacoregio added this to the 6.3.0-RC1 milestone Apr 3, 2024
@marcusdacoregio marcusdacoregio merged commit d85857f into spring-projects:main Apr 3, 2024
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: core An issue in spring-security-core type: enhancement A general enhancement
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants