Skip to content

Commit

Permalink
Merge pull request #12 from agorapulse/feature/result-requires-permis…
Browse files Browse the repository at this point in the history
…sions

Add annotation to check permission on result
  • Loading branch information
DKarim authored May 3, 2023
2 parents e4b6a8c + ea5745f commit e294a73
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 5 deletions.
6 changes: 4 additions & 2 deletions docs/guide/src/docs/asciidoc/usage.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ annotating the associated service methods with the `@RequiresPermissions` annota
include::{root-dir}/libs/micronaut-permissions/src/test/groovy/com/agorapulse/permissions/PostService.java[lines=20..-1]
----
<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_advisors, permission advisor>>.

Expand All @@ -44,10 +46,10 @@ TIP: `PermissionAdvisor` extends `Ordered` to support prioritizing one advisor o

The following advisor will check if the current `User` is the author of the `Post` instance.

.EditPostAdvisor.java
.PostAdvisor.java
[source,java]
----
include::{root-dir}/libs/micronaut-permissions/src/test/groovy/com/agorapulse/permissions/EditPostAdvisor.java[lines=20..-1]
include::{root-dir}/libs/micronaut-permissions/src/test/groovy/com/agorapulse/permissions/PostAdvisor.java[lines=20..-1]
----
<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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Copyright 2023 Agorapulse.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.agorapulse.permissions;

import io.micronaut.aop.Around;
import io.micronaut.context.annotation.Type;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
* You can use {@link GrantsPermission} annotation to declare the required permission checks for every method's argument
* which has the {@link PermissionAdvisor} declared. At least one argument must have the {@link PermissionAdvisor} declared
* and every check must pass.
*/
@Around
@Documented
@Retention(RUNTIME)
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Type(ResultRequiresPermissionInterceptor.class)
public @interface ResultRequiresPermission {

/**
* @return the arbitrary permissions' definition, there is no semantics given by default as they are declared by the advisors
*/
String value();

/**
* @return <code>true</code> if the method should return <code>null</code> instead of throwing exception
*/
boolean returnNull() default false;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Copyright 2023 Agorapulse.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.agorapulse.permissions;

import io.micronaut.aop.MethodInterceptor;
import io.micronaut.aop.MethodInvocationContext;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.type.Argument;

import javax.inject.Singleton;

@Singleton
public class ResultRequiresPermissionInterceptor implements MethodInterceptor<Object, Object> {

public static final int POSITION = -299;

private final PermissionChecker permissionChecker;

public ResultRequiresPermissionInterceptor(PermissionChecker permissionChecker) {
this.permissionChecker = permissionChecker;
}

@Override
public Object intercept(MethodInvocationContext<Object, Object> context) {
AnnotationValue<ResultRequiresPermission> annotation = context.findAnnotation(ResultRequiresPermission.class)
.orElseThrow(() -> new PermissionException(null, context.getTargetMethod(), "Method without @ResultRequiresPermission annotation!"));

String permissionString = annotation.getRequiredValue(String.class);
Object value = context.proceed();

if (value == null) {
return null;
}

Argument<Object> argument = context.getReturnType().asArgument();
PermissionCheckResult result = permissionChecker.checkPermission(permissionString, value, argument);

boolean returnNull = annotation.get("returnNull", Boolean.class, Boolean.FALSE);

if (returnNull) {
switch (result) {
case DENY:
return null;
case ALLOW:
return value;
default:
throw new PermissionException(permissionString, context.getTargetMethod(), "Cannot determine if the user has the permissions to perform operation");
}
}

switch (result) {
case DENY:
throw new PermissionException(permissionString, value, "The user does not have a permissions to perform operation");
case ALLOW:
return value;
default:
throw new PermissionException(permissionString, context.getTargetMethod(), "Cannot determine if the user has the permissions to perform operation");
}

}

@Override
public int getOrder() {
return POSITION;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
import java.util.Objects;

@Singleton
public class EditPostAdvisor implements PermissionAdvisor<Post> {
public class PostAdvisor implements PermissionAdvisor<Post> {

private final UserProvider provider;
private final static List<String> PERMISSIONS = Arrays.asList("edit", "read");
private final static List<String> PERMISSIONS = Arrays.asList("edit", "read", "view");

public EditPostAdvisor(UserProvider provider) {
public PostAdvisor(UserProvider provider) {
this.provider = provider;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ public PostController(PostService postService, PostRepository postRepository) {
this.postRepository = postRepository;
}

@Get("/{id}")
public Post view(Long id) {
return postService.get(id);
}

@Get("/{id}/or-empty")
public Post viewOrEmpty(Long id) {
return postService.getOrEmpty(id);
}

@Status(HttpStatus.CREATED)
@io.micronaut.http.annotation.Post("/")
public Post create(@Nullable @Header("X-User-Id") Long userId, String message) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,63 @@ class PostControllerSpec extends Specification {
}
}

void 'view post without any auth'() {
given:
Post post = Post.createDraft(AUTH_ID_1.toLong(), HELLO_MESSAGE)
postRepository.save(post)
expect:
gru.test {
get '/post/1'
expect {
status UNAUTHORIZED
json 'viewFailed.json'
}
}
}

void 'view post with auth'() {
given:
Post post = Post.createDraft(AUTH_ID_1.toLong(), HELLO_MESSAGE)
postRepository.save(post)
expect:
gru.test {
get '/post/1', {
headers 'X-User-Id': '1'
}
expect {
json 'existingPost.json'
}
}
}

void 'view post or empty without any auth'() {
given:
Post post = Post.createDraft(AUTH_ID_1.toLong(), HELLO_MESSAGE)
postRepository.save(post)
expect:
gru.test {
get '/post/1/or-empty'
expect {
status NOT_FOUND
}
}
}

void 'view post or empty with auth'() {
given:
Post post = Post.createDraft(AUTH_ID_1.toLong(), HELLO_MESSAGE)
postRepository.save(post)
expect:
gru.test {
get '/post/1/or-empty', {
headers 'X-User-Id': '1'
}
expect {
json 'existingPost.json'
}
}
}

void 'archive post without any auth'() {
given:
Post post = Post.createDraft(AUTH_ID_1.toLong(), HELLO_MESSAGE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
@Singleton
public class PostService {

private final PostRepository postRepository;

public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}

public Post create(Long userId, String message) {
if (userId == null || userId == 0) {
throw new IllegalArgumentException("User not specified");
Expand All @@ -50,6 +56,16 @@ public Post publish(Post post) {
return post.publish();
}

@ResultRequiresPermission(value = "view") // <2>
public Post get(Long id) {
return postRepository.get(id);
}

@ResultRequiresPermission(value = "view", returnNull = true) // <3>
public Post getOrEmpty(Long id) {
return postRepository.get(id);
}

@RequiresPermission("read")
public Post merge(Long userId, Post post1, Post post2) {
return Post.createDraft(userId, post1.getMessage() + post2.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"id": 1,
"status": "DRAFT",
"authorId": 1,
"message": "Hello"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"message": "The user does not have a permissions to perform operation"
}

0 comments on commit e294a73

Please sign in to comment.