1. Installation
repositories {
mavenCentral()
}
dependencies {
implementation 'com.agorapulse:micronaut-permissions:2.0.0'
}
2. Introduction
Micronaut Permissions provides a lightweight library to declare object level permissions in Micronaut which can be declared on any service and which are not limited to HTTP environment.
3. Usage
The typical example guarantees that only the creator of an object can edit its properties. Imagine a social network which publishes posts:
public class Post {
public enum Status { DRAFT, PUBLISHED, ARCHIVED }
public static Post createDraft(Long authorId, String message) {
return new Post(null, authorId, message, Status.DRAFT);
}
private Long id;
private final Status status;
private final Long authorId;
private final String message;
private Post(Long id, Long authorId, String message, Status status) {
this.id = id;
this.authorId = authorId;
this.message = message;
this.status = status;
}
public Status getStatus() {
return status;
}
public Long getAuthorId() {
return authorId;
}
public String getMessage() {
return message;
}
public Long getId() {
return id;
}
void setId(Long id) {
this.id = id;
}
Post publish() {
return new Post(id, authorId, message, Status.PUBLISHED);
}
Post archive() {
return new Post(id, authorId, message, Status.ARCHIVED);
}
}
Only the author of the Post
can update its state to either publish
it or archive
it. This can be achieved by
annotating the associated service methods with the @RequiresPermissions
annotation.
3.1. Require Permissions
import jakarta.inject.Singleton;
import java.util.Collection;
import java.util.Map;
@Singleton
public class PostService implements IPostService {
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
@Override
public Post create(Long userId, String message) {
if (userId == null || userId == 0) {
throw new IllegalArgumentException("User not specified");
}
return Post.createDraft(userId, message);
}
@Override (1)
public Post archive(Post post) {
return post.archive();
}
@Override
public void handleIterableContainer(Collection<Post> posts) {
}
@Override
public void handleContainerNonIterable(Post post, Map<String, String> couldBeIterableContainer) {
}
@Override
public Post publish(Post post) {
return post.publish();
}
@Override
public Post get(Long id) {
return postRepository.get(id);
}
@Override
public Post getOrEmpty(Long id) {
return postRepository.get(id);
}
@Override
public Post merge(Long userId, Post post1, Post post2) {
return Post.createDraft(userId, post1.getMessage() + post2.getMessage());
}
}
1 | Require permission edit on the Post object post |
2 | If you need to check the permissions on the returned object, use @ResultRequiresPermission |
3 | You can use @ResultRequiresPermission with returnNull = true to return null instead of throwing exception effectively producing NOT_FOUND HTTP statuses in the controllers. |
The permission can be any String
. The semantics are given by the implementation of the permission advisor.
For methods with multiple arguments, every argument must pass verification by the defined permission advisor. There must be a permission advisor defined for at least one of the arguments.
It is possible to validate a typed Iterable argument, permission advisor will apply to all of its items based on their type defined in method signature.
3.2. Permission Advisors
Permissions are checked by beans that implement PermissionAdvisor<T>
.
import io.micronaut.core.order.Ordered;
import io.micronaut.core.type.Argument;
/**
* The permission advisor checks the permissions for the given object and the current execution
* context.
*
* @param <T> the type of the objects which permissions are being checked
*/
public interface PermissionAdvisor<T> extends Ordered {
/**
* @return the argument type of the objects being checked
*/
Argument<T> argumentType();
/**
* Checks whether the operation described by the <code>permissionDefinition</code> can be
* executed within current execution context on the <code>value</code> object.
*
* @param permissionDefinition the string definition of the permission which are being
* evaluated
* @param value the current value being evaluated
* @param argument the argument with can be source of additional metadata such as annotations
* being present
* @return <code>ALLOW</code> if the operation is allowed,
* <code>DENY</code> if the operation is forbidden or
* <code>UNKNOWN</code> if the result cannot be decided
*/
PermissionCheckResult checkPermissions(
String permissionDefinition,
T value,
Argument<T> argument
);
}
PermissionAdvisor extends Ordered to support prioritizing one advisor over another.
|
The following advisor will check if the current User
is the author of the Post
instance.
import io.micronaut.core.type.Argument;
import jakarta.inject.Singleton;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
@Singleton
public class PostAdvisor implements PermissionAdvisor<Post> {
private final UserProvider provider;
private final static List<String> PERMISSIONS = Arrays.asList("edit", "read", "view");
public PostAdvisor(UserProvider provider) {
this.provider = provider;
}
@Override
public Argument<Post> argumentType() {
return Argument.of(Post.class); (1)
}
@Override
public PermissionCheckResult checkPermissions(
String permissionDefinition,
Post value,
Argument<Post> argument
) {
if (provider == null || value == null || !PERMISSIONS.contains(permissionDefinition)) {
return PermissionCheckResult.UNKNOWN; (2)
}
return provider.getCurrentUser().map(u -> {
if (Objects.equals(u.getId(), value.getAuthorId())) {
return PermissionCheckResult.ALLOW; (3)
}
return PermissionCheckResult.DENY; (4)
}).orElse(PermissionCheckResult.DENY);
}
}
1 | Declare the type of the argument being verified |
2 | Return UNKNOWN if the status cannot be determined; the next advisor will be asked for the result |
3 | Return ALLOW if the user can perform the requested operation |
4 | Return DENY if the user cannot perform the requested operation |
If any advisor returns DENY
, or every advisor returns UNKNOWN
, a PermissionException
is thrown.
3.3. Exception Handling
You can map PermissionException
to 401 UNAUTHORIZED
using the @Error
annotation in your HTTP environment.
PermissionException
in Controller@Error(PermissionException.class)
public HttpResponse<JsonError> permissionException(PermissionException ex) {
return HttpResponse.<JsonError>unauthorized().body(new JsonError(ex.getMessage()));
}
3.4. User Retrieval
There is no predefined interface for retrieving the user, but the best practise is to create a simple interface that
returns an Optional
:
import java.util.Optional;
public interface UserProvider {
Optional<User> getCurrentUser();
}
Then the example implementation can look like this:
import io.micronaut.http.context.ServerRequestContext;
import jakarta.inject.Singleton;
import java.util.Optional;
@Singleton (1)
public class RequestUserProvider implements UserProvider {
private static final String KEY = "com.agorapulse.user";
private final UserRepository userRepository;
public RequestUserProvider(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public Optional<User> getCurrentUser() {
return ServerRequestContext.currentRequest().flatMap(request -> { (2)
Optional<User> existingUser = request.getAttribute(KEY, User.class); (3)
if (existingUser.isPresent()) {
return existingUser;
}
Optional<Long> maybeUserId = request.getHeaders().get("X-User-Id", Long.class);
if (!maybeUserId.isPresent()) {
return Optional.empty();
}
User user = userRepository.get(maybeUserId.get()); (4)
request.setAttribute(KEY, user); (5)
return Optional.of(user);
});
}
}
1 | Use @Singleton instead of @RequestScope to use an advisor outside of controller calls (e.g. jobs) |
2 | Use ServerRequestContext to retrieve the current request, if present |
3 | Check if the request already contains the user object |
4 | Retrieving User is a potentially expensive operation |
5 | Store the User in the request for the calls which may follow |
3.5. Granting Permissions
You can disable a check for a particular method call, for example for administrator access, or for calling the code from a serverless function.
The following example shows two options for temporarily disabling permission checks.
import jakarta.inject.Singleton;
@Singleton
public class AdministratorPostService {
private final PostService postService;
private final PostRepository postRepository;
private final TemporaryPermissions temporaryPermissions;
public AdministratorPostService(
PostService postService,
PostRepository postRepository,
TemporaryPermissions temporaryPermissions
) {
this.postService = postService;
this.postRepository = postRepository;
this.temporaryPermissions = temporaryPermissions;
}
@GrantsPermission("edit") (1)
public Post archive(Post post) {
return postRepository.save(postService.archive(post));
}
public Post publish(Post post) {
Post publishedPost = temporaryPermissions.grantPermissions("edit", post, () -> {(2)
return postService.archive(post);
});
return postRepository.save(publishedPost);
}
}
1 | Annotate with @GrantPermissions to disable checks within the method body for any objects, use target if you want to specify the object under permission test |
2 | Disable checks just for the limited scope with TemporaryPermissions object |