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:

Post.java
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

PostService.java
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>.

PermissionAdvisor.java
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.

PostAdvisor.java
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.

Handling 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:

UserProvider.java
import java.util.Optional;

public interface UserProvider {

    Optional<User> getCurrentUser();

}

Then the example implementation can look like this:

RequestScopeUserProvider.java
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.

AdministratorPostService.java
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