1
0

Split into per-article modules and add the method-security module

Moves the existing virtual-thread/context-propagation project into
context-propagation/ and adds method-security/ for the Spring Security 7
method-security article: nine runnable demos, fourteen assertions, and every
transcript the article quotes, regenerated by scripts/run-all.sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSrsDSRKVsY588yFiMJMo9
This commit is contained in:
2026-08-25 02:01:29 +00:00
parent 9f950bffa9
commit 5e9e7f1b12
65 changed files with 4088 additions and 119 deletions

View File

@@ -0,0 +1,51 @@
package com.ankurm.methodsec;
/**
* The one domain object every demo in this module operates on.
*
* <p>{@code owner} is deliberately a plain {@code String} that matches
* {@code authentication.getName()}, because that is what makes expressions like
* {@code returnObject.owner == authentication.name} readable in the SpEL reference
* (<a href="../../../../../../docs/02-spel-reference.md">docs/02-spel-reference.md</a>).
*
* <p>Not a record: {@link Demo7DeniedHandling} needs a CGLIB-proxyable, non-final class for
* {@code @AuthorizeReturnObject}, and records are final. That restriction is itself one of the
* findings -- see
* <a href="../../../../../../docs/06-denied-handling.md">docs/06-denied-handling.md</a>.
*/
public class Account {
private final long id;
private final String owner;
private long balanceMinor;
public Account(long id, String owner, long balanceMinor) {
this.id = id;
this.owner = owner;
this.balanceMinor = balanceMinor;
}
public long getId() {
return this.id;
}
public String getOwner() {
return this.owner;
}
public long getBalanceMinor() {
return this.balanceMinor;
}
public void setBalanceMinor(long balanceMinor) {
this.balanceMinor = balanceMinor;
}
@Override
public String toString() {
return "Account[" + this.id + "," + this.owner + "," + this.balanceMinor + "]";
}
}

View File

@@ -0,0 +1,174 @@
package com.ankurm.methodsec;
import java.util.ArrayList;
import java.util.List;
import jakarta.annotation.security.DenyAll;
import jakarta.annotation.security.PermitAll;
import jakarta.annotation.security.RolesAllowed;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.access.prepost.PreFilter;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Demo 1 -- every method-security annotation, on one service, against a real Spring context.
*
* <p>The point of this demo is to be boring: it establishes what the happy path looks like so
* the later demos can be about the ways it silently does not happen. Chapter:
* <a href="../../../../../../docs/01-how-method-security-runs.md">docs/01-how-method-security-runs.md</a>.
*
* <p>Note {@code securedEnabled} and {@code jsr250Enabled} are {@code false} by default on
* {@link EnableMethodSecurity} -- verified by reading the {@code AnnotationDefault} attributes
* out of {@code spring-security-config-7.1.1.jar}, not from prose. {@code @Secured} and
* {@code @RolesAllowed} are therefore inert unless you switch them on, which is silent failure
* number zero.
*/
public class Demo1AnnotationsInAction {
public static void main(String[] args) {
Support.banner("Demo 1 -- the four pre/post annotations, @Secured and JSR-250, all switched on");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
BankService bank = ctx.getBean(BankService.class);
Support.heading("as alice (ROLE_USER)");
Support.login("alice", "ROLE_USER");
Support.attempt("@PreAuthorize hasRole('ADMIN')", () -> bank.adminOnly());
Support.attempt("@PreAuthorize #owner == authentication.name", () -> bank.accountsOf("alice"));
Support.attempt("@PreAuthorize #owner == authentication.name", () -> bank.accountsOf("bob"));
Support.attempt("@PostAuthorize returnObject.owner == ...name", () -> bank.readAccount(1));
Support.attempt("@PostAuthorize returnObject.owner == ...name", () -> bank.readAccount(2));
Support.attempt("@PostFilter filterObject.owner == ...name", () -> bank.allAccounts());
Support.attempt("@Secured(\"ROLE_ADMIN\")", () -> bank.securedAdminOnly());
Support.attempt("@RolesAllowed(\"ADMIN\")", () -> bank.jsr250AdminOnly());
Support.attempt("@PermitAll", () -> bank.jsr250Open());
Support.attempt("@DenyAll", () -> bank.jsr250Closed());
Support.heading("as root (ROLE_ADMIN, ROLE_USER)");
Support.login("root", "ROLE_ADMIN", "ROLE_USER");
Support.attempt("@PreAuthorize hasRole('ADMIN')", () -> bank.adminOnly());
Support.attempt("@Secured(\"ROLE_ADMIN\")", () -> bank.securedAdminOnly());
Support.attempt("@RolesAllowed(\"ADMIN\")", () -> bank.jsr250AdminOnly());
Support.attempt("@PostFilter filterObject.owner == ...name", () -> bank.allAccounts());
Support.heading("with no Authentication at all (SecurityContextHolder cleared)");
Support.logout();
Support.attempt("@PreAuthorize hasRole('ADMIN')", () -> bank.adminOnly());
Support.attempt("@PermitAll", () -> bank.jsr250Open());
Support.heading("@PreFilter -- filtering the ARGUMENT, as alice");
Support.login("alice", "ROLE_USER");
List<Account> batch = new ArrayList<>(List.of(new Account(1, "alice", 100),
new Account(2, "bob", 200), new Account(3, "alice", 300)));
System.out.println(" caller's list before the call : " + batch);
bank.deposit(batch);
System.out.println(" caller's list after the call : " + batch);
System.out.println();
System.out.println(" @PreFilter did not hand the method a copy. It removed bob's account from");
System.out.println(" the caller's own list, in place, before the method body ever ran. That is");
System.out.println(" why Demo 5's immutable List.of(..) blows up.");
Support.heading("@PreFilter on a method with more than one argument");
List<Account> two = new ArrayList<>(List.of(new Account(4, "alice", 10), new Account(5, "bob", 20)));
Support.attemptVoid("no filterTarget, 2 args", () -> bank.depositAmbiguous(two, 50));
Support.attemptVoid("filterTarget = \"accounts\"", () -> bank.depositDisambiguated(two, 50));
System.out.println();
System.out.println(" This one is loud, not silent -- but it only fires when the method is");
System.out.println(" actually called, so a rarely-exercised path can ship broken.");
}
finally {
Support.logout();
}
}
@Configuration
@EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true)
static class Config {
@Bean
BankService bankService() {
return new BankService();
}
}
/**
* The annotated service the whole module is built around. Every trap demo later reuses
* these expressions so the difference is always the plumbing, never the rule.
*/
public static class BankService {
private final List<Account> ledger = new ArrayList<>(
List.of(new Account(1, "alice", 100), new Account(2, "bob", 200), new Account(3, "alice", 300)));
@PreAuthorize("hasRole('ADMIN')")
public String adminOnly() {
return "the admin console";
}
@PreAuthorize("#owner == authentication.name")
public List<Account> accountsOf(String owner) {
return this.ledger.stream().filter((a) -> a.getOwner().equals(owner)).toList();
}
@PostAuthorize("returnObject.owner == authentication.name")
public Account readAccount(long id) {
return this.ledger.stream().filter((a) -> a.getId() == id).findFirst().orElseThrow();
}
@PostFilter("filterObject.owner == authentication.name")
public List<Account> allAccounts() {
return new ArrayList<>(this.ledger);
}
@PreFilter("filterObject.owner == authentication.name")
public void deposit(List<Account> accounts) {
accounts.forEach((a) -> a.setBalanceMinor(a.getBalanceMinor() + 50));
System.out.println(" method body saw : " + accounts);
}
/**
* Two arguments and no {@code filterTarget}: Spring Security cannot guess which one to
* filter, and throws {@code IllegalStateException} at invocation time -- not at startup.
*/
@PreFilter("filterObject.owner == authentication.name")
public void depositAmbiguous(List<Account> accounts, long amountMinor) {
accounts.forEach((a) -> a.setBalanceMinor(a.getBalanceMinor() + amountMinor));
}
@PreFilter(value = "filterObject.owner == authentication.name", filterTarget = "accounts")
public void depositDisambiguated(List<Account> accounts, long amountMinor) {
accounts.forEach((a) -> a.setBalanceMinor(a.getBalanceMinor() + amountMinor));
System.out.println(" method body saw : " + accounts);
}
@Secured("ROLE_ADMIN")
public String securedAdminOnly() {
return "secured payload";
}
@RolesAllowed("ADMIN")
public String jsr250AdminOnly() {
return "jsr250 payload";
}
@PermitAll
public String jsr250Open() {
return "open payload";
}
@DenyAll
public String jsr250Closed() {
return "unreachable";
}
}
}

View File

@@ -0,0 +1,136 @@
package com.ankurm.methodsec;
import java.lang.reflect.Method;
import org.springframework.aop.framework.AopContext;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Demo 2 -- silent failure #1: self-invocation.
*
* <p>{@code @PreAuthorize} is advice on a proxy. A call that starts inside the target object
* never touches the proxy, so the advice never runs. Nothing logs, nothing throws, and the
* annotation is still visibly there in the source and in reflection -- which is exactly what
* makes it survive code review.
*
* <p>Chapter:
* <a href="../../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>.
*/
public class Demo2SelfInvocation {
public static void main(String[] args) {
Support.banner("Demo 2 -- self-invocation: the annotation is there, the check is not");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
ReportService reports = ctx.getBean(ReportService.class);
Support.login("alice", "ROLE_USER");
Support.heading("alice has ROLE_USER only. adminReport() requires ROLE_ADMIN.");
Support.attempt("reports.adminReport() (via proxy)", () -> reports.adminReport());
Support.attempt("reports.userEntryPoint() (this.adminReport())", () -> reports.userEntryPoint());
Support.heading("Is the annotation actually there? (reflection on the target class)");
try {
Method m = ReportService.class.getDeclaredMethod("adminReport");
System.out.println(" ReportService.adminReport() @PreAuthorize -> "
+ m.getAnnotation(PreAuthorize.class));
System.out.println(" bean is an AOP proxy -> " + AopUtils.isAopProxy(reports));
System.out.println(" proxy class -> " + reports.getClass().getName());
System.out.println(" target class -> "
+ AopUtils.getTargetClass(reports).getName());
}
catch (NoSuchMethodException ex) {
throw new IllegalStateException(ex);
}
System.out.println();
System.out.println(" The annotation is present, the bean IS proxied, and the call was still");
System.out.println(" not checked. The proxy only sees calls that arrive from outside.");
Support.heading("Three ways to make the inner call go through the proxy");
Support.attempt("self-injection (ObjectProvider)", () -> reports.viaSelfInjection());
Support.attempt("AopContext.currentProxy()", () -> reports.viaAopContext());
Support.attempt("call a different bean (collaborator)", () -> ctx.getBean(FacadeService.class).run());
}
finally {
Support.logout();
}
}
@Configuration
@EnableMethodSecurity
// exposeProxy = true is what makes AopContext.currentProxy() work at all. Without it that
// call throws IllegalStateException("Cannot find current proxy: Set 'exposeProxy' to true").
@EnableAspectJAutoProxy(exposeProxy = true)
static class Config {
@Bean
ReportService reportService(ObjectProvider<ReportService> self) {
return new ReportService(self);
}
@Bean
FacadeService facadeService(ReportService reports) {
return new FacadeService(reports);
}
}
public static class ReportService {
/**
* An {@code ObjectProvider} rather than a field of the bean's own type: injecting the
* bean into itself directly is a circular reference the container will refuse in a
* constructor. The provider resolves lazily, at call time, and hands back the proxy.
*/
private final ObjectProvider<ReportService> self;
ReportService(ObjectProvider<ReportService> self) {
this.self = self;
}
@PreAuthorize("hasRole('ADMIN')")
public String adminReport() {
return "TOP SECRET REVENUE NUMBERS";
}
/** The bug. {@code this} is the raw target object, so no advice runs. */
public String userEntryPoint() {
return adminReport();
}
/** Fix 1 -- route the inner call back through the container-managed proxy. */
public String viaSelfInjection() {
return this.self.getObject().adminReport();
}
/** Fix 2 -- ask AOP for the proxy that is currently handling this invocation. */
public String viaAopContext() {
return ((ReportService) AopContext.currentProxy()).adminReport();
}
}
/** Fix 3 -- the boring one. A different bean means a real, external, proxied call. */
public static class FacadeService {
private final ReportService reports;
FacadeService(ReportService reports) {
this.reports = reports;
}
public String run() {
return this.reports.adminReport();
}
}
}

View File

@@ -0,0 +1,199 @@
package com.ankurm.methodsec;
import java.lang.reflect.Method;
import org.springframework.aop.framework.Advised;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Demo 3 -- silent failure #2: methods Spring AOP cannot advise.
*
* <p>A CGLIB proxy is a generated subclass. It can only intercept a method it is allowed to
* override. {@code final}, {@code static} and {@code private} methods cannot be overridden, so
* the annotation on them is decoration. Nothing warns you.
*
* <p>The two surprises here are that <em>package-private</em> methods ARE advised (the
* generated subclass lands in the same package), and that a {@code final} class is the one
* variant that fails loudly at startup instead of silently at runtime.
*
* <p>Chapter:
* <a href="../../../../../../docs/04-non-proxyable-methods.md">docs/04-non-proxyable-methods.md</a>.
*/
public class Demo3NonProxyable {
public static void main(String[] args) {
Support.banner("Demo 3 -- @PreAuthorize on methods the proxy cannot override");
Support.login("alice", "ROLE_USER");
try {
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
Vault vault = ctx.getBean(Vault.class);
Support.heading("alice has ROLE_USER. Every method below says hasRole('ADMIN').");
Support.attempt("public (overridable)", () -> vault.publicAdminOnly());
Support.attempt("public final (NOT overridable)", () -> vault.finalAdminOnly());
Support.attempt("static (NOT overridable)", () -> Vault.staticAdminOnly());
Support.attempt("package-private (overridable, same package)", () -> vault.packagePrivateAdminOnly());
Support.attempt("private, reached via a public wrapper", () -> vault.callsPrivate());
Support.heading("What the proxy actually overrode");
Class<?> proxyClass = vault.getClass();
for (String name : new String[] { "publicAdminOnly", "finalAdminOnly", "packagePrivateAdminOnly" }) {
Method target = find(Vault.class, name);
Method onProxy = find(proxyClass, name);
boolean overridden = onProxy != null && !onProxy.getDeclaringClass().equals(Vault.class);
System.out.printf(" %-26s declared final=%-5s overridden by proxy=%s%n", name,
java.lang.reflect.Modifier.isFinal(target.getModifiers()), overridden);
}
System.out.println(" proxy class -> " + proxyClass.getName());
}
Support.heading("A JDK dynamic proxy only advises methods that are ON the interface");
try (var ctx = new AnnotationConfigApplicationContext(JdkProxyConfig.class)) {
LedgerOperations ops = ctx.getBean(LedgerOperations.class);
System.out.println(" proxy is a JDK proxy -> " + AopUtils.isJdkDynamicProxy(ops));
System.out.println(" proxied interfaces -> "
+ java.util.Arrays.toString(((Advised) ops).getProxiedInterfaces()));
Support.attempt("onTheInterface() (advised)", () -> ops.onTheInterface());
System.out.println(" notOnTheInterface() is public and annotated, but the JDK proxy does not");
System.out.println(" implement it at all -- a caller cannot even reach it without casting to");
System.out.println(" the implementation class, and that cast throws ClassCastException.");
Support.attempt("cast proxy to Ledger impl class", () -> ((Ledger) ops).notOnTheInterface());
}
Support.heading("A final CLASS is the loud one");
try (var ctx = new AnnotationConfigApplicationContext(FinalClassConfig.class)) {
System.out.println(" context started, bean = " + ctx.getBean(SealedVault.class).getClass().getName());
}
catch (RuntimeException ex) {
System.out.println(" startup FAILED -> " + ex.getClass().getSimpleName());
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.println(" root cause -> " + root.getClass().getName());
System.out.println(" message -> " + String.valueOf(root.getMessage()).split("\n")[0]);
}
}
finally {
Support.logout();
}
}
private static Method find(Class<?> type, String name) {
for (Method m : type.getMethods()) {
if (m.getName().equals(name)) {
return m;
}
}
for (Method m : type.getDeclaredMethods()) {
if (m.getName().equals(name)) {
return m;
}
}
return null;
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean
Vault vault() {
return new Vault();
}
}
@Configuration
@EnableMethodSecurity
static class JdkProxyConfig {
@Bean
LedgerOperations ledger() {
return new Ledger();
}
}
@Configuration
@EnableMethodSecurity
static class FinalClassConfig {
@Bean
SealedVault sealedVault() {
return new SealedVault();
}
}
public static class Vault {
@PreAuthorize("hasRole('ADMIN')")
public String publicAdminOnly() {
return "public payload";
}
@PreAuthorize("hasRole('ADMIN')")
public final String finalAdminOnly() {
return "final payload";
}
@PreAuthorize("hasRole('ADMIN')")
public static String staticAdminOnly() {
return "static payload";
}
@PreAuthorize("hasRole('ADMIN')")
String packagePrivateAdminOnly() {
return "package-private payload";
}
@PreAuthorize("hasRole('ADMIN')")
private String privateAdminOnly() {
return "private payload";
}
public String callsPrivate() {
return privateAdminOnly();
}
}
public interface LedgerOperations {
String onTheInterface();
}
public static class Ledger implements LedgerOperations {
@Override
@PreAuthorize("hasRole('ADMIN')")
public String onTheInterface() {
return "interface payload";
}
@PreAuthorize("hasRole('ADMIN')")
public String notOnTheInterface() {
return "impl-only payload";
}
}
/** {@code final} class: CGLIB has nothing to subclass. */
public static final class SealedVault {
@PreAuthorize("hasRole('ADMIN')")
public String adminOnly() {
return "sealed payload";
}
}
}

View File

@@ -0,0 +1,318 @@
package com.ankurm.methodsec;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.authorization.AuthorizationManagerFactory;
import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
import org.springframework.security.core.Authentication;
/**
* Demo 4 -- the SpEL surface of method security, evaluated for real.
*
* <p>Every row this prints is a real annotated method on a real proxied bean, invoked twice
* under two different identities. The reference table in the article is generated from this
* output, so if Spring Security changes one of these, the table is wrong on the next run
* rather than wrong forever.
*
* <p>Chapter:
* <a href="../../../../../../docs/02-spel-reference.md">docs/02-spel-reference.md</a>.
*/
public class Demo4SpelReference {
public static void main(String[] args) {
Support.banner("Demo 4 -- what you can actually write inside @PreAuthorize / @PostAuthorize");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
Spel s = ctx.getBean(Spel.class);
Support.heading("alice: ROLE_USER, ROLE_AUDITOR, plus the authority 'report:read'");
Support.login("alice", "ROLE_USER", "ROLE_AUDITOR", "report:read");
runAll(s);
Support.heading("root: ROLE_ADMIN only (RoleHierarchy says ADMIN > USER > GUEST)");
Support.login("root", "ROLE_ADMIN");
runAll(s);
Support.heading("Role prefix and hierarchy");
Support.login("alice", "ROLE_USER", "ROLE_AUDITOR", "report:read");
Support.attempt("hasRole('USER') -> ROLE_USER", () -> s.hasRoleUser());
Support.attempt("hasAuthority('USER') -> literal 'USER'", () -> s.hasAuthorityUserNoPrefix());
Support.attempt("hasAuthority('ROLE_USER')", () -> s.hasAuthorityRoleUser());
Support.login("root", "ROLE_ADMIN");
Support.attempt("root hasRole('GUEST') via RoleHierarchy", () -> s.hasRoleGuest());
Support.heading("Method arguments, the return value, and bean references");
Support.login("alice", "ROLE_USER", "ROLE_AUDITOR", "report:read");
Support.attempt("#owner == authentication.name (\"alice\")", () -> s.byParameterName("alice"));
Support.attempt("#owner == authentication.name (\"bob\")", () -> s.byParameterName("bob"));
Support.attempt("@P(\"o\") alias, #o == ...name (\"alice\")", () -> s.byParameterAlias("alice"));
Support.attempt("#root.this (the target object)", () -> s.byRootThis());
Support.attempt("#root.args[0] -- no such property", () -> s.byPositionalArg("alice"));
Support.attempt("@policy.canRead(authentication, #id) id=1", () -> s.byBeanReference(1));
Support.attempt("@policy.canRead(authentication, #id) id=9", () -> s.byBeanReference(9));
Support.attempt("hasPermission(#id, 'account', 'read') id=1", () -> s.byPermissionEvaluator(1));
Support.attempt("hasPermission(#id, 'account', 'read') id=9", () -> s.byPermissionEvaluator(9));
Support.attempt("T(java.time.LocalDate) type reference", () -> s.byTypeReference());
Support.attempt("@PostAuthorize returnObject.owner == ...name", () -> s.postAuthorizeReturnObject("alice"));
Support.attempt("@PostAuthorize returnObject.owner == ...name", () -> s.postAuthorizeReturnObject("bob"));
Support.heading("The literal constants on SecurityExpressionRoot");
System.out.println(" permitAll / denyAll exist as BOTH a boolean field and a no-arg method,");
System.out.println(" and read/write/create/delete/admin are String constants meant for");
System.out.println(" hasPermission(..) -- e.g. hasPermission(#id, 'account', read).");
Support.attempt("hasPermission(#id, 'account', read) id=1", () -> s.byPermissionConstant(1));
}
finally {
Support.logout();
}
}
private static void runAll(Spel s) {
Support.attempt("permitAll", () -> s.permitAll());
Support.attempt("denyAll", () -> s.denyAll());
Support.attempt("isAuthenticated()", () -> s.isAuthenticated());
Support.attempt("isAnonymous()", () -> s.isAnonymous());
Support.attempt("isFullyAuthenticated()", () -> s.isFullyAuthenticated());
Support.attempt("isRememberMe()", () -> s.isRememberMe());
Support.attempt("hasRole('ADMIN')", () -> s.hasRoleAdmin());
Support.attempt("hasAnyRole('ADMIN','AUDITOR')", () -> s.hasAnyRole());
Support.attempt("hasAllRoles('USER','AUDITOR')", () -> s.hasAllRoles());
Support.attempt("hasAuthority('report:read')", () -> s.hasAuthorityReportRead());
Support.attempt("hasAnyAuthority('report:read','x')", () -> s.hasAnyAuthority());
Support.attempt("hasAllAuthorities('report:read','x')", () -> s.hasAllAuthorities());
Support.attempt("authentication.name == 'alice'", () -> s.authenticationName());
Support.attempt("principal == 'alice'", () -> s.principalEquals());
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean
Spel spel() {
return new Spel();
}
@Bean
AccountPolicy policy() {
return new AccountPolicy();
}
/**
* Spring Security 7.1 routes {@code hasRole}, {@code hasAuthority},
* {@code authenticated()} and friends through an
* {@link AuthorizationManagerFactory}. Role hierarchy and role prefix are set here,
* NOT on the expression handler -- {@code AbstractSecurityExpressionHandler}'s
* {@code setRoleHierarchy(..)} is deprecated in 7.1 (the compiler says so; that is
* how this demo found out).
*/
@Bean
static AuthorizationManagerFactory<MethodInvocation> authorizationManagerFactory() {
DefaultAuthorizationManagerFactory<MethodInvocation> factory = new DefaultAuthorizationManagerFactory<>();
factory.setRoleHierarchy(RoleHierarchyImpl.withDefaultRolePrefix()
.role("ADMIN").implies("USER")
.role("USER").implies("GUEST")
.build());
// The prefix hasRole('X') expands with. Left at the default so the printed table
// is the one readers reproduce; declared to show where the knob moved to.
factory.setRolePrefix("ROLE_");
return factory;
}
/** hasPermission(..) still needs a PermissionEvaluator on the expression handler. */
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(
AuthorizationManagerFactory<MethodInvocation> authorizationManagerFactory) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setAuthorizationManagerFactory(authorizationManagerFactory);
handler.setPermissionEvaluator(new AccountPermissionEvaluator());
return handler;
}
}
/** Every method is one SpEL expression and nothing else. */
public static class Spel {
@PreAuthorize("permitAll")
public String permitAll() {
return "ok";
}
@PreAuthorize("denyAll")
public String denyAll() {
return "ok";
}
@PreAuthorize("isAuthenticated()")
public String isAuthenticated() {
return "ok";
}
@PreAuthorize("isAnonymous()")
public String isAnonymous() {
return "ok";
}
@PreAuthorize("isFullyAuthenticated()")
public String isFullyAuthenticated() {
return "ok";
}
@PreAuthorize("isRememberMe()")
public String isRememberMe() {
return "ok";
}
@PreAuthorize("hasRole('ADMIN')")
public String hasRoleAdmin() {
return "ok";
}
@PreAuthorize("hasRole('USER')")
public String hasRoleUser() {
return "ok";
}
@PreAuthorize("hasRole('GUEST')")
public String hasRoleGuest() {
return "ok";
}
@PreAuthorize("hasAnyRole('ADMIN','AUDITOR')")
public String hasAnyRole() {
return "ok";
}
@PreAuthorize("hasAllRoles('USER','AUDITOR')")
public String hasAllRoles() {
return "ok";
}
@PreAuthorize("hasAuthority('report:read')")
public String hasAuthorityReportRead() {
return "ok";
}
@PreAuthorize("hasAuthority('USER')")
public String hasAuthorityUserNoPrefix() {
return "ok";
}
@PreAuthorize("hasAuthority('ROLE_USER')")
public String hasAuthorityRoleUser() {
return "ok";
}
@PreAuthorize("hasAnyAuthority('report:read','report:write')")
public String hasAnyAuthority() {
return "ok";
}
@PreAuthorize("hasAllAuthorities('report:read','report:write')")
public String hasAllAuthorities() {
return "ok";
}
@PreAuthorize("authentication.name == 'alice'")
public String authenticationName() {
return "ok";
}
@PreAuthorize("principal == 'alice'")
public String principalEquals() {
return "ok";
}
@PreAuthorize("#owner == authentication.name")
public String byParameterName(String owner) {
return "ok";
}
@PreAuthorize("#o == authentication.name")
public String byParameterAlias(@org.springframework.security.core.parameters.P("o") String owner) {
return "ok";
}
@PreAuthorize("#root.this != null")
public String byRootThis() {
return "ok";
}
/**
* There is no positional access to arguments. {@code MethodSecurityExpressionRoot}
* exposes {@code filterObject}, {@code returnObject} and {@code this} and nothing
* else; arguments are bound by NAME into the evaluation context. Kept here because
* the failure is worth seeing.
*/
@PreAuthorize("#root.args[0] == authentication.name")
public String byPositionalArg(String owner) {
return "ok";
}
@PreAuthorize("@policy.canRead(authentication, #id)")
public String byBeanReference(long id) {
return "ok";
}
@PreAuthorize("hasPermission(#id, 'account', 'read')")
public String byPermissionEvaluator(long id) {
return "ok";
}
@PreAuthorize("hasPermission(#id, 'account', read)")
public String byPermissionConstant(long id) {
return "ok";
}
@PreAuthorize("T(java.time.LocalDate).now().year >= 2020")
public String byTypeReference() {
return "ok";
}
@org.springframework.security.access.prepost.PostAuthorize("returnObject.owner == authentication.name")
public Account postAuthorizeReturnObject(String owner) {
return new Account(1, owner, 100);
}
}
/** A plain bean, reachable from SpEL as {@code @policy}. */
public static class AccountPolicy {
private final List<Long> readable = List.of(1L, 2L, 3L);
public boolean canRead(Authentication authentication, long id) {
return this.readable.contains(id) && authentication.isAuthenticated();
}
}
/** Wired into the expression handler; backs {@code hasPermission(..)}. */
static class AccountPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
return false;
}
@Override
public boolean hasPermission(Authentication authentication, java.io.Serializable targetId, String targetType,
Object permission) {
return "account".equals(targetType) && "read".equals(permission) && ((Long) targetId) <= 3L;
}
}
}

View File

@@ -0,0 +1,229 @@
package com.ankurm.methodsec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreFilter;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Demo 5 -- {@code filterObject}, and the container types filtering does and does not accept.
*
* <p>Filtering is the part of method security that mutates your data. {@code @PreFilter}
* removes elements from the caller's own argument, in place; {@code @PostFilter} rebuilds the
* return value. Both need a mutable container, and both quietly do nothing to a type they do
* not recognise -- which is the third silent failure in this module.
*
* <p>Chapter:
* <a href="../../../../../../docs/05-filtering.md">docs/05-filtering.md</a>.
*/
public class Demo5FilteringTraps {
public static void main(String[] args) {
Support.banner("Demo 5 -- filterObject: mutability, container types, and the silent no-op");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
Filters f = ctx.getBean(Filters.class);
Support.login("alice", "ROLE_USER");
Support.heading("@PreFilter needs a MUTABLE argument -- and does not tell you when it is not");
List<Account> mutable = new ArrayList<>(alicesAndBobs());
Support.attemptVoid("new ArrayList<>(..)", () -> f.consume(mutable));
Support.attemptVoid("List.of(..) (immutable)", () -> f.consume(alicesAndBobs()));
Support.attemptVoid("List.copyOf(..) (immutable)", () -> f.consume(List.copyOf(alicesAndBobs())));
Support.attemptVoid("Arrays.asList(..)", () -> f.consume(
java.util.Arrays.asList(alicesAndBobs().toArray(new Account[0]))));
Support.attemptVoid("Collections.unmodifiableList(..)", () -> f.consume(
java.util.Collections.unmodifiableList(new ArrayList<>(alicesAndBobs()))));
Support.attemptVoid("stream().toList() (unmodifiable since 16)", () -> f.consume(
alicesAndBobs().stream().toList()));
Support.attemptVoid("stream().collect(toList()) (ArrayList)", () -> f.consume(
alicesAndBobs().stream().collect(java.util.stream.Collectors.toList())));
Support.attemptVoid("Account[] (arrays rejected outright)", () -> f.consumeArray(
alicesAndBobs().toArray(new Account[0])));
System.out.println();
System.out.println(" Read the second and third lines again: bob's account reached the method");
System.out.println(" body. @PreFilter filters by CLEARING the caller's collection and adding");
System.out.println(" the survivors back. On an immutable list that throws, and");
System.out.println(" DefaultMethodSecurityExpressionHandler.filterCollection catches the");
System.out.println(" UnsupportedOperationException and returns a fresh list instead -- which");
System.out.println(" PreFilterAuthorizationMethodInterceptor.invoke then discards, because it");
System.out.println(" ignores filter()'s return value entirely. No exception, no WARN, no 403.");
Support.heading("The only trace it leaves (same call, logger at TRACE)");
enableTraceOn("org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler");
Support.attemptVoid("List.of(..) with TRACE on", () -> f.consume(alicesAndBobs()));
resetLogging();
Support.heading("What @PostFilter accepts as a return type");
Support.attempt("List<Account>", () -> f.returnsList());
Support.attempt("Account[]", () -> f.returnsArray());
Support.attempt("Stream<Account> (collected here)", () -> f.returnsStream().map(Account::getOwner).toList());
Support.attempt("Map<String, Account>", () -> f.returnsMap());
Support.attempt("Optional<Account> (alice's)", () -> f.returnsOptional("alice"));
Support.attempt("Optional<Account> (bob's)", () -> f.returnsOptional("bob"));
Support.attempt("List.of(..) (immutable return)", () -> f.returnsImmutableList());
Support.attempt("Ledger (a type Spring Security does not know)", () -> f.returnsCustomContainer());
Support.attempt("Page<Account> (real Spring Data PageImpl)", () -> f.returnsPage());
Support.heading("Identity: does @PostFilter hand back the same object?");
List<Account> source = new ArrayList<>(alicesAndBobs());
List<Account> filtered = f.returnsGivenList(source);
System.out.println(" returned == the list the method returned : " + (filtered == source));
System.out.println(" the method's own list, after filtering : " + source);
System.out.println();
System.out.println(" @PostFilter mutates the returned collection in place and hands the same");
System.out.println(" reference back. If that collection is a cached or shared instance, you");
System.out.println(" have just deleted rows from it for every future caller.");
}
finally {
Support.logout();
}
}
/** Route the named logger to stdout at FINEST so the TRACE message is part of the capture. */
private static void enableTraceOn(String loggerName) {
java.util.logging.Logger logger = java.util.logging.Logger.getLogger(loggerName);
logger.setLevel(java.util.logging.Level.FINEST);
logger.setUseParentHandlers(false);
java.util.logging.Handler handler = new java.util.logging.StreamHandler(System.out,
new java.util.logging.Formatter() {
@Override
public String format(java.util.logging.LogRecord record) {
return " TRACE " + record.getMessage() + System.lineSeparator();
}
}) {
@Override
public synchronized void publish(java.util.logging.LogRecord record) {
super.publish(record);
flush();
}
};
handler.setLevel(java.util.logging.Level.FINEST);
logger.addHandler(handler);
}
private static void resetLogging() {
java.util.logging.Logger logger = java.util.logging.Logger
.getLogger("org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler");
for (java.util.logging.Handler h : logger.getHandlers()) {
logger.removeHandler(h);
}
logger.setLevel(null);
logger.setUseParentHandlers(true);
}
private static List<Account> alicesAndBobs() {
return List.of(new Account(1, "alice", 100), new Account(2, "bob", 200), new Account(3, "alice", 300));
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean
Filters filters() {
return new Filters();
}
}
public static class Filters {
@PreFilter("filterObject.owner == authentication.name")
public void consume(List<Account> accounts) {
System.out.println(" method body saw: " + accounts);
}
@PreFilter("filterObject.owner == authentication.name")
public void consumeArray(Account[] accounts) {
System.out.println(" method body saw: " + List.of(accounts));
}
@PostFilter("filterObject.owner == authentication.name")
public List<Account> returnsList() {
return new ArrayList<>(alicesAndBobs());
}
@PostFilter("filterObject.owner == authentication.name")
public List<Account> returnsGivenList(List<Account> accounts) {
return accounts;
}
@PostFilter("filterObject.owner == authentication.name")
public List<Account> returnsImmutableList() {
return alicesAndBobs();
}
@PostFilter("filterObject.owner == authentication.name")
public Account[] returnsArray() {
return alicesAndBobs().toArray(new Account[0]);
}
@PostFilter("filterObject.owner == authentication.name")
public Stream<Account> returnsStream() {
return alicesAndBobs().stream();
}
/** For a Map, {@code filterObject} is a {@code Map.Entry}, not the value. */
@PostFilter("filterObject.value.owner == authentication.name")
public Map<String, Account> returnsMap() {
Map<String, Account> map = new LinkedHashMap<>();
for (Account a : alicesAndBobs()) {
map.put("acct-" + a.getId(), a);
}
return map;
}
@PostFilter("filterObject.owner == authentication.name")
public Optional<Account> returnsOptional(String owner) {
return alicesAndBobs().stream().filter((a) -> a.getOwner().equals(owner)).findFirst();
}
/**
* A real Spring Data {@code Page}. {@code PageImpl} implements {@code Slice} ->
* {@code Streamable} -> {@code Iterable}, but NOT {@code Collection}, so it falls
* through every branch of {@code DefaultMethodSecurityExpressionHandler.filter}.
*/
@PostFilter("filterObject.owner == authentication.name")
public org.springframework.data.domain.Page<Account> returnsPage() {
return new org.springframework.data.domain.PageImpl<>(new ArrayList<>(alicesAndBobs()));
}
/** A container type Spring Security has no visitor for. */
@PostFilter("filterObject.owner == authentication.name")
public Ledger returnsCustomContainer() {
return new Ledger(new ArrayList<>(alicesAndBobs()));
}
}
/** Deliberately not a Collection -- this is the Spring Data {@code Page} shape in miniature. */
public static class Ledger {
private final List<Account> content;
public Ledger(List<Account> content) {
this.content = content;
}
public List<Account> getContent() {
return this.content;
}
@Override
public String toString() {
return "Ledger" + this.content;
}
}
}

View File

@@ -0,0 +1,223 @@
package com.ankurm.methodsec;
import javax.sql.DataSource;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.Advised;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.access.prepost.PreFilter;
import org.springframework.security.authorization.method.AuthorizationInterceptorsOrder;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.Transactional;
/**
* Demo 6 -- where the security advice sits in the interceptor chain, and why that decides
* whether a denied {@code @PostAuthorize} rolls anything back.
*
* <p>Everything printed here is read out of the running container: the real
* {@link AuthorizationInterceptorsOrder} constants, the real advisor list on a real proxy, and
* a real H2 row count after a real denial.
*
* <p>Chapter:
* <a href="../../../../../../docs/07-ordering-and-transactions.md">docs/07-ordering-and-transactions.md</a>.
*/
public class Demo6InterceptorOrder {
public static void main(String[] args) {
Support.banner("Demo 6 -- interceptor order, and @PostAuthorize vs @Transactional");
Support.heading("AuthorizationInterceptorsOrder, read from the enum itself");
System.out.printf(" %-16s %s%n", "CONSTANT", "getOrder()");
for (AuthorizationInterceptorsOrder value : AuthorizationInterceptorsOrder.values()) {
System.out.printf(" %-16s %d%n", value.name(), value.getOrder());
}
System.out.println();
System.out.println(" Lower order = higher precedence = further OUT in the chain. Spring's own");
System.out.println(" @Transactional advisor defaults to Ordered.LOWEST_PRECEDENCE (" + Ordered.LOWEST_PRECEDENCE
+ "),");
System.out.println(" which is larger than every number above -- so security wraps transactions,");
System.out.println(" not the other way round.");
Support.login("alice", "ROLE_USER");
try {
try (var ctx = new AnnotationConfigApplicationContext(ChainConfig.class)) {
Support.heading("The advisor chain on a bean carrying all four annotations");
AllFour bean = ctx.getBean(AllFour.class);
System.out.println(" advisors applied to the proxy: " + ((Advised) bean).getAdvisors().length);
System.out.printf(" %6s %s%n", "ORDER", "ADVISOR BEAN (as registered by @EnableMethodSecurity)");
ctx.getBeansOfType(Advisor.class)
.entrySet()
.stream()
// each interceptor is registered twice, once under a "...Advisor" alias
.filter((e) -> !e.getKey().endsWith("Advisor"))
.sorted(java.util.Comparator
.comparingInt((java.util.Map.Entry<String, Advisor> e) -> (e.getValue() instanceof Ordered o)
? o.getOrder() : Integer.MAX_VALUE))
.forEach((e) -> System.out.printf(" %6d %s%n",
(e.getValue() instanceof Ordered o) ? o.getOrder() : Integer.MAX_VALUE, e.getKey()));
System.out.println(" (authorizeReturnObject sits at SECURE_RESULT = 450 and is registered");
System.out.println(" whether or not anything in the app uses @AuthorizeReturnObject.)");
System.out.println();
System.out.println(" For BEFORE advice, a lower order runs earlier: @PreFilter (100) really");
System.out.println(" does run before @PreAuthorize (200). For AFTER advice the same numbers");
System.out.println(" mean the opposite. @PostAuthorize (500) sits FURTHER OUT than");
System.out.println(" @PostFilter (600), so on the way back out @PostFilter finishes first");
System.out.println(" and @PostAuthorize evaluates returnObject on the ALREADY-FILTERED list.");
System.out.println();
System.out.println(" Both methods below return the same 3 elements and filter one away:");
Support.attempt("@PostAuthorize returnObject.size() == 3", () -> bean.expectsThree(abc()));
Support.attempt("@PostAuthorize returnObject.size() == 2", () -> bean.expectsTwo(abc()));
}
Support.heading("Default order: @PostAuthorize denies AFTER the transaction commits");
try (var ctx = new AnnotationConfigApplicationContext(DefaultOrderConfig.class)) {
runTransfer(ctx);
}
Support.heading("@EnableTransactionManagement(order = FIRST): the write rolls back");
try (var ctx = new AnnotationConfigApplicationContext(TxOuterConfig.class)) {
runTransfer(ctx);
}
}
finally {
Support.logout();
}
}
private static java.util.List<String> abc() {
return new java.util.ArrayList<>(java.util.List.of("a", "b", "c"));
}
private static void runTransfer(AnnotationConfigApplicationContext ctx) {
Ledger ledger = ctx.getBean(Ledger.class);
JdbcTemplate jdbc = ctx.getBean(JdbcTemplate.class);
System.out.println(" rows before : " + jdbc.queryForObject("select count(*) from audit", Integer.class));
Support.attempt("recordAndReturn(\"bob\") @PostAuthorize", () -> ledger.recordAndReturn("bob"));
System.out.println(" rows after the denial : " + jdbc.queryForObject("select count(*) from audit", Integer.class));
}
@Configuration
@EnableMethodSecurity
static class ChainConfig {
@Bean
AllFour allFour() {
return new AllFour();
}
}
public static class AllFour {
@PreFilter("filterObject != null")
@PreAuthorize("isAuthenticated()")
@PostAuthorize("returnObject.size() == 3")
@PostFilter("filterObject != 'c'")
public java.util.List<String> expectsThree(java.util.List<String> in) {
return in;
}
@PreFilter("filterObject != null")
@PreAuthorize("isAuthenticated()")
@PostAuthorize("returnObject.size() == 2")
@PostFilter("filterObject != 'c'")
public java.util.List<String> expectsTwo(java.util.List<String> in) {
return in;
}
}
@Configuration
@EnableMethodSecurity
@EnableTransactionManagement
static class DefaultOrderConfig extends BaseDbConfig {
@Override
String dbName() {
return "audit-default-order";
}
}
@Configuration
@EnableMethodSecurity
// FIRST is Integer.MIN_VALUE, so the transaction advisor becomes the OUTERMOST one and the
// AuthorizationDeniedException thrown by @PostAuthorize propagates through it as a rollback.
@EnableTransactionManagement(order = Integer.MIN_VALUE)
static class TxOuterConfig extends BaseDbConfig {
@Override
String dbName() {
return "audit-tx-outer";
}
}
abstract static class BaseDbConfig {
abstract String dbName();
@Bean
DataSource dataSource() {
// A fixed name rather than generateUniqueName(true): the two contexts in this
// demo are opened and closed in sequence, and a stable URL keeps the captured
// output identical between runs.
return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2)
.setName(dbName())
.build();
}
@Bean
JdbcTemplate jdbcTemplate(DataSource dataSource) {
JdbcTemplate jdbc = new JdbcTemplate(dataSource);
jdbc.execute("create table audit (id identity primary key, owner varchar(64))");
return jdbc;
}
@Bean
PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
Ledger ledger(JdbcTemplate jdbc) {
return new Ledger(jdbc);
}
}
public static class Ledger {
private final JdbcTemplate jdbc;
Ledger(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/**
* Writes an audit row, then returns an account the caller may not be allowed to see.
* The write is the point: it happens inside the transaction, before the security
* check that rejects the return value ever runs.
*/
@Transactional
@PostAuthorize("returnObject.owner == authentication.name")
public Account recordAndReturn(String owner) {
this.jdbc.update("insert into audit(owner) values (?)", owner);
return new Account(1, owner, 100);
}
}
}

View File

@@ -0,0 +1,182 @@
package com.ankurm.methodsec;
import java.util.List;
import java.util.Optional;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.authorization.AuthorizationProxyFactory;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.authorization.method.AuthorizeReturnObject;
import org.springframework.security.authorization.method.HandleAuthorizationDenied;
import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
/**
* Demo 7 -- what happens on denial, and how to change it.
*
* <p>Two mechanisms that are newer than most of the material written about method security:
* {@code @HandleAuthorizationDenied}, which lets a denial return a masked value instead of
* throwing, and {@code @AuthorizeReturnObject}, which pushes the check down onto the returned
* object's own getters.
*
* <p>Chapter:
* <a href="../../../../../../docs/06-denied-handling.md">docs/06-denied-handling.md</a>.
*/
public class Demo7DeniedHandling {
public static void main(String[] args) {
Support.banner("Demo 7 -- @HandleAuthorizationDenied and @AuthorizeReturnObject");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
Support.login("alice", "ROLE_USER");
Support.heading("The exception type you actually catch");
Accounts accounts = ctx.getBean(Accounts.class);
try {
accounts.adminOnly();
}
catch (RuntimeException ex) {
System.out.println(" thrown -> " + ex.getClass().getName());
System.out.println(" is AccessDeniedException -> "
+ (ex instanceof org.springframework.security.access.AccessDeniedException));
System.out.println(" is AuthorizationDeniedException -> " + (ex instanceof AuthorizationDeniedException));
if (ex instanceof AuthorizationDeniedException denied) {
AuthorizationResult result = denied.getAuthorizationResult();
System.out.println(" carries an AuthorizationResult -> " + result.getClass().getSimpleName()
+ " granted=" + result.isGranted());
}
}
System.out.println();
System.out.println(" Handlers written against AccessDeniedException still work -- but the");
System.out.println(" concrete type carries the AuthorizationResult that explains the denial.");
Support.heading("@HandleAuthorizationDenied: return something instead of throwing");
Support.attempt("maskedBalance() (alice, no ROLE_FINANCE)", () -> accounts.maskedBalance());
Support.attempt("maskedList() (alice, no ROLE_FINANCE)", () -> accounts.maskedList());
Support.login("cfo", "ROLE_FINANCE");
Support.attempt("maskedBalance() (cfo, has ROLE_FINANCE)", () -> accounts.maskedBalance());
Support.heading("@AuthorizeReturnObject: the check moves onto the returned object");
Support.login("alice", "ROLE_USER");
Customer proxied = accounts.findCustomer("alice");
System.out.println(" returned instance -> " + proxied.getClass().getName());
Support.attempt("customer.getName() (no authority needed)", () -> proxied.getName());
Support.attempt("customer.getEmail() (needs 'pii:read')", () -> proxied.getEmail());
Support.login("privacy-officer", "pii:read");
Customer allowed = accounts.findCustomer("alice");
Support.attempt("customer.getEmail() (has 'pii:read')", () -> allowed.getEmail());
Support.heading("Same thing without the annotation, via AuthorizationProxyFactory");
Support.login("alice", "ROLE_USER");
// NOTE the package: org.springframework.security.authorization, not
// ...authorization.method, which is where the reference docs place it.
AuthorizationProxyFactory factory = ctx.getBean(AuthorizationProxyFactory.class);
Customer raw = new Customer("alice", "alice@example.com");
Customer wrapped = factory.proxy(raw);
Support.attempt("raw.getEmail() (unproxied object)", () -> raw.getEmail());
Support.attempt("wrapped.getEmail() (proxied object)", () -> wrapped.getEmail());
}
finally {
Support.logout();
}
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean
Accounts accounts() {
return new Accounts();
}
@Bean
MaskingHandler maskingHandler() {
return new MaskingHandler();
}
}
public static class Accounts {
@PreAuthorize("hasRole('ADMIN')")
public String adminOnly() {
return "admin payload";
}
@PreAuthorize("hasRole('FINANCE')")
@HandleAuthorizationDenied(handlerClass = MaskingHandler.class)
public String maskedBalance() {
return "1,204,993.22";
}
@PreAuthorize("hasRole('FINANCE')")
@HandleAuthorizationDenied(handlerClass = MaskingHandler.class)
public List<String> maskedList() {
return List.of("a", "b");
}
@AuthorizeReturnObject
public Customer findCustomer(String name) {
return new Customer(name, name + "@example.com");
}
@AuthorizeReturnObject
public Optional<Customer> findOptionalCustomer(String name) {
return Optional.of(new Customer(name, name + "@example.com"));
}
}
/**
* Note the class is not final and {@code getEmail()} is not final -- the returned object is
* proxied with CGLIB, so the same rules as Demo 3 apply to it. A record here would fail.
*/
public static class Customer {
private final String name;
private final String email;
public Customer(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return this.name;
}
@PreAuthorize("hasAuthority('pii:read')")
public String getEmail() {
return this.email;
}
}
/**
* Returns a masked value rather than throwing. The return type must be assignable to the
* method's declared return type, which is why this one inspects it.
*/
static class MaskingHandler implements MethodAuthorizationDeniedHandler {
@Override
public Object handleDeniedInvocation(MethodInvocation methodInvocation,
AuthorizationResult authorizationResult) {
Class<?> returnType = methodInvocation.getMethod().getReturnType();
if (List.class.isAssignableFrom(returnType)) {
return List.of();
}
return "***masked***";
}
}
}

View File

@@ -0,0 +1,248 @@
package com.ankurm.methodsec;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults;
/**
* Demo 8 -- meta-annotations, expression templates, class-level rules, and the one
* configuration mistake method security refuses to start with.
*
* <p>Chapter:
* <a href="../../../../../../docs/08-meta-annotations.md">docs/08-meta-annotations.md</a>.
*/
public class Demo8MetaAnnotations {
public static void main(String[] args) {
Support.banner("Demo 8 -- meta-annotations, templates, class-level rules, ambiguity");
Support.login("alice", "ROLE_USER");
try {
Support.heading("A plain meta-annotation needs no extra configuration");
try (var ctx = new AnnotationConfigApplicationContext(PlainConfig.class)) {
Plain plain = ctx.getBean(Plain.class);
Support.attempt("@IsAdmin (alice, ROLE_USER)", () -> plain.adminOnly());
Support.login("root", "ROLE_ADMIN");
Support.attempt("@IsAdmin (root, ROLE_ADMIN)", () -> plain.adminOnly());
Support.login("alice", "ROLE_USER");
}
Support.heading("A TEMPLATED meta-annotation, with NO AnnotationTemplateExpressionDefaults bean");
try (var ctx = new AnnotationConfigApplicationContext(NoTemplateConfig.class)) {
Templated t = ctx.getBean(Templated.class);
Support.login("root", "ROLE_ADMIN");
Support.attempt("@HasRole(\"ADMIN\") as root (ROLE_ADMIN)", () -> t.needsAdminRole());
Support.login("alice", "ROLE_USER");
Support.attempt("@HasRole(\"ADMIN\") as alice (ROLE_USER)", () -> t.needsAdminRole());
}
System.out.println();
System.out.println(" '{value}' was substituted anyway. The reference documentation says you");
System.out.println(" must publish an AnnotationTemplateExpressionDefaults bean for templated");
System.out.println(" meta-annotations to work; in 7.1.1 you do not.");
System.out.println(" PreAuthorizeExpressionAttributeRegistry initialises its scanner with");
System.out.println(" SecurityAnnotationScanners.requireUnique(PreAuthorize.class), and that");
System.out.println(" overload constructs a default AnnotationTemplateExpressionDefaults for");
System.out.println(" you. Publishing the bean only changes ignoreUnknown.");
Support.heading("The same annotation WITH the AnnotationTemplateExpressionDefaults bean");
try (var ctx = new AnnotationConfigApplicationContext(TemplateConfig.class)) {
Templated t = ctx.getBean(Templated.class);
Support.login("root", "ROLE_ADMIN");
Support.attempt("@HasRole(\"ADMIN\") as root (ROLE_ADMIN)", () -> t.needsAdminRole());
Support.login("alice", "ROLE_USER");
Support.attempt("@HasRole(\"ADMIN\") as alice (ROLE_USER)", () -> t.needsAdminRole());
Support.attempt("@HasRole(\"USER\") as alice (ROLE_USER)", () -> t.needsRole());
}
Support.heading("Class-level rules, and what a method-level one does to them");
try (var ctx = new AnnotationConfigApplicationContext(ClassLevelConfig.class)) {
ClassLevel c = ctx.getBean(ClassLevel.class);
Support.attempt("inherited from the class (needs ADMIN)", () -> c.inherited());
Support.attempt("method-level overrides it (needs USER)", () -> c.overridden());
Support.attempt("class @PreAuthorize AND method @PostAuthorize", () -> c.andedWithPostAuthorize());
}
Support.heading("Two interfaces, two different @PreAuthorize on the same method");
try (var ctx = new AnnotationConfigApplicationContext(AmbiguousConfig.class)) {
System.out.println(" context started fine.");
ReadsAsUser bean = ctx.getBean(ReadsAsUser.class);
System.out.println(" bean type -> " + bean.getClass().getName());
Support.attempt("read() -- inherits two conflicting rules", () -> bean.read());
System.out.println();
System.out.println(" It is not a startup failure: the context refreshes, the bean is");
System.out.println(" proxied, and the conflict only surfaces when the method is called.");
System.out.println(" The fix is to put @PreAuthorize on the implementation method, which");
System.out.println(" is the nearest declaration and therefore wins outright.");
}
catch (RuntimeException ex) {
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
System.out.println(" startup FAILED -> " + root.getClass().getSimpleName());
System.out.println(" message -> " + String.valueOf(root.getMessage()).split("\n")[0]);
}
}
finally {
Support.logout();
}
}
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface IsAdmin {
}
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('{value}')")
public @interface HasRole {
String value();
}
@Configuration
@EnableMethodSecurity
static class PlainConfig {
@Bean
Plain plain() {
return new Plain();
}
}
@Configuration
@EnableMethodSecurity
static class NoTemplateConfig {
@Bean
Templated templated() {
return new Templated();
}
}
@Configuration
@EnableMethodSecurity
static class TemplateConfig {
@Bean
Templated templated() {
return new Templated();
}
/**
* The documented prerequisite for {@code {value}} templates. Verified NOT to be one:
* the scanner already builds its own default. The bean's only job is
* {@code setIgnoreUnknown(false)}, which turns an unrecognised placeholder into an
* error instead of leaving it in the expression.
*/
@Bean
static AnnotationTemplateExpressionDefaults templateDefaults() {
return new AnnotationTemplateExpressionDefaults();
}
}
@Configuration
@EnableMethodSecurity
static class ClassLevelConfig {
@Bean
ClassLevel classLevel() {
return new ClassLevel();
}
}
@Configuration
@EnableMethodSecurity
static class AmbiguousConfig {
@Bean
Ambiguous ambiguous() {
return new Ambiguous();
}
}
public static class Plain {
@IsAdmin
public String adminOnly() {
return "ok";
}
}
public static class Templated {
@HasRole("USER")
public String needsRole() {
return "ok";
}
@HasRole("ADMIN")
public String needsAdminRole() {
return "ok";
}
}
@PreAuthorize("hasRole('ADMIN')")
public static class ClassLevel {
public String inherited() {
return "ok";
}
@PreAuthorize("hasRole('USER')")
public String overridden() {
return "ok";
}
@PreAuthorize("hasRole('USER')")
@org.springframework.security.access.prepost.PostAuthorize("returnObject == 'never'")
public String andedWithPostAuthorize() {
return "ok";
}
}
public interface ReadsAsUser {
@PreAuthorize("hasRole('USER')")
String read();
}
public interface ReadsAsAdmin {
@PreAuthorize("hasRole('ADMIN')")
String read();
}
public static class Ambiguous implements ReadsAsUser, ReadsAsAdmin {
@Override
public String read() {
return "ok";
}
}
}

View File

@@ -0,0 +1,88 @@
package com.ankurm.methodsec;
import java.lang.reflect.Method;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.parameters.P;
/**
* Demo 9 -- {@code #parameterName} depends on a compiler flag.
*
* <p>{@code @PreAuthorize("#owner == authentication.name")} resolves {@code #owner} by looking
* up the method's parameter names through a {@code ParameterNameDiscoverer}. Parameter names
* survive compilation only when {@code javac} is given {@code -parameters}. Without it the
* name is {@code arg0}, {@code #owner} resolves to nothing, and the comparison is false --
* every call is denied.
*
* <p>{@code scripts/run-all.sh} compiles this module twice and runs this class from both
* builds, so {@code docs/output/demo9-with-parameters.txt} and
* {@code docs/output/demo9-without-parameters.txt} are the same code under the two flags.
*
* <p>Chapter:
* <a href="../../../../../../docs/02-spel-reference.md">docs/02-spel-reference.md</a>.
*/
public class Demo9ParameterNames {
public static void main(String[] args) throws Exception {
Support.banner("Demo 9 -- #parameterName and the -parameters compiler flag");
Method byName = Owned.class.getMethod("byParameterName", String.class);
Method byAlias = Owned.class.getMethod("byParameterAlias", String.class);
System.out.println(" compiled with -parameters : " + byName.getParameters()[0].isNamePresent());
System.out.println(" byParameterName param[0] : " + byName.getParameters()[0].getName());
System.out.println(" byParameterAlias param[0] : " + byAlias.getParameters()[0].getName()
+ " (annotated @P(\"o\"))");
try (var ctx = new AnnotationConfigApplicationContext(Config.class)) {
Owned owned = ctx.getBean(Owned.class);
Support.login("alice", "ROLE_USER");
Support.heading("alice calling with her own name");
Support.attempt("#owner == authentication.name", () -> owned.byParameterName("alice"));
Support.attempt("#o == authentication.name (@P(\"o\"))", () -> owned.byParameterAlias("alice"));
Support.heading("alice calling with somebody else's name");
Support.attempt("#owner == authentication.name", () -> owned.byParameterName("bob"));
Support.attempt("#o == authentication.name (@P(\"o\"))", () -> owned.byParameterAlias("bob"));
}
finally {
Support.logout();
}
System.out.println();
System.out.println(" Without -parameters the first expression denies BOTH calls -- it fails");
System.out.println(" closed, which is the good direction, but it fails silently in the sense");
System.out.println(" that nothing tells you the rule is not the rule you wrote. @P(\"o\") does");
System.out.println(" not depend on the flag, because the name is in the class file either way.");
}
@Configuration
@EnableMethodSecurity
static class Config {
@Bean
Owned owned() {
return new Owned();
}
}
public static class Owned {
@PreAuthorize("#owner == authentication.name")
public String byParameterName(String owner) {
return "ok";
}
@PreAuthorize("#o == authentication.name")
public String byParameterAlias(@P("o") String owner) {
return "ok";
}
}
}

View File

@@ -0,0 +1,128 @@
package com.ankurm.methodsec;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Shared plumbing for every demo in this module: log in as somebody, run something, and
* report what happened in one line.
*
* <p>Nothing here is Spring Security API worth learning -- it exists so the demos can be read
* as a list of claims rather than a list of try/catch blocks. See
* <a href="../../../../../../docs/01-how-method-security-runs.md">docs/01-how-method-security-runs.md</a>
* for what actually happens between {@code run(..)} and the annotated method.
*/
public final class Support {
static {
tidyLogging();
}
private Support() {
}
/**
* Strip timestamps and source-method lines out of JUL output so captured files diff
* cleanly between runs, and silence the embedded-database chatter. Spring's own WARNING
* about un-proxyable final methods is deliberately kept -- it is evidence, not noise.
*/
private static void tidyLogging() {
java.util.logging.Logger root = java.util.logging.Logger.getLogger("");
for (java.util.logging.Handler handler : root.getHandlers()) {
handler.setFormatter(new java.util.logging.Formatter() {
@Override
public String format(java.util.logging.LogRecord record) {
return record.getLevel() + " " + shortName(record.getLoggerName()) + ": "
+ formatMessage(record) + System.lineSeparator();
}
});
}
java.util.logging.Logger.getLogger("org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseFactory")
.setLevel(java.util.logging.Level.WARNING);
}
private static String shortName(String loggerName) {
if (loggerName == null) {
return "?";
}
int dot = loggerName.lastIndexOf('.');
return (dot < 0) ? loggerName : loggerName.substring(dot + 1);
}
/** Put an authenticated user with the given authorities into the {@code SecurityContextHolder}. */
public static void login(String name, String... authorities) {
Authentication auth = UsernamePasswordAuthenticationToken.authenticated(name, "n/a",
AuthorityUtils.createAuthorityList(authorities));
SecurityContextHolder.getContext().setAuthentication(auth);
}
/** Clear the context -- an unauthenticated caller, not an anonymous one. */
public static void logout() {
SecurityContextHolder.clearContext();
}
/**
* Invoke {@code body} and print one line saying whether it returned or was denied.
* Returns the value on success and {@code null} on denial, so callers can keep going.
*/
public static <T> T attempt(String label, Supplier<T> body) {
try {
T value = body.get();
System.out.printf(" %-46s ALLOWED -> %s%n", label, render(value));
return value;
}
catch (RuntimeException ex) {
Throwable root = ex;
while (root.getCause() != null) {
root = root.getCause();
}
String detail = firstLine(ex.getMessage());
if (root != ex) {
detail += " [cause: " + root.getClass().getSimpleName() + ": " + firstLine(root.getMessage()) + "]";
}
System.out.printf(" %-46s DENIED -> %s: %s%n", label, ex.getClass().getSimpleName(), detail);
return null;
}
}
/** Same as {@link #attempt} for a void call. */
public static void attemptVoid(String label, Runnable body) {
attempt(label, () -> {
body.run();
return "(void)";
});
}
public static void heading(String title) {
System.out.println();
System.out.println(title);
System.out.println("-".repeat(title.length()));
}
public static void banner(String title) {
System.out.println("=".repeat(78));
System.out.println(title);
System.out.println("=".repeat(78));
}
private static String render(Object value) {
if (value instanceof Object[] array) {
return List.of(array).toString();
}
return String.valueOf(value);
}
private static String firstLine(String message) {
if (message == null) {
return "(no message)";
}
int newline = message.indexOf('\n');
return (newline < 0) ? message : message.substring(0, newline) + " ...";
}
}