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());
+ }
+}