Add an endpoint to grant permissions to user (#2239)

#### What type of PR is this?

/kind feature
/kind api-change
/area core
/milestone 2.0

#### What this PR does / why we need it:

Add an endpoint to grant permissions to user.

#### Which issue(s) this PR fixes:

Fixes #

#### Special notes for your reviewer:

Test steps:

1. Start Halo
2. Check the initial password in the console log
3. Request <http://localhost:8090/webjars/swagger-ui/index.html> from browser and you will be redirected to login page
4. Input the username(admin) and the password you got just now
5. Grant permission as you wish

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/2241/head
John Niang 2022-07-13 15:17:08 +08:00 committed by GitHub
parent de493ccb2c
commit faae645e88
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 355 additions and 29 deletions

View File

@ -1,6 +1,9 @@
package run.halo.app.core.extension;
import static java.util.Arrays.compare;
import static run.halo.app.core.extension.Role.GROUP;
import static run.halo.app.core.extension.Role.KIND;
import static run.halo.app.core.extension.Role.VERSION;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
@ -18,13 +21,17 @@ import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "",
version = "v1alpha1",
kind = "Role",
@GVK(group = GROUP,
version = VERSION,
kind = KIND,
plural = "roles",
singular = "role")
public class Role extends AbstractExtension {
public static final String GROUP = "";
public static final String VERSION = "v1alpha1";
public static final String KIND = "Role";
@Schema(required = true)
List<PolicyRule> rules;

View File

@ -1,13 +1,22 @@
package run.halo.app.core.extension;
import static run.halo.app.core.extension.RoleBinding.GROUP;
import static run.halo.app.core.extension.RoleBinding.KIND;
import static run.halo.app.core.extension.RoleBinding.VERSION;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.GVK;
import run.halo.app.extension.Metadata;
/**
* RoleBinding references a role, but does not contain it.
@ -20,13 +29,19 @@ import run.halo.app.extension.GVK;
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@GVK(group = "",
version = "v1alpha1",
kind = "RoleBinding",
@GVK(group = GROUP,
version = VERSION,
kind = KIND,
plural = "rolebindings",
singular = "rolebinding")
public class RoleBinding extends AbstractExtension {
public static final String GROUP = "";
public static final String VERSION = "v1alpha1";
public static final String KIND = "RoleBinding";
/**
* Subjects holds references to the objects the role applies to.
*/
@ -91,5 +106,55 @@ public class RoleBinding extends AbstractExtension {
* Defaults to "rbac.authorization.halo.run" for User and Group subjects.
*/
String apiGroup;
public static Predicate<Subject> isUser(String username) {
return subject -> User.KIND.equals(subject.getKind())
&& User.GROUP.equals(subject.getApiGroup())
&& username.equals(subject.getName());
}
public static Predicate<Subject> containsUser(Set<String> usernames) {
return subject -> User.KIND.equals(subject.getKind())
&& User.GROUP.equals(subject.apiGroup)
&& usernames.contains(subject.getName());
}
}
public static RoleBinding create(String username, String roleName) {
var metadata = new Metadata();
metadata.setName(String.join("-", username, roleName, "binding"));
var roleRef = new RoleRef();
roleRef.setKind(Role.KIND);
roleRef.setName(roleName);
roleRef.setApiGroup(Role.GROUP);
var subject = new Subject();
subject.setKind(User.KIND);
subject.setName(username);
subject.setApiGroup(User.GROUP);
var binding = new RoleBinding();
binding.setMetadata(metadata);
binding.setRoleRef(roleRef);
// keep the subjects mutable
var subjects = new LinkedList<Subject>();
subjects.add(subject);
binding.setSubjects(subjects);
return binding;
}
public static Predicate<RoleBinding> containsUser(String username) {
return ExtensionOperator.<RoleBinding>isNotDeleted().and(
binding -> binding.getSubjects().stream()
.anyMatch(Subject.isUser(username)));
}
public static Predicate<RoleBinding> containsUser(Set<String> usernames) {
return ExtensionOperator.<RoleBinding>isNotDeleted()
.and(binding -> binding.getSubjects().stream()
.anyMatch(Subject.containsUser(usernames)));
}
}

View File

@ -1,5 +1,9 @@
package run.halo.app.core.extension;
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import static run.halo.app.core.extension.User.VERSION;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.List;
@ -17,13 +21,17 @@ import run.halo.app.extension.GVK;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@GVK(group = "",
version = "v1alpha1",
kind = "User",
@GVK(group = GROUP,
version = VERSION,
kind = KIND,
singular = "user",
plural = "users")
public class User extends AbstractExtension {
public static final String GROUP = "";
public static final String VERSION = "v1alpha1";
public static final String KIND = "User";
@Schema(required = true)
private UserSpec spec;

View File

@ -1,15 +1,26 @@
package run.halo.app.core.extension.endpoint;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import java.util.HashSet;
import java.util.Set;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
@ -35,13 +46,76 @@ public class UserEndpoint implements CustomEndpoint {
.bodyValue(user));
}
Mono<ServerResponse> grantPermission(ServerRequest request) {
var username = request.pathVariable("name");
return request.bodyToMono(GrantRequest.class)
.switchIfEmpty(Mono.error(() -> new ServerWebInputException("Request body is empty")))
.flatMap(grant -> {
// preflight check
client.fetch(User.class, username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"User " + username + " was not found"));
grant.roles.forEach(roleName -> client.fetch(Role.class, roleName)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Role " + roleName + " was not found")));
var bindings =
client.list(RoleBinding.class, RoleBinding.containsUser(username), null);
var bindingToUpdate = new HashSet<RoleBinding>();
var bindingToDelete = new HashSet<RoleBinding>();
var existingRoles = new HashSet<String>();
bindings.forEach(binding -> {
var roleName = binding.getRoleRef().getName();
if (grant.roles.contains(roleName)) {
existingRoles.add(roleName);
return;
}
binding.getSubjects().removeIf(RoleBinding.Subject.isUser(username));
if (CollectionUtils.isEmpty(binding.getSubjects())) {
// remove it if subjects is empty
bindingToDelete.add(binding);
} else {
bindingToUpdate.add(binding);
}
});
bindingToUpdate.forEach(client::update);
bindingToDelete.forEach(client::delete);
// remove existing roles
var roles = new HashSet<>(grant.roles);
roles.removeAll(existingRoles);
roles.stream()
.map(roleName -> RoleBinding.create(username, roleName))
.forEach(client::create);
return ServerResponse.ok().build();
});
}
record GrantRequest(Set<String> roles) {
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "api.halo.run/v1alpha1/User";
return SpringdocRouteBuilder.route()
.GET("/users/-", this::me, builder -> builder.operationId("GetCurrentUserDetail")
.description("Get current user detail")
.tag("api.halo.run/v1alpha1/User")
.tag(tag)
.response(responseBuilder().implementation(User.class)))
.POST("/users/{name}/permissions", this::grantPermission,
builder -> builder.operationId("GrantPermission")
.description("Grant permissions to user")
.tag(tag)
.parameter(parameterBuilder().in(ParameterIn.PATH).name("name")
.description("User name")
.required(true))
.requestBody(
requestBodyBuilder().required(true).implementation(GrantRequest.class))
.response(responseBuilder().implementation(User.class)))
.build();
}
}

View File

@ -1,14 +1,15 @@
package run.halo.app.core.extension.reconciler;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.util.Lazy;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.User;
@ -32,7 +33,7 @@ public class RoleBindingReconciler implements Reconciler {
client.fetch(RoleBinding.class, request.name()).ifPresent(roleBinding -> {
// get all usernames;
var usernames = roleBinding.getSubjects().stream()
.filter(subject -> "User".equals(subject.getKind()))
.filter(subject -> User.KIND.equals(subject.getKind()))
.map(Subject::getName)
.collect(Collectors.toSet());
@ -44,7 +45,7 @@ public class RoleBindingReconciler implements Reconciler {
var roleNames = bindings.get().stream()
.filter(containsUser(username))
.map(RoleBinding::getRoleRef)
.filter(roleRef -> Objects.equals(roleRef.getKind(), "Role"))
.filter(roleRef -> Objects.equals(roleRef.getKind(), Role.KIND))
.map(RoleBinding.RoleRef::getName)
.sorted()
// we have to use LinkedHashSet below to make sure the sorted above functional
@ -68,17 +69,4 @@ public class RoleBindingReconciler implements Reconciler {
return new Result(false, null);
}
Predicate<RoleBinding> containsUser(String username) {
return roleBinding -> roleBinding.getMetadata().getDeletionTimestamp() == null
&& roleBinding.getSubjects().stream()
.anyMatch(subject -> "User".equals(subject.getKind())
&& username.equals(subject.getName()));
}
Predicate<RoleBinding> containsUser(Set<String> usernames) {
return roleBinding -> roleBinding.getMetadata().getDeletionTimestamp() == null
&& roleBinding.getSubjects().stream()
.anyMatch(subject -> "User".equals(subject.getKind())
&& usernames.contains(subject.getName()));
}
}

View File

@ -3,6 +3,7 @@ package run.halo.app.extension;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.function.Predicate;
import org.springframework.util.StringUtils;
/**
@ -91,4 +92,7 @@ public interface ExtensionOperator {
return GroupVersionKind.fromAPIVersionAndKind(getApiVersion(), getKind());
}
static <T extends ExtensionOperator> Predicate<T> isNotDeleted() {
return ext -> ext.getMetadata().getDeletionTimestamp() == null;
}
}

View File

@ -0,0 +1,43 @@
package run.halo.app.core.extension;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.Metadata;
class RoleBindingTest {
@Test
void shouldContainUser() {
var subject = new RoleBinding.Subject();
subject.setName("fake-name");
subject.setApiGroup("");
subject.setKind("User");
var binding = new RoleBinding();
binding.setMetadata(new Metadata());
binding.setSubjects(List.of(subject));
assertTrue(RoleBinding.containsUser("fake-name").test(binding));
assertFalse(RoleBinding.containsUser("non-exist-fake-name").test(binding));
}
@Test
void shouldNotContainUserWhenBindingIsDeleted() {
var subject = new RoleBinding.Subject();
subject.setName("fake-name");
subject.setApiGroup("");
subject.setKind("User");
var binding = new RoleBinding();
var metadata = new Metadata();
metadata.setDeletionTimestamp(Instant.now());
binding.setMetadata(metadata);
binding.setSubjects(List.of(subject));
assertFalse(RoleBinding.containsUser("fake-name").test(binding));
assertFalse(RoleBinding.containsUser("non-exist-fake-name").test(binding));
}
}

View File

@ -1,11 +1,22 @@
package run.halo.app.core.extension.endpoint;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
@ -15,6 +26,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.reactive.server.WebTestClient;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
@ -44,6 +56,8 @@ class UserEndpointTest {
var role = new Role();
role.setRules(List.of(rule));
when(roleService.getRole(anyString())).thenReturn(role);
// prevent from initializing the super admin.
when(client.fetch(User.class, "admin")).thenReturn(Optional.of(mock(User.class)));
}
@Test
@ -70,4 +84,99 @@ class UserEndpointTest {
.expectBody(User.class)
.isEqualTo(user);
}
@Nested
class GrantPermissionEndpointTest {
@Test
@WithMockUser("fake-user")
void shouldGetBadRequestIfRequestBodyIsEmpty() {
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isBadRequest();
// Why one more time to verify? Because the SuperAdminInitializer will fetch admin user.
verify(client, never()).fetch(same(User.class), eq("fake-user"));
verify(client, never()).fetch(same(Role.class), eq("fake-role"));
}
@Test
@WithMockUser("fake-user")
void shouldGetNotFoundIfUserNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.empty());
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(mock(Role.class)));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role")))
.exchange()
.expectStatus().isNotFound();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, never()).fetch(same(Role.class), eq("fake-role"));
}
@Test
@WithMockUser("fake-user")
void shouldGetNotFoundIfRoleNotFound() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.empty());
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role")))
.exchange()
.expectStatus().isNotFound();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
}
@Test
@WithMockUser("fake-user")
void shouldCreateRoleBindingIfNotExist() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
var role = mock(Role.class);
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(role));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role")))
.exchange()
.expectStatus().isOk();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, never()).update(isA(RoleBinding.class));
verify(client, never()).delete(isA(RoleBinding.class));
}
@Test
@WithMockUser("fake-user")
void shouldDeleteRoleBindingIfNotProvided() {
when(client.fetch(User.class, "fake-user")).thenReturn(Optional.of(mock(User.class)));
var role = mock(Role.class);
when(client.fetch(Role.class, "fake-role")).thenReturn(Optional.of(role));
var roleBinding = RoleBinding.create("fake-user", "non-provided-fake-role");
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(
List.of(roleBinding));
webClient.post().uri("/apis/api.halo.run/v1alpha1/users/fake-user/permissions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(new UserEndpoint.GrantRequest(Set.of("fake-role")))
.exchange()
.expectStatus().isOk();
verify(client, times(1)).fetch(same(User.class), eq("fake-user"));
verify(client, times(1)).fetch(same(Role.class), eq("fake-role"));
verify(client, times(1)).create(RoleBinding.create("fake-user", "fake-role"));
verify(client, times(1)).delete(argThat(binding ->
binding.getMetadata().getName().equals(roleBinding.getMetadata().getName())));
verify(client, never()).update(isA(RoleBinding.class));
}
}
}

View File

@ -137,6 +137,10 @@ class RoleBindingReconcilerTest {
var bindingName = "fake-binding-name";
var userName = "fake-user-name";
var subject = mock(Subject.class);
when(subject.getKind()).thenReturn("User");
when(subject.getName()).thenReturn(userName);
when(subject.getApiGroup()).thenReturn("");
var user = mock(User.class);
var userMetadata = mock(Metadata.class);
var binding = createRoleBinding("Role", "fake-role", false, subject);
@ -145,8 +149,6 @@ class RoleBindingReconcilerTest {
.thenReturn(Optional.of(binding));
when(binding.getSubjects()).thenReturn(List.of(subject));
when(subject.getKind()).thenReturn("User");
when(subject.getName()).thenReturn(userName);
when(client.fetch(User.class, userName)).thenReturn(Optional.of(user));
when(user.getMetadata()).thenReturn(userMetadata);
var bindings = List.of(
@ -182,6 +184,7 @@ class RoleBindingReconcilerTest {
var metadata = mock(Metadata.class);
lenient().when(roleRef.getKind()).thenReturn(roleRefKind);
lenient().when(roleRef.getName()).thenReturn(roleRefName);
lenient().when(roleRef.getApiGroup()).thenReturn("");
lenient().when(metadata.getDeletionTimestamp()).thenReturn(deleting ? Instant.now() : null);
lenient().when(binding.getRoleRef()).thenReturn(roleRef);
lenient().when(binding.getMetadata()).thenReturn(metadata);

View File

@ -0,0 +1,25 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Instant;
import org.junit.jupiter.api.Test;
class ExtensionOperatorTest {
@Test
void testIsNotDeleted() {
var ext = mock(ExtensionOperator.class);
var metadata = mock(Metadata.class);
when(metadata.getDeletionTimestamp()).thenReturn(null);
when(ext.getMetadata()).thenReturn(metadata);
assertTrue(ExtensionOperator.isNotDeleted().test(ext));
when(metadata.getDeletionTimestamp()).thenReturn(Instant.now());
assertFalse(ExtensionOperator.isNotDeleted().test(ext));
}
}