diff --git a/README.md b/README.md index 91a7de4..514f14e 100644 --- a/README.md +++ b/README.md @@ -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 — diff --git a/permission-evaluator/README.md b/permission-evaluator/README.md new file mode 100644 index 0000000..2dab6f2 --- /dev/null +++ b/permission-evaluator/README.md @@ -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. diff --git a/permission-evaluator/output/00-denyall-log.txt b/permission-evaluator/output/00-denyall-log.txt new file mode 100644 index 0000000..7ef6c64 --- /dev/null +++ b/permission-evaluator/output/00-denyall-log.txt @@ -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 diff --git a/permission-evaluator/output/01-bean-only-wiring.txt b/permission-evaluator/output/01-bean-only-wiring.txt new file mode 100644 index 0000000..87ebdb0 --- /dev/null +++ b/permission-evaluator/output/01-bean-only-wiring.txt @@ -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) diff --git a/permission-evaluator/output/02-explicit-handler-matrix.txt b/permission-evaluator/output/02-explicit-handler-matrix.txt new file mode 100644 index 0000000..8d77c7c --- /dev/null +++ b/permission-evaluator/output/02-explicit-handler-matrix.txt @@ -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 diff --git a/permission-evaluator/output/03-postauthorize-side-effect.txt b/permission-evaluator/output/03-postauthorize-side-effect.txt new file mode 100644 index 0000000..fb3d622 --- /dev/null +++ b/permission-evaluator/output/03-postauthorize-side-effect.txt @@ -0,0 +1,2 @@ +carol getThenCheck(1) -> DENIED (AuthorizationDeniedException: Access Denied) +method body ran 1 time(s) during that call diff --git a/permission-evaluator/output/04-forged-owner.txt b/permission-evaluator/output/04-forged-owner.txt new file mode 100644 index 0000000..aca537d --- /dev/null +++ b/permission-evaluator/output/04-forged-owner.txt @@ -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] diff --git a/permission-evaluator/output/05-load-counts.txt b/permission-evaluator/output/05-load-counts.txt new file mode 100644 index 0000000..f09d5e3 --- /dev/null +++ b/permission-evaluator/output/05-load-counts.txt @@ -0,0 +1,3 @@ +get(1) loads: 2 +list() loads: 0 (returned 3 of 4) +listByIdCheck() loads: 4 (returned 3 of 4) diff --git a/permission-evaluator/output/06-cache-and-staleness.txt b/permission-evaluator/output/06-cache-and-staleness.txt new file mode 100644 index 0000000..9df4727 --- /dev/null +++ b/permission-evaluator/output/06-cache-and-staleness.txt @@ -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) diff --git a/permission-evaluator/output/07-http-status-codes.txt b/permission-evaluator/output/07-http-status-codes.txt new file mode 100644 index 0000000..35e0849 --- /dev/null +++ b/permission-evaluator/output/07-http-status-codes.txt @@ -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 diff --git a/permission-evaluator/pom.xml b/permission-evaluator/pom.xml new file mode 100644 index 0000000..3cc1b0c --- /dev/null +++ b/permission-evaluator/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + permission-evaluator-demo + 1.0.0 + permission-evaluator-demo + Object-level authorization with hasPermission() and a custom PermissionEvaluator on Spring Security 7.1 - runnable companion for ankurm.com + + + 25 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/PermissionDemoApplication.java b/permission-evaluator/src/main/java/com/ankurm/permission/PermissionDemoApplication.java new file mode 100644 index 0000000..a2dcd0f --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/PermissionDemoApplication.java @@ -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); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/domain/Document.java b/permission-evaluator/src/main/java/com/ankurm/permission/domain/Document.java new file mode 100644 index 0000000..5db7d59 --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/domain/Document.java @@ -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); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/domain/DocumentRepository.java b/permission-evaluator/src/main/java/com/ankurm/permission/domain/DocumentRepository.java new file mode 100644 index 0000000..1e4040b --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/domain/DocumentRepository.java @@ -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 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 findById(long id) { + loads.incrementAndGet(); + return Optional.ofNullable(table.get(id)); + } + + public List 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); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/security/CachingPermissionEvaluator.java b/permission-evaluator/src/main/java/com/ankurm/permission/security/CachingPermissionEvaluator.java new file mode 100644 index 0000000..412b142 --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/security/CachingPermissionEvaluator.java @@ -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). + * + *

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 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(); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/security/DocumentPermissionEvaluator.java b/permission-evaluator/src/main/java/com/ankurm/permission/security/DocumentPermissionEvaluator.java new file mode 100644 index 0000000..5975567 --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/security/DocumentPermissionEvaluator.java @@ -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. + * + *

Rules: an admin may do anything; the owner may read, write and delete; members of the + * document's team may read. + * + *

Spring calls one of two methods depending on how the expression was written: + *

    + *
  • {@code hasPermission(returnObject, 'read')} calls the object overload (the Document + * is already in memory);
  • + *
  • {@code hasPermission(#id, 'Document', 'read')} calls the id overload, so this class + * has to load the Document itself.
  • + *
+ */ +@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 + }; + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/security/ExplicitHandlerConfig.java b/permission-evaluator/src/main/java/com/ankurm/permission/security/ExplicitHandlerConfig.java new file mode 100644 index 0000000..4c36091 --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/security/ExplicitHandlerConfig.java @@ -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. + * + *

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; + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/security/SecurityConfig.java b/permission-evaluator/src/main/java/com/ankurm/permission/security/SecurityConfig.java new file mode 100644 index 0000000..7aa55bf --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/security/SecurityConfig.java @@ -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()); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/security/UserDirectory.java b/permission-evaluator/src/main/java/com/ankurm/permission/security/UserDirectory.java new file mode 100644 index 0000000..5930f0c --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/security/UserDirectory.java @@ -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 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"); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/service/DocumentService.java b/permission-evaluator/src/main/java/com/ankurm/permission/service/DocumentService.java new file mode 100644 index 0000000..ca36c54 --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/service/DocumentService.java @@ -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 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 listByIdCheck() { + return repository.findAll(); + } + + public int bodyRuns() { + return bodyRuns.get(); + } +} diff --git a/permission-evaluator/src/main/java/com/ankurm/permission/web/DocumentController.java b/permission-evaluator/src/main/java/com/ankurm/permission/web/DocumentController.java new file mode 100644 index 0000000..e40199d --- /dev/null +++ b/permission-evaluator/src/main/java/com/ankurm/permission/web/DocumentController.java @@ -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 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); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/CacheTest.java b/permission-evaluator/src/test/java/com/ankurm/permission/CacheTest.java new file mode 100644 index 0000000..6740df1 --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/CacheTest.java @@ -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"); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/DefaultWiringTest.java b/permission-evaluator/src/test/java/com/ankurm/permission/DefaultWiringTest.java new file mode 100644 index 0000000..702477a --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/DefaultWiringTest.java @@ -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"); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/ExplicitHandlerTest.java b/permission-evaluator/src/test/java/com/ankurm/permission/ExplicitHandlerTest.java new file mode 100644 index 0000000..5c50ff4 --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/ExplicitHandlerTest.java @@ -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(); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/HttpTest.java b/permission-evaluator/src/test/java/com/ankurm/permission/HttpTest.java new file mode 100644 index 0000000..46f1e6d --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/HttpTest.java @@ -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"); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/Support.java b/permission-evaluator/src/test/java/com/ankurm/permission/Support.java new file mode 100644 index 0000000..f4936de --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/Support.java @@ -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(); + } +} diff --git a/permission-evaluator/src/test/java/com/ankurm/permission/Transcript.java b/permission-evaluator/src/test/java/com/ankurm/permission/Transcript.java new file mode 100644 index 0000000..36e2f09 --- /dev/null +++ b/permission-evaluator/src/test/java/com/ankurm/permission/Transcript.java @@ -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/.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()); + } +}