diff --git a/src/main/java/run/halo/app/core/extension/Role.java b/src/main/java/run/halo/app/core/extension/Role.java index e64905b01..be53befd8 100644 --- a/src/main/java/run/halo/app/core/extension/Role.java +++ b/src/main/java/run/halo/app/core/extension/Role.java @@ -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 rules; diff --git a/src/main/java/run/halo/app/core/extension/RoleBinding.java b/src/main/java/run/halo/app/core/extension/RoleBinding.java index 7f99904cf..1d559522e 100644 --- a/src/main/java/run/halo/app/core/extension/RoleBinding.java +++ b/src/main/java/run/halo/app/core/extension/RoleBinding.java @@ -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 isUser(String username) { + return subject -> User.KIND.equals(subject.getKind()) + && User.GROUP.equals(subject.getApiGroup()) + && username.equals(subject.getName()); + } + + public static Predicate containsUser(Set 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(); + subjects.add(subject); + + binding.setSubjects(subjects); + return binding; + } + + public static Predicate containsUser(String username) { + return ExtensionOperator.isNotDeleted().and( + binding -> binding.getSubjects().stream() + .anyMatch(Subject.isUser(username))); + } + + public static Predicate containsUser(Set usernames) { + return ExtensionOperator.isNotDeleted() + .and(binding -> binding.getSubjects().stream() + .anyMatch(Subject.containsUser(usernames))); } } diff --git a/src/main/java/run/halo/app/core/extension/User.java b/src/main/java/run/halo/app/core/extension/User.java index 55da16b58..d7d78012c 100644 --- a/src/main/java/run/halo/app/core/extension/User.java +++ b/src/main/java/run/halo/app/core/extension/User.java @@ -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; diff --git a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index ea5b30c73..ae91cca94 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -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 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(); + var bindingToDelete = new HashSet(); + var existingRoles = new HashSet(); + 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 roles) { + } + @Override public RouterFunction 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(); } + } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java index 67fefeb89..1b3b9e3e0 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java @@ -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 containsUser(String username) { - return roleBinding -> roleBinding.getMetadata().getDeletionTimestamp() == null - && roleBinding.getSubjects().stream() - .anyMatch(subject -> "User".equals(subject.getKind()) - && username.equals(subject.getName())); - } - - Predicate containsUser(Set usernames) { - return roleBinding -> roleBinding.getMetadata().getDeletionTimestamp() == null - && roleBinding.getSubjects().stream() - .anyMatch(subject -> "User".equals(subject.getKind()) - && usernames.contains(subject.getName())); - } } diff --git a/src/main/java/run/halo/app/extension/ExtensionOperator.java b/src/main/java/run/halo/app/extension/ExtensionOperator.java index 343f9d3ab..49005dcc1 100644 --- a/src/main/java/run/halo/app/extension/ExtensionOperator.java +++ b/src/main/java/run/halo/app/extension/ExtensionOperator.java @@ -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 Predicate isNotDeleted() { + return ext -> ext.getMetadata().getDeletionTimestamp() == null; + } } diff --git a/src/test/java/run/halo/app/core/extension/RoleBindingTest.java b/src/test/java/run/halo/app/core/extension/RoleBindingTest.java new file mode 100644 index 000000000..1eca22462 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/RoleBindingTest.java @@ -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)); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 85672f00c..7999c6b58 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -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)); + } + + } + } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java index b82c08b22..0ccf1ea4c 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java @@ -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); diff --git a/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java b/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java new file mode 100644 index 000000000..08534e89e --- /dev/null +++ b/src/test/java/run/halo/app/extension/ExtensionOperatorTest.java @@ -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)); + } +} \ No newline at end of file