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:
@@ -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 + "]";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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***";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
128
method-security/src/main/java/com/ankurm/methodsec/Support.java
Normal file
128
method-security/src/main/java/com/ankurm/methodsec/Support.java
Normal 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) + " ...";
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user