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
175 lines
7.2 KiB
Java
175 lines
7.2 KiB
Java
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";
|
|
}
|
|
|
|
}
|
|
|
|
}
|