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
|
# spring-auth-demo
|
||||||
|
|
||||||
Runnable companion code for five articles on [ankurm.com](https://ankurm.com):
|
Runnable companion code for six articles on [ankurm.com](https://ankurm.com):
|
||||||
|
|
||||||
| | article | code |
|
| | article | code |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -9,6 +9,7 @@ Runnable companion code for five articles on [ankurm.com](https://ankurm.com):
|
|||||||
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
|
| 3 | [Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider](https://ankurm.com/spring-authorization-server-oauth2-oidc-provider/) | [`authorization-server/`](authorization-server) |
|
||||||
| 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) |
|
| 4 | [Passkeys and WebAuthn with Spring Security 7](https://ankurm.com/passkeys-webauthn-spring-security-7/) | [`passkeys/`](passkeys) |
|
||||||
| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
|
| 5 | [Keycloak with Spring Boot 4.1: Roles, Resource Server and Admin API](https://ankurm.com/keycloak-spring-boot-4-1-roles-resource-server-admin-api/) | [`keycloak/`](keycloak) |
|
||||||
|
| 6 | [Spring Security hasPermission() with PermissionEvaluator and @EnableMethodSecurity](https://ankurm.com/spring-security-haspermission-permissionevaluator-method-security/) | [`permission-evaluator/`](permission-evaluator) — Spring Boot 4.1.2 / Security 7.1.2 |
|
||||||
|
|
||||||
Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
|
Five Maven projects. The first four share one [`docs/`](docs) tree; `keycloak/` keeps its explanations in the article. The first mints and verifies its own
|
||||||
tokens with a hand-written filter. The second verifies tokens minted by somebody else —
|
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