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,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";
}
}
}