Add permission-evaluator module: hasPermission() with PermissionEvaluator and @EnableMethodSecurity

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YPgGH6aMbSLANL3E2HHmS6
This commit is contained in:
2026-10-25 16:55:23 +00:00
co-authored by Claude Sonnet 5.5
parent 6b09fe7b82
commit d1b9339951
27 changed files with 857 additions and 1 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# spring-auth-demo # spring-auth-demo
Runnable companion code for five articles on [ankurm.com](https://ankurm.com): Runnable companion code for six articles on [ankurm.com](https://ankurm.com):
| | article | code | | | article | code |
|---|---|---| |---|---|---|
@@ -9,6 +9,7 @@ Runnable companion code for five articles on [ankurm.com](https://ankurm.com):
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) | | 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
| 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) | | 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) |
| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) | | 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
| 6 | [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/) | [`permission-evaluator/`](permission-evaluator) &mdash; Spring Boot 4.1.2 / Security 7.1.2 |
Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
tokens with a hand-written filter. The second verifies tokens minted by somebody else &mdash; tokens with a hand-written filter. The second verifies tokens minted by somebody else &mdash;
+20
View File
@@ -0,0 +1,20 @@
# permission-evaluator
Companion code for [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/).
A `Document` has an owner and a team. `DocumentPermissionEvaluator` decides: admins can do everything, the owner or a team-mate can read, only the owner can write or delete.
Run it: `mvn test` (JDK 25, Spring Boot 4.1.2, Spring Security 7.1.2). Each test writes what it observed to [`output/`](output).
| file | what it shows |
|---|---|
| `00-denyall-log.txt` | log lines from the bean-only setup: Spring Security's default evaluator denies |
| `01-bean-only-wiring.txt` | only a `PermissionEvaluator` bean, no handler bean: everything is denied |
| `02-explicit-handler-matrix.txt` | with a `MethodSecurityExpressionHandler` bean: the allow/deny matrix |
| `03-postauthorize-side-effect.txt` | `@PostAuthorize` runs the method body before denying |
| `04-forged-owner.txt` | `hasPermission(#doc, ...)` trusts the caller's object; the id-only variant does not |
| `05-load-counts.txt` | document loads per call: `get`, `list`, `@PostFilter` by id |
| `06-cache-and-staleness.txt` | a caching evaluator saves loads and serves a stale allow |
| `07-http-status-codes.txt` | 401, 200, 403 over HTTP Basic, including a missing document |
Profiles: `handler` registers the `MethodSecurityExpressionHandler` bean, `cache` adds the caching evaluator.
@@ -0,0 +1,3 @@
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'read' on object with Id 1
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'write' on object with Id 1
o.s.s.a.e.DenyAllPermissionEvaluator : Denying user alice permission 'delete' on object with Id 1
@@ -0,0 +1,14 @@
PermissionEvaluator beans: [documentPermissionEvaluator]
MethodSecurityExpressionHandler beans: []
alice read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
alice write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
alice delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
@@ -0,0 +1,12 @@
alice read doc 1 -> ALLOWED
alice write doc 1 -> ALLOWED
alice delete doc 1 -> ALLOWED
bob read doc 1 -> ALLOWED
bob write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
bob delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol read doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol write doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
carol delete doc 1 -> DENIED (AuthorizationDeniedException: Access Denied)
admin read doc 1 -> ALLOWED
admin write doc 1 -> ALLOWED
admin delete doc 1 -> ALLOWED
@@ -0,0 +1,2 @@
carol getThenCheck(1) -> DENIED (AuthorizationDeniedException: Access Denied)
method body ran 1 time(s) during that call
@@ -0,0 +1,5 @@
document 1 before: Document[id=1, owner=alice, team=team-a, title=Q3 plan]
carol update(forged owner=carol) -> ALLOWED
document 1 after: Document[id=1, owner=carol, team=team-b, title=hijacked]
carol updateTitle(1, "hijacked") -> DENIED (AuthorizationDeniedException: Access Denied)
document 1 after: Document[id=1, owner=alice, team=team-a, title=Q3 plan]
@@ -0,0 +1,3 @@
get(1) loads: 2
list() loads: 0 (returned 3 of 4)
listByIdCheck() loads: 4 (returned 3 of 4)
@@ -0,0 +1,5 @@
3 x get(1) with cache: 4 loads (uncached it is 6)
alice updateTitle(1) -> ALLOWED
owner of doc 1 is now bob
alice updateTitle(1) -> ALLOWED (cached allow)
after evictAll() -> DENIED (AuthorizationDeniedException: Access Denied)
@@ -0,0 +1,7 @@
no credentials GET /documents/1 -> 401
alice GET /documents/1 -> 200
carol GET /documents/1 -> 403
alice GET /documents/99 -> 403
admin GET /documents/99 -> 403
bob PUT /documents/1?title=x -> 403
alice PUT /documents/1?title=x -> 200
+61
View File
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>permission-evaluator-demo</artifactId>
<version>1.0.0</version>
<name>permission-evaluator-demo</name>
<description>Object-level authorization with hasPermission() and a custom PermissionEvaluator on Spring Security 7.1 - runnable companion for ankurm.com</description>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Boot 4 split the test slices out: MockMvc support and Spring Security test support -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
package com.ankurm.permission;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PermissionDemoApplication {
public static void main(String[] args) {
SpringApplication.run(PermissionDemoApplication.class, args);
}
}
@@ -0,0 +1,16 @@
package com.ankurm.permission.domain;
/**
* The object we authorize against. {@code owner} is a username; {@code team} is the team
* whose members may read the document.
*/
public record Document(long id, String owner, String team, String title) {
public Document withOwner(String newOwner) {
return new Document(id, newOwner, team, title);
}
public Document withTitle(String newTitle) {
return new Document(id, owner, team, newTitle);
}
}
@@ -0,0 +1,58 @@
package com.ankurm.permission.domain;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.stereotype.Repository;
/**
* In-memory stand-in for a database table. {@link #loads()} counts how many times a
* document was fetched by id, which is how the tests make "this check costs a query" visible.
*/
@Repository
public class DocumentRepository {
private final Map<Long, Document> table = new ConcurrentHashMap<>();
private final AtomicInteger loads = new AtomicInteger();
public DocumentRepository() {
reset();
}
/** Restores the four seed documents and zeroes the load counter. */
public final void reset() {
table.clear();
table.put(1L, new Document(1, "alice", "team-a", "Q3 plan"));
table.put(2L, new Document(2, "alice", "team-a", "Salary notes"));
table.put(3L, new Document(3, "carol", "team-b", "Roadmap"));
table.put(4L, new Document(4, "bob", "team-a", "Standup"));
loads.set(0);
}
/** Fetch by id: this is the call that costs a query in a real application. */
public Optional<Document> findById(long id) {
loads.incrementAndGet();
return Optional.ofNullable(table.get(id));
}
public List<Document> findAll() {
return new ArrayList<>(table.values().stream().sorted((a, b) -> Long.compare(a.id(), b.id())).toList());
}
public Document save(Document doc) {
table.put(doc.id(), doc);
return doc;
}
public int loads() {
return loads.get();
}
public void resetLoads() {
loads.set(0);
}
}
@@ -0,0 +1,48 @@
package com.ankurm.permission.security;
import java.io.Serializable;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
/**
* Profile {@code cache}: memoizes the id-based decision per (user, type, id, permission).
*
* <p>This saves the Document load on repeated checks. The price is staleness: a cached
* "allow" survives a change of ownership until {@link #evictAll()} is called. The tests
* show both halves.
*/
@Component
@Primary
@Profile("cache")
public class CachingPermissionEvaluator implements PermissionEvaluator {
private final DocumentPermissionEvaluator delegate;
private final Map<String, Boolean> decisions = new ConcurrentHashMap<>();
public CachingPermissionEvaluator(DocumentPermissionEvaluator delegate) {
this.delegate = delegate;
}
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
return delegate.hasPermission(authentication, targetDomainObject, permission); // already in memory
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
Object permission) {
String key = authentication.getName() + "|" + targetType + "|" + targetId + "|" + permission;
return decisions.computeIfAbsent(key,
k -> delegate.hasPermission(authentication, targetId, targetType, permission));
}
public void evictAll() {
decisions.clear();
}
}
@@ -0,0 +1,73 @@
package com.ankurm.permission.security;
import java.io.Serializable;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.domain.DocumentRepository;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
/**
* Decides whether the caller may {@code read}, {@code write} or {@code delete} a Document.
*
* <p>Rules: an admin may do anything; the owner may read, write and delete; members of the
* document's team may read.
*
* <p>Spring calls one of two methods depending on how the expression was written:
* <ul>
* <li>{@code hasPermission(returnObject, 'read')} calls the object overload (the Document
* is already in memory);</li>
* <li>{@code hasPermission(#id, 'Document', 'read')} calls the id overload, so this class
* has to load the Document itself.</li>
* </ul>
*/
@Component
public class DocumentPermissionEvaluator implements PermissionEvaluator {
private final DocumentRepository documents;
private final UserDirectory users;
public DocumentPermissionEvaluator(DocumentRepository documents, UserDirectory users) {
this.documents = documents;
this.users = users;
}
@Override
public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) {
if (!(targetDomainObject instanceof Document document)) {
return false; // unknown target type: deny
}
return decide(authentication, document, String.valueOf(permission));
}
@Override
public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType,
Object permission) {
if (!"Document".equals(targetType) || !(targetId instanceof Number id)) {
return false; // unknown type or id shape: deny
}
return documents.findById(id.longValue())
.map(document -> decide(authentication, document, String.valueOf(permission)))
.orElse(false); // a missing document is denied, not "not found"
}
private boolean decide(Authentication authentication, Document document, String permission) {
if (authentication == null || !authentication.isAuthenticated()) {
return false;
}
boolean admin = authentication.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
if (admin) {
return true;
}
String user = authentication.getName();
boolean owner = document.owner().equals(user);
boolean sameTeam = users.teamOf(user).equals(document.team());
return switch (permission) {
case "read" -> owner || sameTeam;
case "write", "delete" -> owner;
default -> false; // unknown permission name: deny
};
}
}
@@ -0,0 +1,31 @@
package com.ankurm.permission.security;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
/**
* Profile {@code handler}: wire the evaluator into method security explicitly.
* Without this profile only the {@link PermissionEvaluator} bean exists. The tests record
* what each setup actually does.
*
* <p>The bean method is {@code static} so the handler is created early, before the
* configuration class that declares it.
*/
@Configuration
@Profile("handler")
public class ExplicitHandlerConfig {
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(PermissionEvaluator evaluator,
ApplicationContext context) {
DefaultMethodSecurityExpressionHandler handler = new DefaultMethodSecurityExpressionHandler();
handler.setPermissionEvaluator(evaluator);
handler.setApplicationContext(context);
return handler;
}
}
@@ -0,0 +1,35 @@
package com.ankurm.permission.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
/** HTTP Basic with four in-memory users, and method security switched on. */
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // stateless demo API; see the post before copying this
.build();
}
@Bean
UserDetailsService users() {
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{noop}alice-password").roles("USER").build(),
User.withUsername("bob").password("{noop}bob-password").roles("USER").build(),
User.withUsername("carol").password("{noop}carol-password").roles("USER").build(),
User.withUsername("admin").password("{noop}admin-password").roles("USER", "ADMIN").build());
}
}
@@ -0,0 +1,20 @@
package com.ankurm.permission.security;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Maps a username to the team it belongs to. In a real system this is your user table. */
@Component
public class UserDirectory {
private final Map<String, String> teams = Map.of(
"alice", "team-a",
"bob", "team-a",
"carol", "team-b",
"admin", "team-admin");
public String teamOf(String username) {
return teams.getOrDefault(username, "none");
}
}
@@ -0,0 +1,80 @@
package com.ankurm.permission.service;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.concurrent.atomic.AtomicInteger;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.domain.DocumentRepository;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
/**
* Every method here carries a different way of calling {@code hasPermission(...)}.
* The comment above each one says what the check costs and where it can go wrong.
*/
@Service
public class DocumentService {
private final DocumentRepository repository;
private final AtomicInteger bodyRuns = new AtomicInteger();
public DocumentService(DocumentRepository repository) {
this.repository = repository;
}
/** Id overload: the evaluator loads the Document, then this method loads it again (2 loads). */
@PreAuthorize("hasPermission(#id, 'Document', 'read')")
public Document get(long id) {
return repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/**
* Object overload on the return value. The method body runs BEFORE the check, so any side
* effect in it (here: {@code bodyRuns}) happens even when access is then denied.
*/
@PostAuthorize("hasPermission(returnObject, 'read')")
public Document getThenCheck(long id) {
bodyRuns.incrementAndGet();
return repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/**
* Object overload on an argument. The caller supplies the Document, so the caller also
* supplies its {@code owner} field. Do not expose this to untrusted input.
*/
@PreAuthorize("hasPermission(#doc, 'write')")
public Document update(Document doc) {
return repository.save(doc);
}
/** Safe variant: only the id comes from the caller; the evaluator loads the real owner. */
@PreAuthorize("hasPermission(#id, 'Document', 'write')")
public Document updateTitle(long id, String title) {
Document current = repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
return repository.save(current.withTitle(title));
}
@PreAuthorize("hasPermission(#id, 'Document', 'delete')")
public void delete(long id) {
repository.findById(id).orElseThrow(() -> new NoSuchElementException("no document " + id));
}
/** Object overload per element: no extra loads, the objects are already in memory. */
@PostFilter("hasPermission(filterObject, 'read')")
public List<Document> list() {
return repository.findAll();
}
/** Id overload per element: one extra load per element, so N+1 loads in total. */
@PostFilter("hasPermission(filterObject.id(), 'Document', 'read')")
public List<Document> listByIdCheck() {
return repository.findAll();
}
public int bodyRuns() {
return bodyRuns.get();
}
}
@@ -0,0 +1,44 @@
package com.ankurm.permission.web;
import java.util.List;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.service.DocumentService;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/documents")
public class DocumentController {
private final DocumentService service;
public DocumentController(DocumentService service) {
this.service = service;
}
@GetMapping("/{id}")
public Document get(@PathVariable long id) {
return service.get(id);
}
@GetMapping
public List<Document> list() {
return service.list();
}
@PutMapping("/{id}")
public Document rename(@PathVariable long id, @RequestParam String title) {
return service.updateTitle(id, title);
}
@DeleteMapping("/{id}")
public void delete(@PathVariable long id) {
service.delete(id);
}
}
@@ -0,0 +1,54 @@
package com.ankurm.permission;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.permission.domain.DocumentRepository;
import com.ankurm.permission.security.CachingPermissionEvaluator;
import com.ankurm.permission.service.DocumentService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/** Profiles "handler" + "cache": id-based decisions are memoized. */
@SpringBootTest
@ActiveProfiles({"handler", "cache"})
class CacheTest {
@Autowired DocumentService service;
@Autowired DocumentRepository repo;
@Autowired CachingPermissionEvaluator cache;
@Test
void cacheSavesLoadsButGoesStale() throws Exception {
Transcript t = new Transcript("06-cache-and-staleness");
repo.reset();
cache.evictAll();
Support.login("alice");
repo.resetLoads();
for (int i = 1; i <= 3; i++) {
service.get(1);
}
t.line("3 x get(1) with cache: %d loads (uncached it is 6)", repo.loads());
int cachedLoads = repo.loads();
// alice may write doc 1 (she owns it) -> the decision is cached
t.line("alice updateTitle(1) -> %s", Support.outcome(() -> service.updateTitle(1, "first")));
// ownership moves to bob behind the evaluator's back
repo.save(repo.findById(1).orElseThrow().withOwner("bob"));
t.line("owner of doc 1 is now bob");
String stale = Support.outcome(() -> service.updateTitle(1, "second"));
t.line("alice updateTitle(1) -> %s (cached allow)", stale);
cache.evictAll();
String fresh = Support.outcome(() -> service.updateTitle(1, "third"));
t.line("after evictAll() -> %s", fresh);
Support.logout();
repo.reset();
t.write();
assertThat(cachedLoads).isEqualTo(4);
assertThat(stale).isEqualTo("ALLOWED");
assertThat(fresh).startsWith("DENIED");
}
}
@@ -0,0 +1,31 @@
package com.ankurm.permission;
import com.ankurm.permission.domain.DocumentRepository;
import com.ankurm.permission.service.DocumentService;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.security.access.PermissionEvaluator;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/** No extra configuration: only the PermissionEvaluator bean and @EnableMethodSecurity. */
@SpringBootTest
class DefaultWiringTest {
@Autowired DocumentService service;
@Autowired DocumentRepository repo;
@Autowired ApplicationContext ctx;
@Test
void recordsWhatTheEvaluatorBeanAloneDoes() throws Exception {
Transcript t = new Transcript("01-bean-only-wiring");
t.line("PermissionEvaluator beans: %s", ctx.getBeansOfType(PermissionEvaluator.class).keySet());
t.line("MethodSecurityExpressionHandler beans: %s", ctx.getBeansOfType(MethodSecurityExpressionHandler.class).keySet());
Support.matrix(t, service, repo);
t.write();
assertThat(t.text()).doesNotContain("-> ALLOWED");
}
}
@@ -0,0 +1,81 @@
package com.ankurm.permission;
import com.ankurm.permission.domain.Document;
import com.ankurm.permission.domain.DocumentRepository;
import com.ankurm.permission.service.DocumentService;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/** Profile "handler": the evaluator is set on an explicit MethodSecurityExpressionHandler bean. */
@SpringBootTest
@ActiveProfiles("handler")
class ExplicitHandlerTest {
@Autowired DocumentService service;
@Autowired DocumentRepository repo;
@Test
void allowAndDenyMatrix() throws Exception {
Transcript t = new Transcript("02-explicit-handler-matrix");
Support.matrix(t, service, repo);
t.write();
assertThat(t.text()).contains("alice write doc 1 -> ALLOWED")
.contains("bob read doc 1 -> ALLOWED")
.contains("bob write doc 1 -> DENIED")
.contains("carol read doc 1 -> DENIED")
.contains("admin delete doc 1 -> ALLOWED");
}
@Test
void postAuthorizeRunsTheBodyBeforeDenying() throws Exception {
Transcript t = new Transcript("03-postauthorize-side-effect");
repo.reset();
Support.login("carol");
int before = service.bodyRuns();
String result = Support.outcome(() -> service.getThenCheck(1));
t.line("carol getThenCheck(1) -> %s", result);
t.line("method body ran %d time(s) during that call", service.bodyRuns() - before);
Support.logout();
t.write();
assertThat(result).startsWith("DENIED");
}
@Test
void forgedObjectVersusIdOnly() throws Exception {
Transcript t = new Transcript("04-forged-owner");
repo.reset();
Support.login("carol");
Document forged = new Document(1, "carol", "team-b", "hijacked");
t.line("document 1 before: %s", repo.findById(1).orElseThrow());
t.line("carol update(forged owner=carol) -> %s", Support.outcome(() -> service.update(forged)));
t.line("document 1 after: %s", repo.findById(1).orElseThrow());
repo.reset();
t.line("carol updateTitle(1, \"hijacked\") -> %s", Support.outcome(() -> service.updateTitle(1, "hijacked")));
t.line("document 1 after: %s", repo.findById(1).orElseThrow());
Support.logout();
repo.reset();
t.write();
}
@Test
void countsDocumentLoads() throws Exception {
Transcript t = new Transcript("05-load-counts");
Support.login("alice");
repo.reset();
repo.resetLoads();
service.get(1);
t.line("get(1) loads: %d", repo.loads());
repo.resetLoads();
int size = service.list().size();
t.line("list() loads: %d (returned %d of %d)", repo.loads(), size, repo.findAll().size());
repo.resetLoads();
size = service.listByIdCheck().size();
t.line("listByIdCheck() loads: %d (returned %d of %d)", repo.loads(), size, repo.findAll().size());
Support.logout();
t.write();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.permission;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.assertj.core.api.Assertions.assertThat;
import com.ankurm.permission.domain.DocumentRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
/** The same rules over HTTP Basic. */
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("handler")
class HttpTest {
@Autowired MockMvc mvc;
@Autowired DocumentRepository repo;
private String status(String method, String url, RequestPostProcessor auth) {
try {
var builder = method.equals("PUT") ? put(url) : get(url);
if (auth != null) {
builder = builder.with(auth);
}
return String.valueOf(mvc.perform(builder).andReturn().getResponse().getStatus());
} catch (Exception e) {
return "exception " + e.getClass().getSimpleName() + " (" + e.getCause() + ")";
}
}
@Test
void statusCodes() throws Exception {
repo.reset();
Transcript t = new Transcript("07-http-status-codes");
t.line("no credentials GET /documents/1 -> %s", status("GET", "/documents/1", null));
t.line("alice GET /documents/1 -> %s", status("GET", "/documents/1", httpBasic("alice", "alice-password")));
t.line("carol GET /documents/1 -> %s", status("GET", "/documents/1", httpBasic("carol", "carol-password")));
t.line("alice GET /documents/99 -> %s", status("GET", "/documents/99", httpBasic("alice", "alice-password")));
t.line("admin GET /documents/99 -> %s", status("GET", "/documents/99", httpBasic("admin", "admin-password")));
t.line("bob PUT /documents/1?title=x -> %s", status("PUT", "/documents/1?title=x", httpBasic("bob", "bob-password")));
t.line("alice PUT /documents/1?title=x -> %s", status("PUT", "/documents/1?title=x", httpBasic("alice", "alice-password")));
t.write();
repo.reset();
assertThat(t.text()).contains("no credentials GET /documents/1 -> 401")
.contains("alice GET /documents/1 -> 200")
.contains("carol GET /documents/1 -> 403");
}
}
@@ -0,0 +1,55 @@
package com.ankurm.permission;
import java.util.List;
import java.util.concurrent.Callable;
import com.ankurm.permission.domain.DocumentRepository;
import com.ankurm.permission.service.DocumentService;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
/** Small helpers shared by the tests: log in without HTTP, and turn a call into one outcome line. */
final class Support {
private Support() {
}
static void login(String user) {
String[] roles = user.equals("admin") ? new String[] {"ROLE_USER", "ROLE_ADMIN"} : new String[] {"ROLE_USER"};
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(user, "n/a", AuthorityUtils.createAuthorityList(roles)));
}
static void logout() {
SecurityContextHolder.clearContext();
}
/** Runs the call and reports ALLOWED, DENIED or ERROR with the exception text. */
static String outcome(Callable<?> call) {
try {
call.call();
return "ALLOWED";
} catch (AccessDeniedException e) {
return "DENIED (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")";
} catch (Exception e) {
return "ERROR (" + e.getClass().getSimpleName() + ": " + e.getMessage() + ")";
}
}
/** One line per user and permission on document 1 (alice / team-a). */
static void matrix(Transcript t, DocumentService service, DocumentRepository repo) {
for (String user : List.of("alice", "bob", "carol", "admin")) {
login(user);
repo.reset();
t.line("%-6s read doc 1 -> %s", user, outcome(() -> service.get(1)));
repo.reset();
t.line("%-6s write doc 1 -> %s", user, outcome(() -> service.updateTitle(1, "renamed")));
repo.reset();
t.line("%-6s delete doc 1 -> %s", user, outcome(() -> { service.delete(1); return null; }));
}
logout();
repo.reset();
}
}
@@ -0,0 +1,31 @@
package com.ankurm.permission;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
/** Collects lines and writes them to {@code output/<name>.txt} (relative to the module directory). */
final class Transcript {
private final String name;
private final StringBuilder text = new StringBuilder();
Transcript(String name) {
this.name = name;
}
Transcript line(String format, Object... args) {
text.append(args.length == 0 ? format : String.format(format, args)).append('\n');
return this;
}
String text() {
return text.toString();
}
void write() throws IOException {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.writeString(dir.resolve(name + ".txt"), text.toString());
}
}