Method Security Proxies and SpEL Expression Evaluation
Method Security Proxies and SpEL Expression Evaluation
CH13 introduced AuthorizationManagerBeforeMethodInterceptor and its role in the proxy chain. This section goes deeper into two specifics: how SpEL expressions are parsed, evaluated, and resolved against method parameters, and how the method security interceptor coexists with TransactionInterceptor inside a single proxy.
PreAuthorizeAuthorizationManager and the Expression Handler
When AuthorizationManagerBeforeMethodInterceptor calls check(), it delegates to PreAuthorizeAuthorizationManager. This manager does one thing: evaluate the SpEL expression from the @PreAuthorize annotation.
The evaluation pipeline has three components:
- ExpressionParser: Parses the annotation’s string value into a SpEL
Expressionobject. Spring Security usesSpelExpressionParserwith a customMethodSecurityExpressionHandler. - EvaluationContext: Provides the variables and functions available to the expression.
MethodSecurityEvaluationContextexposes theAuthenticationobject, method parameters, and security-specific functions. - RootObject: A
MethodSecurityExpressionRootthat provideshasRole(),hasAuthority(),hasPermission(), and other built-in methods.
The sequence for @PreAuthorize("hasRole('ADMIN') and #tenantId == authentication.principal.tenantId"):
// Inside PreAuthorizeAuthorizationManager.check()
ExpressionParser parser = handler.getExpressionParser();
Expression expression = parser.parseExpression(attribute.getAttribute());
EvaluationContext ctx = handler.createEvaluationContext(
authentication, invocation);
boolean granted = ExpressionUtils.evaluateAsBoolean(expression, ctx);
The EvaluationContext is where the expression resolves its variables. MethodSecurityEvaluationContext extends StandardEvaluationContext and adds two critical features: it registers the Authentication as a property on the root object, and it lazily resolves method parameter names.
Parameter Name Resolution: The #paramName Mechanism
When a SpEL expression references #tenantId, the evaluation context must map that name to the correct method argument. This mapping is not automatic from the bytecode alone.
MethodSecurityEvaluationContext uses ParameterNameDiscoverer to resolve parameter names. The default discoverer is DefaultParameterNameDiscoverer, which chains two strategies:
- StandardReflectionParameterNameDiscoverer: Uses
java.lang.reflect.Parameter.getName(). This only returns meaningful names if the code was compiled with the-parametersflag. Without it, parameter names arearg0,arg1, etc. - LocalVariableTableParameterNameDiscoverer: Reads the
LocalVariableTableattribute from the class file’s debug info. This works if the code was compiled with debug info (the default in most build tools), but it was deprecated in Spring Framework 6.1 and is no longer reliable with records and sealed classes.
Here is the failure:
// BROKEN: parameter name not available at runtime
@PreAuthorize("#tenantId == authentication.principal.tenantId")
@Transactional
public List<Order> getOrders(TenantId tenantId) {
return orderRepository.findByTenantId(tenantId);
}
If compiled without -parameters, StandardReflectionParameterNameDiscoverer returns arg0 for the first parameter. The SpEL expression references #tenantId, which does not exist in the evaluation context. The result depends on Spring Security version:
- In some versions,
#tenantIdresolves tonull, andnull == authentication.principal.tenantIdevaluates tofalse. Access is silently denied. - In other versions, a
SpelEvaluationExceptionis thrown with the message “EL1008E: Property or field ‘tenantId’ cannot be found.”
Both outcomes are wrong. The first denies legitimate access. The second crashes the request. Neither tells you the root cause is a missing compiler flag.
The fix:
<!-- CORRECT: retain parameter names in compiled classes -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
</configuration>
</plugin>
Or with Gradle:
// CORRECT: Gradle equivalent
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.add('-parameters')
}
With -parameters enabled, Parameter.getName() returns tenantId, the SpEL expression resolves correctly, and the authorization check works as intended.
Spring Boot 3’s parent POM includes -parameters by default. If you use spring-boot-starter-parent, you have it. If you manage your own compiler configuration, you must add it explicitly.
The Expression Root Object: Built-in Security Functions
MethodSecurityExpressionRoot extends SecurityExpressionRoot and provides the functions available in SpEL expressions. These are not standalone functions. They are methods on the root object that the SpEL engine calls.
// What the root object provides
public final boolean hasRole(String role) {
return hasAnyRole(role);
}
public final boolean hasAnyRole(String... roles) {
return hasAnyAuthorityName(defaultRolePrefix, roles);
}
public final boolean hasAuthority(String authority) {
return hasAnyAuthority(authority);
}
public final boolean isAuthenticated() {
return authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
}
The defaultRolePrefix is ROLE_. When you write hasRole('ADMIN'), the root object checks for ROLE_ADMIN in the granted authorities. When you write hasAuthority('ADMIN'), it checks for ADMIN exactly. This distinction causes confusion when migrating from older Spring Security versions where the prefix behavior changed.
For the SaaS backend, a complete authorization expression for tenant-scoped admin operations:
@PreAuthorize("hasRole('TENANT_ADMIN') and #tenantId == authentication.principal.tenantId")
@Transactional
public void updateTenantSettings(TenantId tenantId, TenantSettings settings) {
tenantRepository.updateSettings(tenantId, settings);
auditService.log(tenantId, "SETTINGS_UPDATED");
}
This expression requires that the caller has ROLE_TENANT_ADMIN and that the tenantId parameter matches the authenticated user’s tenant. A global admin with ROLE_ADMIN would be denied by this expression unless you compose it differently:
@PreAuthorize("hasRole('ADMIN') or (hasRole('TENANT_ADMIN') and #tenantId == authentication.principal.tenantId)")
Custom Permission Evaluators
When SpEL expressions become complex, extract the logic into a PermissionEvaluator. The built-in hasPermission() function delegates to a registered PermissionEvaluator bean:
@Component
public class SaasTenantPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication,
Object targetDomainObject,
Object permission) {
if (!(targetDomainObject instanceof TenantId tenantId)) {
return false;
}
SaasUserDetails user = (SaasUserDetails) authentication.getPrincipal();
return switch (permission.toString()) {
case "READ" -> user.getTenantId().equals(tenantId)
|| user.hasRole("ADMIN");
case "WRITE" -> user.getTenantId().equals(tenantId)
&& user.hasRole("TENANT_ADMIN");
default -> false;
};
}
@Override
public boolean hasPermission(Authentication authentication,
Serializable targetId,
String targetType,
Object permission) {
// Not used in this application
return false;
}
}
Register it with the expression handler:
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
SaasTenantPermissionEvaluator evaluator) {
DefaultMethodSecurityExpressionHandler handler =
new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(evaluator);
return handler;
}
}
Now use it in annotations:
@PreAuthorize("hasPermission(#tenantId, 'WRITE')")
@Transactional
public void updateTenantSettings(TenantId tenantId, TenantSettings settings) {
tenantRepository.updateSettings(tenantId, settings);
}
This moves authorization logic out of inline SpEL and into testable Java code. The PermissionEvaluator can be unit tested without Spring context. The annotation remains readable.
Interceptor Chain Ordering: Security Before Transaction
CH13 stated that authorization and transaction interceptors coexist in one proxy. The ordering is determined by the @Order value on each advisor:
AuthorizationManagerBeforeMethodInterceptorhas orderAuthorizationInterceptorsOrder.PRE_AUTHORIZE(100)TransactionInterceptorhas orderOrdered.LOWEST_PRECEDENCE(Integer.MAX_VALUE)
Lower order values execute first in the before phase. The result:
method call on proxy
-> AuthorizationManagerBeforeMethodInterceptor (order 100)
-> [authorization check: PASS or AccessDeniedException]
-> TransactionInterceptor (order MAX_VALUE)
-> [begin transaction]
-> target method execution
-> [commit/rollback transaction]
-> AuthorizationManagerAfterMethodInterceptor (@PostAuthorize, if present)
This is the correct order. Authorization runs before the transaction opens. A denied request never acquires a database connection. If you somehow reverse this ordering (by setting a custom order on the security interceptor higher than the transaction interceptor), you would open a transaction, acquire a connection, then throw AccessDeniedException, then rollback. Wasted resources. The defaults are correct. Do not change them without understanding the chain from CH9.
To verify the interceptor order at runtime, inject the Advisor[] from the proxy:
@Component
@RequiredArgsConstructor
public class ProxyInspector implements CommandLineRunner {
private final OrderService orderService;
@Override
public void run(String... args) {
if (AopUtils.isAopProxy(orderService)) {
Advised advised = (Advised) orderService;
for (Advisor advisor : advised.getAdvisors()) {
System.out.println(advisor.getClass().getSimpleName()
+ " -> " + advisor.getAdvice().getClass().getSimpleName());
}
}
}
}
This prints the advisor chain in execution order. You will see the authorization interceptor before the transaction interceptor. If you do not, check your @Order annotations and your @EnableMethodSecurity configuration.