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:
@@ -1,6 +1,6 @@
|
||||
# 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 |
|
||||
|---|---|---|
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 6 | [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/) | [`permission-evaluator/`](permission-evaluator) — 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
|
||||
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+73
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
+31
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user