From 2afe19f1b906db1886e722efb40e2f3259e28b39 Mon Sep 17 00:00:00 2001 From: John Niang Date: Thu, 7 Jul 2022 11:36:10 +0800 Subject: [PATCH] Add rolebinding reconciler to reset role names of users (#2217) * Add rolebinding reconciler to reset role names of users Signed-off-by: johnniang * Add @Bean annotations on roleBindingController * Fix errors complained by unneccesary catch Signed-off-by: johnniang --- .../app/config/ExtensionConfiguration.java | 10 + .../reconciler/RoleBindingReconciler.java | 84 ++++++++ .../reconciler/RoleBindingReconcilerTest.java | 192 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java create mode 100644 src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 471e46fd6..73e9f322e 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -6,7 +6,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; 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.reconciler.RoleBindingReconciler; import run.halo.app.core.extension.reconciler.RoleReconciler; import run.halo.app.core.extension.reconciler.UserReconciler; import run.halo.app.extension.DefaultExtensionClient; @@ -62,4 +64,12 @@ public class ExtensionConfiguration { .extension(new Role()) .build(); } + + @Bean + Controller roleBindingController(ExtensionClient client) { + return new ControllerBuilder("role-binding-controller", client) + .reconciler(new RoleBindingReconciler(client)) + .extension(new RoleBinding()) + .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 new file mode 100644 index 000000000..67fefeb89 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/reconciler/RoleBindingReconciler.java @@ -0,0 +1,84 @@ +package run.halo.app.core.extension.reconciler; + +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.RoleBinding; +import run.halo.app.core.extension.RoleBinding.Subject; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.infra.utils.JsonUtils; + +@Slf4j +public class RoleBindingReconciler implements Reconciler { + + static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names"; + + private final ExtensionClient client; + + public RoleBindingReconciler(ExtensionClient client) { + this.client = client; + } + + @Override + public Result reconcile(Request request) { + client.fetch(RoleBinding.class, request.name()).ifPresent(roleBinding -> { + // get all usernames; + var usernames = roleBinding.getSubjects().stream() + .filter(subject -> "User".equals(subject.getKind())) + .map(Subject::getName) + .collect(Collectors.toSet()); + + // get all role-bindings lazily + var bindings = + Lazy.of(() -> client.list(RoleBinding.class, containsUser(usernames), null)); + + usernames.forEach(username -> { + var roleNames = bindings.get().stream() + .filter(containsUser(username)) + .map(RoleBinding::getRoleRef) + .filter(roleRef -> Objects.equals(roleRef.getKind(), "Role")) + .map(RoleBinding.RoleRef::getName) + .sorted() + // we have to use LinkedHashSet below to make sure the sorted above functional + .collect(Collectors.toCollection(LinkedHashSet::new)); + // we should update the role names even if the role names are empty + client.fetch(User.class, username).ifPresent(user -> { + var annotations = user.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + } + var oldAnnotations = Map.copyOf(annotations); + annotations.put(ROLE_NAMES_ANNO, JsonUtils.objectToJson(roleNames)); + user.getMetadata().setAnnotations(annotations); + if (!Objects.deepEquals(oldAnnotations, annotations)) { + // update user + client.update(user); + } + }); + }); + }); + 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/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java new file mode 100644 index 000000000..b82c08b22 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/reconciler/RoleBindingReconcilerTest.java @@ -0,0 +1,192 @@ +package run.halo.app.core.extension.reconciler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.lenient; +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 static run.halo.app.core.extension.reconciler.RoleBindingReconciler.ROLE_NAMES_ANNO; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.extension.RoleBinding.RoleRef; +import run.halo.app.core.extension.RoleBinding.Subject; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.controller.Reconciler.Result; + +@ExtendWith(MockitoExtension.class) +class RoleBindingReconcilerTest { + + @Mock + ExtensionClient client; + + @InjectMocks + RoleBindingReconciler reconciler; + + final Result doNotReEnQueue = new Result(false, null); + + @Test + void shouldDoNothingIfRequestNotFound() { + var bindingName = "fake-binding-name"; + when(client.fetch(RoleBinding.class, bindingName)) + .thenReturn(Optional.empty()); + + var result = reconciler.reconcile(new Reconciler.Request(bindingName)); + assertEquals(doNotReEnQueue, result); + + verify(client, times(1)).fetch(same(RoleBinding.class), anyString()); + verify(client, never()).list(same(RoleBinding.class), any(), any()); + verify(client, never()).fetch(same(User.class), anyString()); + verify(client, never()).update(isA(User.class)); + } + + @Test + void shouldDoNothingIfNotContainAnyUserSubject() { + var bindingName = "fake-binding-name"; + var binding = mock(RoleBinding.class); + + when(client.fetch(RoleBinding.class, bindingName)) + .thenReturn(Optional.of(binding)); + when(binding.getSubjects()).thenReturn(List.of()); + + var result = reconciler.reconcile(new Reconciler.Request(bindingName)); + assertEquals(doNotReEnQueue, result); + + verify(client, times(1)).fetch(same(RoleBinding.class), anyString()); + verify(client, never()).list(same(RoleBinding.class), any(), any()); + verify(client, never()).fetch(same(User.class), anyString()); + verify(client, never()).update(isA(User.class)); + } + + @Test + void shouldDoNothingIfUserNotFound() { + var bindingName = "fake-binding-name"; + var userName = "fake-user-name"; + var binding = mock(RoleBinding.class); + var subject = mock(Subject.class); + + when(client.fetch(RoleBinding.class, bindingName)) + .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.empty()); + + var result = reconciler.reconcile(new Reconciler.Request(bindingName)); + assertEquals(doNotReEnQueue, result); + + verify(client, times(1)).fetch(same(RoleBinding.class), anyString()); + verify(client, times(1)).list(same(RoleBinding.class), any(), any()); + verify(client, times(1)).fetch(same(User.class), eq(userName)); + + verify(client, never()).update(isA(User.class)); + } + + @Test + void shouldUpdateRoleNamesIfNoBindingRelatedTheUser() { + var bindingName = "fake-binding-name"; + var userName = "fake-user-name"; + var subject = mock(Subject.class); + var binding = createRoleBinding("Role", "fake-role", false, subject); + var user = mock(User.class); + var userMetadata = mock(Metadata.class); + + when(client.fetch(RoleBinding.class, bindingName)) + .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); + when(client.list(same(RoleBinding.class), any(), any())).thenReturn(List.of()); + + var result = reconciler.reconcile(new Reconciler.Request(bindingName)); + assertEquals(doNotReEnQueue, result); + + verify(client, times(1)).fetch(same(RoleBinding.class), anyString()); + verify(client, times(1)).list(same(RoleBinding.class), any(), any()); + verify(client, times(1)).fetch(same(User.class), anyString()); + verify(client, times(1)).update(isA(User.class)); + + verify(userMetadata).setAnnotations(argThat(annotation -> { + String roleNames = annotation.get(ROLE_NAMES_ANNO); + return roleNames != null && roleNames.equals("[]"); + })); + } + + @Test + void shouldUpdateRoleNames() { + var bindingName = "fake-binding-name"; + var userName = "fake-user-name"; + var subject = mock(Subject.class); + var user = mock(User.class); + var userMetadata = mock(Metadata.class); + var binding = createRoleBinding("Role", "fake-role", false, subject); + + when(client.fetch(RoleBinding.class, bindingName)) + .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( + createRoleBinding("Role", "fake-role-01", false, subject), + createRoleBinding("Role", "fake-role-03", false, subject), + createRoleBinding("Role", "fake-role-02", false, subject), + createRoleBinding("NotRole", "fake-role-04", false, subject), + createRoleBinding("Role", "fake-role-05", true, subject) + ); + when(client.list(same(RoleBinding.class), any(), any())).thenReturn(bindings); + + var result = reconciler.reconcile(new Reconciler.Request(bindingName)); + assertEquals(doNotReEnQueue, result); + + verify(client, times(1)).fetch(same(RoleBinding.class), anyString()); + verify(client, times(1)).list(same(RoleBinding.class), any(), any()); + verify(client, times(1)).fetch(same(User.class), anyString()); + verify(client, times(1)).update(isA(User.class)); + + verify(userMetadata).setAnnotations(argThat(annotation -> { + var roleNames = annotation.get(ROLE_NAMES_ANNO); + return roleNames != null && roleNames.equals(""" + ["fake-role-01","fake-role-02","fake-role-03"]"""); + })); + } + + RoleBinding createRoleBinding(String roleRefKind, + String roleRefName, + boolean deleting, + Subject subject) { + var binding = mock(RoleBinding.class); + var roleRef = mock(RoleRef.class); + var metadata = mock(Metadata.class); + lenient().when(roleRef.getKind()).thenReturn(roleRefKind); + lenient().when(roleRef.getName()).thenReturn(roleRefName); + lenient().when(metadata.getDeletionTimestamp()).thenReturn(deleting ? Instant.now() : null); + lenient().when(binding.getRoleRef()).thenReturn(roleRef); + lenient().when(binding.getMetadata()).thenReturn(metadata); + lenient().when(binding.getSubjects()).thenReturn(List.of(subject)); + return binding; + } + +}