Fix the problem that roles could not be granted sometimes (#6471)

#### What type of PR is this?

/kind improvement
/area core

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

This PR refactors searching roles by using index mechanism to speed up every request and fix the problem of not being able to grant roles to users sometimes.

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

Fixes #5807 
Fixes https://github.com/halo-dev/halo/issues/4954
Fixes https://github.com/halo-dev/halo/issues/5057

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

```release-note
修复有时无法给用户赋权限的问题
```
pull/6486/head
John Niang 2024-08-21 11:22:50 +08:00 committed by GitHub
parent 7ba5fc671f
commit 3a782be607
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 590 additions and 947 deletions

View File

@ -22881,14 +22881,12 @@
}
},
"roles": {
"uniqueItems": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/Role"
}
},
"uiPermissions": {
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"

View File

@ -6236,14 +6236,12 @@
}
},
"roles": {
"uniqueItems": true,
"type": "array",
"items": {
"$ref": "#/components/schemas/Role"
}
},
"uiPermissions": {
"uniqueItems": true,
"type": "array",
"items": {
"type": "string"

View File

@ -13,6 +13,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.ToString;
import org.springframework.util.StringUtils;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.ExtensionOperator;
import run.halo.app.extension.GVK;
@ -118,6 +119,14 @@ public class RoleBinding extends AbstractExtension {
&& User.GROUP.equals(subject.apiGroup)
&& usernames.contains(subject.getName());
}
@Override
public String toString() {
if (StringUtils.hasText(apiGroup)) {
return apiGroup + "/" + kind + "/" + name;
}
return kind + "/" + name;
}
}
public static RoleBinding create(String username, String roleName) {

View File

@ -46,6 +46,8 @@ public class User extends AbstractExtension {
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
public static final String REQUEST_TO_UPDATE = "halo.run/request-to-update";
@Schema(requiredMode = REQUIRED)
private UserSpec spec = new UserSpec();

View File

@ -3,6 +3,9 @@ package run.halo.app.extension;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import org.springframework.data.domain.Sort;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.index.query.QueryFactory;
public enum ExtensionUtil {
;
@ -34,4 +37,25 @@ public enum ExtensionUtil {
return removed;
}
/**
* Query for not deleting.
*
* @return Query
*/
public static Query notDeleting() {
return QueryFactory.isNull("metadata.deletionTimestamp");
}
/**
* Default sort by creation timestamp desc and name asc.
*
* @return Sort
*/
public static Sort defaultSort() {
return Sort.by(
Sort.Order.desc("metadata.creationTimestamp"),
Sort.Order.asc("metadata.name")
);
}
}

View File

@ -10,9 +10,9 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuil
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
import static run.halo.app.extension.ListResult.generateGenericClass;
import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.contains;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.in;
import static run.halo.app.extension.index.query.QueryFactory.or;
import static run.halo.app.extension.router.QueryParamBuildUtil.sortParameter;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions;
@ -28,13 +28,15 @@ import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.security.Principal;
import java.time.Duration;
import java.util.Collections;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
@ -83,8 +85,6 @@ import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ValidationUtils;
@ -457,11 +457,14 @@ public class UserEndpoint implements CustomEndpoint {
private Mono<ServerResponse> getUserByName(ServerRequest request) {
final var name = request.pathVariable("name");
return userService.getUser(name)
.flatMap(this::toDetailedUser)
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user)
);
.flatMap(user -> roleService.getRolesByUsername(name)
.collectList()
.flatMap(roleNames -> roleService.list(new HashSet<>(roleNames), true)
.collectList()
.map(roles -> new DetailedUser(user, roles))
)
)
.flatMap(detailedUser -> ServerResponse.ok().bodyValue(detailedUser));
}
record CreateUserRequest(@Schema(requiredMode = REQUIRED) String name,
@ -600,33 +603,16 @@ public class UserEndpoint implements CustomEndpoint {
Mono<ServerResponse> me(ServerRequest request) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.filter(obj -> !(obj instanceof TwoFactorAuthentication))
.map(Authentication::getName)
.defaultIfEmpty(AnonymousUserConst.PRINCIPAL)
.flatMap(userService::getUser)
.flatMap(this::toDetailedUser)
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user));
}
private Mono<DetailedUser> toDetailedUser(User user) {
Set<String> roleNames = roleNames(user);
return roleService.list(roleNames)
.collectList()
.map(roles -> new DetailedUser(user, roles))
.defaultIfEmpty(new DetailedUser(user, List.of()));
}
Set<String> roleNames(User user) {
Assert.notNull(user, "User must not be null");
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
String roleNamesJson = annotations.get(User.ROLE_NAMES_ANNO);
if (StringUtils.isBlank(roleNamesJson)) {
return Set.of();
}
return JsonUtils.jsonToObject(roleNamesJson, new TypeReference<>() {
});
.filter(auth -> !(auth instanceof TwoFactorAuthentication))
.flatMap(auth -> userService.getUser(auth.getName())
.flatMap(user -> {
var roleNames = authoritiesToRoles(auth.getAuthorities());
return roleService.list(roleNames, true)
.collectList()
.map(roles -> new DetailedUser(user, roles));
})
)
.flatMap(detailedUser -> ServerResponse.ok().bodyValue(detailedUser));
}
record DetailedUser(@Schema(requiredMode = REQUIRED) User user,
@ -649,85 +635,55 @@ public class UserEndpoint implements CustomEndpoint {
@NonNull
private Mono<ServerResponse> getUserPermission(ServerRequest request) {
var name = request.pathVariable("name");
Mono<UserPermission> userPermission;
if (SELF_USER.equals(name)) {
userPermission = ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> {
var roleNames = authoritiesToRoles(auth.getAuthorities());
var up = new UserPermission();
var roles = roleService.list(roleNames)
.collect(Collectors.toSet())
.doOnNext(up::setRoles)
.then();
var permissions = roleService.listPermissions(roleNames)
.distinct()
.collectList()
.doOnNext(up::setPermissions)
.doOnNext(perms -> {
var uiPermissions = uiPermissions(new HashSet<>(perms));
up.setUiPermissions(uiPermissions);
})
.then();
return roles.and(permissions).thenReturn(up);
var username = request.pathVariable("name");
return Mono.defer(() -> {
if (SELF_USER.equals(username)) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(auth -> authoritiesToRoles(auth.getAuthorities()));
}
return roleService.getRolesByUsername(username)
.collect(Collectors.toCollection(LinkedHashSet::new));
}).flatMap(roleNames -> {
var up = new UserPermission();
var setRoles = roleService.list(roleNames, true)
.distinct()
.collectSortedList()
.doOnNext(up::setRoles);
var setPerms = roleService.listPermissions(roleNames)
.distinct()
.collectSortedList()
.doOnNext(permissions -> {
up.setPermissions(permissions);
up.setUiPermissions(uiPermissions(permissions));
});
} else {
// get roles from username
userPermission = userService.listRoles(name)
.collect(Collectors.toSet())
.flatMap(roles -> {
var up = new UserPermission();
var setRoles = Mono.fromRunnable(() -> up.setRoles(roles)).then();
var roleNames = roles.stream()
.map(role -> role.getMetadata().getName())
.collect(Collectors.toSet());
var setPermissions = roleService.listPermissions(roleNames)
.distinct()
.collectList()
.doOnNext(up::setPermissions)
.doOnNext(perms -> {
var uiPermissions = uiPermissions(new HashSet<>(perms));
up.setUiPermissions(uiPermissions);
})
.then();
return setRoles.and(setPermissions).thenReturn(up);
});
}
return ServerResponse.ok().body(userPermission, UserPermission.class);
return Mono.when(setRoles, setPerms).thenReturn(up);
}).flatMap(userPermission -> ServerResponse.ok().bodyValue(userPermission));
}
private Set<String> uiPermissions(Set<Role> roles) {
private List<String> uiPermissions(Collection<Role> roles) {
if (CollectionUtils.isEmpty(roles)) {
return Collections.emptySet();
return List.of();
}
return roles.stream()
.<Set<String>>map(role -> {
var annotations = role.getMetadata().getAnnotations();
if (annotations == null) {
return Set.of();
}
var uiPermissionsJson = annotations.get(Role.UI_PERMISSIONS_ANNO);
if (StringUtils.isBlank(uiPermissionsJson)) {
return Set.of();
}
return JsonUtils.jsonToObject(uiPermissionsJson,
new TypeReference<LinkedHashSet<String>>() {
});
})
.flatMap(Set::stream)
.collect(Collectors.toSet());
var uiPerms = new LinkedList<String>();
roles.forEach(role -> Optional.ofNullable(role.getMetadata().getAnnotations())
.map(annotations -> annotations.get(Role.UI_PERMISSIONS_ANNO))
.filter(StringUtils::isNotBlank)
.map(json -> JsonUtils.jsonToObject(json, new TypeReference<Set<String>>() {
}))
.ifPresent(uiPerms::addAll)
);
return uiPerms.stream().distinct().sorted().toList();
}
@Data
public static class UserPermission {
@Schema(requiredMode = REQUIRED)
private Set<Role> roles;
private List<Role> roles;
@Schema(requiredMode = REQUIRED)
private List<Role> permissions;
@Schema(requiredMode = REQUIRED)
private Set<String> uiPermissions;
private List<String> uiPermissions;
}
@ -767,29 +723,23 @@ public class UserEndpoint implements CustomEndpoint {
* Converts query parameters to list options.
*/
public ListOptions toListOptions() {
var listOptions =
var defaultListOptions =
labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector());
var fieldQuery = listOptions.getFieldSelector().query();
if (StringUtils.isNotBlank(getKeyword())) {
fieldQuery = and(
fieldQuery,
or(
contains("spec.displayName", getKeyword()),
equal("metadata.name", getKeyword())
)
);
}
var builder = ListOptions.builder(defaultListOptions);
if (StringUtils.isNotBlank(getRole())) {
fieldQuery = and(
fieldQuery,
equal(User.USER_RELATED_ROLES_INDEX, getRole())
);
}
Optional.ofNullable(getKeyword())
.filter(StringUtils::isNotBlank)
.ifPresent(keyword -> builder.andQuery(or(
contains("spec.displayName", keyword),
equal("metadata.name", keyword)
)));
listOptions.setFieldSelector(FieldSelector.of(fieldQuery));
return listOptions;
Optional.ofNullable(getRole())
.filter(StringUtils::isNotBlank)
.ifPresent(role -> builder.andQuery(in(User.USER_RELATED_ROLES_INDEX, role)));
return builder.build();
}
public static void buildParameters(Builder builder) {
@ -829,17 +779,28 @@ public class UserEndpoint implements CustomEndpoint {
}
private Mono<ListResult<ListedUser>> toListedUser(ListResult<User> listResult) {
return Flux.fromStream(listResult.get())
.concatMap(user -> {
Set<String> roleNames = roleNames(user);
return roleService.list(roleNames)
.collectList()
.map(roles -> new ListedUser(user, roles))
.defaultIfEmpty(new ListedUser(user, List.of()));
})
.collectList()
.map(items -> convertFrom(listResult, items))
.defaultIfEmpty(convertFrom(listResult, List.of()));
var usernames = listResult.getItems().stream()
.map(user -> user.getMetadata().getName())
.collect(Collectors.toList());
return roleService.getRolesByUsernames(usernames)
.flatMap(usernameRolesMap -> {
var allRoleNames = new HashSet<String>();
usernameRolesMap.values().forEach(allRoleNames::addAll);
return roleService.list(allRoleNames)
.collectMap(role -> role.getMetadata().getName())
.map(roleMap -> {
var listedUsers = listResult.getItems().stream()
.map(user -> {
var username = user.getMetadata().getName();
var roles = Optional.ofNullable(usernameRolesMap.get(username))
.map(roleNames -> roleNames.stream().map(roleMap::get).toList())
.orElseGet(List::of);
return new ListedUser(user, roles);
})
.toList();
return convertFrom(listResult, listedUsers);
});
});
}
<T> ListResult<T> convertFrom(ListResult<?> listResult, List<T> items) {

View File

@ -1,82 +0,0 @@
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.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.util.Lazy;
import org.springframework.stereotype.Component;
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;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.infra.utils.JsonUtils;
@Slf4j
@Component
public class RoleBindingReconciler implements Reconciler<Request> {
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.KIND.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.KIND))
.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(User.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);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new RoleBinding())
.build();
}
}

View File

@ -1,20 +1,24 @@
package run.halo.app.core.extension.reconciler;
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.defaultSort;
import static run.halo.app.extension.ExtensionUtil.isDeleted;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.net.URI;
import java.util.HashSet;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import org.springframework.util.CollectionUtils;
import org.springframework.web.util.UriComponentsBuilder;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.core.extension.attachment.Attachment;
@ -22,8 +26,7 @@ import run.halo.app.core.extension.service.AttachmentService;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupKind;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
@ -32,7 +35,6 @@ import run.halo.app.extension.controller.RequeueException;
import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.ExternalUrlSupplier;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.infra.utils.PathUtils;
@Slf4j
@Component
@ -43,26 +45,21 @@ public class UserReconciler implements Reconciler<Request> {
private final ExternalUrlSupplier externalUrlSupplier;
private final RoleService roleService;
private final AttachmentService attachmentService;
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(20)
.fixedBackoff(300)
.retryOn(IllegalStateException.class)
.build();
private final UserService userService;
@Override
public Result reconcile(Request request) {
client.fetch(User.class, request.name()).ifPresent(user -> {
if (user.getMetadata().getDeletionTimestamp() != null) {
cleanUpResourcesAndRemoveFinalizer(request.name());
if (isDeleted(user)) {
deleteUserConnections(request.name());
removeFinalizers(user.getMetadata(), Set.of(FINALIZER_NAME));
client.update(user);
return;
}
addFinalizerIfNecessary(user);
ensureRoleNamesAnno(request.name());
updatePermalink(request.name());
handleAvatar(request.name());
addFinalizers(user.getMetadata(), Set.of(FINALIZER_NAME));
ensureRoleNamesAnno(user);
updatePermalink(user);
handleAvatar(user);
checkVerifiedEmail(user);
client.update(user);
});
@ -92,147 +89,102 @@ public class UserReconciler implements Reconciler<Request> {
.orElse(false);
}
private void handleAvatar(String name) {
client.fetch(User.class, name).ifPresent(user -> {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
private void handleAvatar(User user) {
var annotations = Optional.ofNullable(user.getMetadata().getAnnotations())
.orElseGet(HashMap::new);
user.getMetadata().setAnnotations(annotations);
String avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO);
String oldAvatarAttachmentName = annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
var avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO);
var oldAvatarAttachmentName =
annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
// remove old avatar if needed
if (StringUtils.isNotBlank(oldAvatarAttachmentName)
&& !StringUtils.equals(avatarAttachmentName, oldAvatarAttachmentName)) {
client.fetch(Attachment.class, oldAvatarAttachmentName)
.ifPresent(client::delete);
annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
}
if (StringUtils.isNotBlank(oldAvatarAttachmentName)
&& !StringUtils.equals(oldAvatarAttachmentName, avatarAttachmentName)) {
client.fetch(Attachment.class, oldAvatarAttachmentName)
.ifPresent(client::delete);
annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
oldAvatarAttachmentName = null;
var spec = user.getSpec();
if (StringUtils.isBlank(avatarAttachmentName)) {
if (StringUtils.isNotBlank(spec.getAvatar())) {
log.info("Remove avatar for user({})", user.getMetadata().getName());
}
if (StringUtils.isNotBlank(avatarAttachmentName)) {
client.fetch(Attachment.class, avatarAttachmentName)
.ifPresent(attachment -> {
URI avatarUri = attachmentService.getPermalink(attachment).block();
if (avatarUri == null) {
log.warn("Failed to get avatar permalink for user [{}] with attachment "
+ "[{}], re-enqueuing...", name, avatarAttachmentName);
throw new RequeueException(new Result(true, null),
"Failed to get avatar permalink.");
}
user.getSpec().setAvatar(avatarUri.toString());
});
} else if (StringUtils.isNotBlank(oldAvatarAttachmentName)) {
user.getSpec().setAvatar(null);
}
if (StringUtils.isBlank(oldAvatarAttachmentName)
&& StringUtils.isNotBlank(avatarAttachmentName)) {
annotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, avatarAttachmentName);
}
client.update(user);
});
}
private void ensureRoleNamesAnno(String name) {
client.fetch(User.class, name).ifPresent(user -> {
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
Map<String, String> oldAnnotations = Map.copyOf(annotations);
List<String> roleNames = listRoleNamesRef(name);
annotations.put(User.ROLE_NAMES_ANNO, JsonUtils.objectToJson(roleNames));
if (!oldAnnotations.equals(annotations)) {
client.update(user);
}
});
}
List<String> listRoleNamesRef(String username) {
var subject = new RoleBinding.Subject(KIND, username, GROUP);
return roleService.listRoleRefs(subject)
.filter(this::isRoleRef)
.map(RoleBinding.RoleRef::getName)
.distinct()
.collectList()
.blockOptional()
.orElse(List.of());
}
private boolean isRoleRef(RoleBinding.RoleRef roleRef) {
var roleGvk = new Role().groupVersionKind();
var gk = new GroupKind(roleRef.getApiGroup(), roleRef.getKind());
return gk.equals(roleGvk.groupKind());
}
private void updatePermalink(String name) {
client.fetch(User.class, name).ifPresent(user -> {
if (AnonymousUserConst.isAnonymousUser(name)) {
// anonymous user is not allowed to have permalink
return;
}
if (user.getStatus() == null) {
user.setStatus(new User.UserStatus());
}
User.UserStatus status = user.getStatus();
String oldPermalink = status.getPermalink();
status.setPermalink(getUserPermalink(user));
if (!StringUtils.equals(oldPermalink, status.getPermalink())) {
client.update(user);
}
});
}
private String getUserPermalink(User user) {
return externalUrlSupplier.get()
.resolve(PathUtils.combinePath("authors", user.getMetadata().getName()))
.normalize().toString();
}
private void addFinalizerIfNecessary(User oldUser) {
Set<String> finalizers = oldUser.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
spec.setAvatar(null);
return;
}
client.fetch(User.class, oldUser.getMetadata().getName())
.ifPresent(user -> {
Set<String> newFinalizers = user.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
user.getMetadata().setFinalizers(newFinalizers);
client.fetch(Attachment.class, avatarAttachmentName)
.flatMap(attachment -> attachmentService.getPermalink(attachment)
.blockOptional(Duration.ofMinutes(1))
)
.map(URI::toString)
.ifPresentOrElse(avatar -> {
if (!Objects.equals(avatar, spec.getAvatar())) {
log.info(
"Update avatar for user({}) to {}",
user.getMetadata().getName(), avatar
);
}
newFinalizers.add(FINALIZER_NAME);
client.update(user);
spec.setAvatar(avatar);
// reset last avatar
annotations.put(
User.LAST_AVATAR_ATTACHMENT_NAME_ANNO,
avatarAttachmentName
);
}, () -> {
throw new RequeueException(
new Result(true, null),
"Avatar permalink(%s) is not available yet."
.formatted(avatarAttachmentName)
);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String userName) {
client.fetch(User.class, userName).ifPresent(user -> {
// wait for dependent resources to be deleted
deleteUserConnections(userName);
// remove finalizer
if (user.getMetadata().getFinalizers() != null) {
user.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(user);
});
private void ensureRoleNamesAnno(User user) {
roleService.getRolesByUsername(user.getMetadata().getName())
.collectList()
.map(JsonUtils::objectToJson)
.doOnNext(roleNamesJson -> {
var annotations = Optional.ofNullable(user.getMetadata().getAnnotations())
.orElseGet(HashMap::new);
user.getMetadata().setAnnotations(annotations);
annotations.put(User.ROLE_NAMES_ANNO, roleNamesJson);
})
.block(Duration.ofMinutes(1));
}
void deleteUserConnections(String userName) {
listConnectionsByUsername(userName).forEach(client::delete);
// wait for user connection to be deleted
retryTemplate.execute(callback -> {
if (listConnectionsByUsername(userName).size() > 0) {
throw new IllegalStateException("User connection is not deleted yet");
}
return null;
});
private void updatePermalink(User user) {
var name = user.getMetadata().getName();
if (AnonymousUserConst.isAnonymousUser(name)) {
// anonymous user is not allowed to have permalink
return;
}
var status = Optional.ofNullable(user.getStatus())
.orElseGet(User.UserStatus::new);
user.setStatus(status);
status.setPermalink(getUserPermalink(user));
}
private String getUserPermalink(User user) {
return UriComponentsBuilder.fromUri(externalUrlSupplier.get())
.pathSegment("authors", user.getMetadata().getName())
.toUriString();
}
void deleteUserConnections(String username) {
var userConnections = listConnectionsByUsername(username);
if (CollectionUtils.isEmpty(userConnections)) {
return;
}
userConnections.forEach(client::delete);
throw new RequeueException(new Result(true, null), "User connections are not deleted yet");
}
List<UserConnection> listConnectionsByUsername(String username) {
return client.list(UserConnection.class,
connection -> connection.getSpec().getUsername().equals(username), null);
var listOptions = ListOptions.builder()
.andQuery(equal("spec.username", username))
.build();
return client.listAll(UserConnection.class, listOptions, defaultSort());
}
@Override

View File

@ -1,13 +1,17 @@
package run.halo.app.core.extension.service;
import static run.halo.app.extension.Comparators.compareCreationTimestamp;
import static run.halo.app.extension.ExtensionUtil.defaultSort;
import static run.halo.app.extension.ExtensionUtil.notDeleting;
import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@ -22,8 +26,12 @@ import run.halo.app.core.extension.Role;
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.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.security.SuperAdminInitializer;
@ -35,18 +43,58 @@ import run.halo.app.security.SuperAdminInitializer;
@Service
public class DefaultRoleService implements RoleService {
private final ReactiveExtensionClient extensionClient;
private final ReactiveExtensionClient client;
public DefaultRoleService(ReactiveExtensionClient extensionClient) {
this.extensionClient = extensionClient;
public DefaultRoleService(ReactiveExtensionClient client) {
this.client = client;
}
private Flux<RoleRef> listRoleRefs(Subject subject) {
return listRoleBindings(subject).map(RoleBinding::getRoleRef);
}
@Override
public Flux<RoleRef> listRoleRefs(Subject subject) {
return extensionClient.list(RoleBinding.class,
binding -> binding.getSubjects().contains(subject),
null)
.map(RoleBinding::getRoleRef);
public Flux<RoleBinding> listRoleBindings(Subject subject) {
var listOptions = ListOptions.builder()
.andQuery(notDeleting())
.andQuery(QueryFactory.in("subjects", subject.toString()))
.build();
return client.listAll(RoleBinding.class, listOptions, defaultSort());
}
@Override
public Flux<String> getRolesByUsername(String username) {
return listRoleRefs(toUserSubject(username))
.filter(DefaultRoleService::isRoleKind)
.map(RoleRef::getName);
}
@Override
public Mono<Map<String, Collection<String>>> getRolesByUsernames(Collection<String> usernames) {
if (CollectionUtils.isEmpty(usernames)) {
return Mono.empty();
}
var subjects = usernames.stream().map(DefaultRoleService::toUserSubject)
.map(Object::toString)
.collect(Collectors.toSet());
var listOptions = ListOptions.builder()
.andQuery(notDeleting())
.andQuery(QueryFactory.in("subjects", subjects))
.build();
return client.listAll(RoleBinding.class, listOptions, defaultSort())
.collect(HashMap::new, (map, roleBinding) -> {
for (Subject subject : roleBinding.getSubjects()) {
if (subjects.contains(subject.toString())) {
var username = subject.getName();
var roleRef = roleBinding.getRoleRef();
if (isRoleKind(roleRef)) {
var roleName = roleRef.getName();
map.computeIfAbsent(username, k -> new HashSet<>()).add(roleName);
}
}
}
});
}
@Override
@ -54,7 +102,7 @@ public class DefaultRoleService implements RoleService {
if (source.contains(SuperAdminInitializer.SUPER_ROLE_NAME)) {
return Mono.just(true);
}
return listDependencies(new HashSet<>(source), shouldFilterHidden(false))
return listWithDependencies(new HashSet<>(source), shouldExcludeHidden(false))
.map(role -> role.getMetadata().getName())
.collect(Collectors.toSet())
.map(roleNames -> roleNames.containsAll(candidates));
@ -64,55 +112,58 @@ public class DefaultRoleService implements RoleService {
public Flux<Role> listPermissions(Set<String> names) {
if (containsSuperRole(names)) {
// search all permissions
return extensionClient.list(Role.class,
shouldFilterHidden(true),
compareCreationTimestamp(true));
return client.listAll(Role.class,
shouldExcludeHidden(true),
ExtensionUtil.defaultSort());
}
return listDependencies(names, shouldFilterHidden(true));
return listWithDependencies(names, shouldExcludeHidden(true));
}
@Override
public Flux<Role> listDependenciesFlux(Set<String> names) {
return listDependencies(names, shouldFilterHidden(false));
return listWithDependencies(names, shouldExcludeHidden(false));
}
private Flux<Role> listRoles(Set<String> names, Predicate<Role> additionalPredicate) {
private static boolean isRoleKind(RoleRef roleRef) {
return Role.GROUP.equals(roleRef.getApiGroup()) && Role.KIND.equals(roleRef.getKind());
}
private static Subject toUserSubject(String username) {
var subject = new Subject();
subject.setApiGroup(User.GROUP);
subject.setKind(User.KIND);
subject.setName(username);
return subject;
}
private Flux<Role> listRoles(Set<String> names, ListOptions additionalListOptions) {
if (CollectionUtils.isEmpty(names)) {
return Flux.empty();
}
Predicate<Role> predicate = role -> names.contains(role.getMetadata().getName());
if (additionalPredicate != null) {
predicate = predicate.and(additionalPredicate);
}
return extensionClient.list(Role.class, predicate, compareCreationTimestamp(true));
var listOptions = Optional.ofNullable(additionalListOptions)
.map(ListOptions::builder)
.orElseGet(ListOptions::builder)
.andQuery(notDeleting())
.andQuery(QueryFactory.in("metadata.name", names))
.build();
return client.listAll(Role.class, listOptions, ExtensionUtil.defaultSort());
}
private static Predicate<Role> shouldFilterHidden(boolean filterHidden) {
if (!filterHidden) {
return r -> true;
private static ListOptions shouldExcludeHidden(boolean excludeHidden) {
if (!excludeHidden) {
return null;
}
return role -> {
var labels = role.getMetadata().getLabels();
if (labels == null) {
return true;
}
var hiddenValue = labels.get(Role.HIDDEN_LABEL_NAME);
return !Boolean.parseBoolean(hiddenValue);
};
return ListOptions.builder().labelSelector()
.notEq(Role.HIDDEN_LABEL_NAME, Boolean.TRUE.toString())
.end()
.build();
}
private static boolean isRoleTemplate(Role role) {
var labels = role.getMetadata().getLabels();
if (labels == null) {
return false;
}
return Boolean.parseBoolean(labels.get(Role.TEMPLATE_LABEL_NAME));
}
private Flux<Role> listDependencies(Set<String> names, Predicate<Role> additionalPredicate) {
private Flux<Role> listWithDependencies(Set<String> names, ListOptions additionalListOptions) {
var visited = new HashSet<String>();
return listRoles(names, additionalPredicate)
return listRoles(names, additionalListOptions)
.expand(role -> {
var name = role.getMetadata().getName();
if (visited.contains(name)) {
@ -128,29 +179,26 @@ public class DefaultRoleService implements RoleService {
return Flux.fromIterable(dependencies)
.filter(dep -> !visited.contains(dep))
.collect(Collectors.<String>toSet())
.flatMapMany(deps -> listRoles(deps, additionalPredicate));
.collect(Collectors.toSet())
.flatMapMany(deps -> listRoles(deps, additionalListOptions));
})
.concatWith(Flux.defer(() -> listAggregatedRoles(visited, additionalPredicate)));
.concatWith(Flux.defer(() -> listAggregatedRoles(visited, additionalListOptions)));
}
private Flux<Role> listAggregatedRoles(Set<String> roleNames,
Predicate<Role> additionalPredicate) {
var aggregatedLabelNames = roleNames.stream()
.map(roleName -> Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName)
.collect(Collectors.toSet());
Predicate<Role> predicate = role -> {
var labels = role.getMetadata().getLabels();
if (labels == null) {
return false;
}
return aggregatedLabelNames.stream()
.anyMatch(aggregatedLabel -> Boolean.parseBoolean(labels.get(aggregatedLabel)));
};
if (additionalPredicate != null) {
predicate = predicate.and(additionalPredicate);
ListOptions additionalListOptions) {
if (CollectionUtils.isEmpty(roleNames)) {
return Flux.empty();
}
return extensionClient.list(Role.class, predicate, compareCreationTimestamp(true));
var listOptionsBuilder = Optional.ofNullable(additionalListOptions)
.map(ListOptions::builder)
.orElseGet(ListOptions::builder);
roleNames.stream()
.map(roleName -> Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName)
.forEach(
label -> listOptionsBuilder.labelSelector().eq(label, Boolean.TRUE.toString())
);
return client.listAll(Role.class, listOptionsBuilder.build(), ExtensionUtil.defaultSort());
}
Predicate<RoleBinding> getRoleBindingPredicate(Subject targetSubject) {
@ -175,11 +223,21 @@ public class DefaultRoleService implements RoleService {
@Override
public Flux<Role> list(Set<String> roleNames) {
return list(roleNames, false);
}
@Override
public Flux<Role> list(Set<String> roleNames, boolean excludeHidden) {
if (CollectionUtils.isEmpty(roleNames)) {
return Flux.empty();
}
return Flux.fromIterable(roleNames)
.flatMap(roleName -> extensionClient.fetch(Role.class, roleName));
var builder = ListOptions.builder()
.andQuery(notDeleting())
.andQuery(QueryFactory.in("metadata.name", roleNames));
if (excludeHidden) {
builder.labelSelector().notEq(Role.HIDDEN_LABEL_NAME, Boolean.TRUE.toString());
}
return client.listAll(Role.class, builder.build(), defaultSort());
}
@NonNull

View File

@ -1,11 +1,12 @@
package run.halo.app.core.extension.service;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.RoleBinding.Subject;
/**
@ -14,7 +15,11 @@ import run.halo.app.core.extension.RoleBinding.Subject;
*/
public interface RoleService {
Flux<RoleRef> listRoleRefs(Subject subject);
Flux<RoleBinding> listRoleBindings(Subject subject);
Flux<String> getRolesByUsername(String username);
Mono<Map<String, Collection<String>>> getRolesByUsernames(Collection<String> usernames);
Mono<Boolean> contains(Collection<String> source, Collection<String> candidates);
@ -29,5 +34,20 @@ public interface RoleService {
Flux<Role> listDependenciesFlux(Set<String> names);
/**
* List roles by role names.
*
* @param roleNames role names
* @return roles
*/
Flux<Role> list(Set<String> roleNames);
/**
* List roles by role names.
*
* @param roleNames role names
* @param excludeHidden should exclude hidden roles
* @return roles
*/
Flux<Role> list(Set<String> roleNames, boolean excludeHidden);
}

View File

@ -3,7 +3,6 @@ package run.halo.app.core.extension.service;
import java.util.Set;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
public interface UserService {
@ -16,8 +15,6 @@ public interface UserService {
Mono<User> updateWithRawPassword(String username, String rawPassword);
Flux<Role> listRoles(String username);
Mono<User> grantRoles(String username, Set<String> roles);
Mono<User> signUp(User user, String password);

View File

@ -2,15 +2,19 @@ package run.halo.app.core.extension.service;
import static org.springframework.data.domain.Sort.Order.asc;
import static org.springframework.data.domain.Sort.Order.desc;
import static run.halo.app.core.extension.RoleBinding.containsUser;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.time.Clock;
import java.time.Duration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@ -21,6 +25,7 @@ import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
@ -48,11 +53,18 @@ public class UserServiceImpl implements UserService {
private final ApplicationEventPublisher eventPublisher;
private final RoleService roleService;
private Clock clock = Clock.systemUTC();
void setClock(Clock clock) {
this.clock = clock;
}
@Override
public Mono<User> getUser(String username) {
return client.get(User.class, username)
.onErrorMap(ExtensionNotFoundException.class,
e -> new UserNotFoundException(username));
.onErrorMap(ExtensionNotFoundException.class, e -> new UserNotFoundException(username));
}
@Override
@ -91,22 +103,17 @@ public class UserServiceImpl implements UserService {
}
@Override
public Flux<Role> listRoles(String name) {
return client.list(RoleBinding.class, containsUser(name), null)
.filter(roleBinding -> Role.KIND.equals(roleBinding.getRoleRef().getKind()))
.map(roleBinding -> roleBinding.getRoleRef().getName())
.flatMap(roleName -> client.fetch(Role.class, roleName));
}
@Override
@Transactional
public Mono<User> grantRoles(String username, Set<String> roles) {
return client.get(User.class, username)
.flatMap(user -> {
var bindingsToUpdate = new HashSet<RoleBinding>();
var bindingsToDelete = new HashSet<RoleBinding>();
var existingRoles = new HashSet<String>();
return client.list(RoleBinding.class, RoleBinding.containsUser(username), null)
var subject = new RoleBinding.Subject();
subject.setKind(User.KIND);
subject.setApiGroup(User.GROUP);
subject.setName(username);
return roleService.listRoleBindings(subject)
.doOnNext(binding -> {
var roleName = binding.getRoleRef().getName();
if (roles.contains(roleName)) {
@ -129,7 +136,13 @@ public class UserServiceImpl implements UserService {
return mutableRoles.stream()
.map(roleName -> RoleBinding.create(username, roleName));
}).flatMap(client::create))
.then(Mono.just(user));
.then(Mono.defer(() -> {
var annotations = Optional.ofNullable(user.getMetadata().getAnnotations())
.orElseGet(HashMap::new);
user.getMetadata().setAnnotations(annotations);
annotations.put(User.REQUEST_TO_UPDATE, clock.instant().toString());
return client.update(user);
}));
});
}
@ -184,7 +197,12 @@ public class UserServiceImpl implements UserService {
.then();
})
.then(Mono.defer(() -> client.create(user)
.flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames)))
.flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames)
.retryWhen(
Retry.backoff(5, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance)
)
))
);
}

View File

@ -7,7 +7,9 @@ import static run.halo.app.extension.index.IndexAttributeFactory.multiValueAttri
import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
@ -31,6 +33,7 @@ import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.UserConnection;
import run.halo.app.core.extension.UserConnection.UserConnectionSpec;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Group;
import run.halo.app.core.extension.attachment.Policy;
@ -51,6 +54,7 @@ import run.halo.app.core.extension.notification.Subscription;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.DefaultSchemeManager;
import run.halo.app.extension.DefaultSchemeWatcherManager;
import run.halo.app.extension.MetadataOperator;
import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.Secret;
import run.halo.app.extension.index.IndexSpec;
@ -89,7 +93,22 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
));
});
schemeManager.register(RoleBinding.class);
schemeManager.register(RoleBinding.class, is -> {
is.add(new IndexSpec()
.setName("roleRef.name")
.setIndexFunc(simpleAttribute(RoleBinding.class,
roleBinding -> roleBinding.getRoleRef().getName())
)
);
is.add(new IndexSpec()
.setName("subjects")
.setIndexFunc(multiValueAttribute(RoleBinding.class,
roleBinding -> roleBinding.getSubjects().stream()
.map(RoleBinding.Subject::toString)
.collect(Collectors.toSet()))
)
);
});
schemeManager.register(User.class, indexSpecs -> {
indexSpecs.add(new IndexSpec()
.setName("spec.displayName")
@ -103,16 +122,16 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
})));
indexSpecs.add(new IndexSpec()
.setName(User.USER_RELATED_ROLES_INDEX)
.setIndexFunc(multiValueAttribute(User.class, user -> {
var roleNamesAnno = MetadataUtil.nullSafeAnnotations(user)
.get(User.ROLE_NAMES_ANNO);
if (StringUtils.isBlank(roleNamesAnno)) {
return Set.of();
}
return JsonUtils.jsonToObject(roleNamesAnno,
new TypeReference<>() {
});
})));
.setIndexFunc(multiValueAttribute(User.class, user ->
Optional.ofNullable(user.getMetadata())
.map(MetadataOperator::getAnnotations)
.map(annotations -> annotations.get(User.ROLE_NAMES_ANNO))
.filter(StringUtils::isNotBlank)
.map(rolesJson -> JsonUtils.jsonToObject(rolesJson,
new TypeReference<Set<String>>() {
})
)
.orElseGet(Set::of))));
});
schemeManager.register(ReverseProxy.class);
schemeManager.register(Setting.class);
@ -453,7 +472,15 @@ public class SchemeInitializer implements ApplicationListener<ApplicationContext
schemeManager.register(Counter.class);
// auth.halo.run
schemeManager.register(AuthProvider.class);
schemeManager.register(UserConnection.class);
schemeManager.register(UserConnection.class, is -> {
is.add(new IndexSpec()
.setName("spec.username")
.setIndexFunc(simpleAttribute(UserConnection.class,
connection -> Optional.ofNullable(connection.getSpec())
.map(UserConnectionSpec::getUsername)
.orElse(null)
)));
});
// security.halo.run
schemeManager.register(PersonalAccessToken.class);

View File

@ -1,8 +1,6 @@
package run.halo.app.security;
import static java.util.Objects.requireNonNullElse;
import static run.halo.app.core.extension.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import static run.halo.app.security.authorization.AuthorityUtils.ANONYMOUS_ROLE_NAME;
import static run.halo.app.security.authorization.AuthorityUtils.AUTHENTICATED_ROLE_NAME;
import static run.halo.app.security.authorization.AuthorityUtils.ROLE_PREFIX;
@ -15,12 +13,8 @@ import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupKind;
import run.halo.app.infra.exception.UserNotFoundException;
import run.halo.app.security.authentication.login.HaloUser;
import run.halo.app.security.authentication.twofactor.TwoFactorUtils;
@ -56,13 +50,10 @@ public class DefaultUserDetailService
e -> new BadCredentialsException("Invalid Credentials"))
.flatMap(user -> {
var name = user.getMetadata().getName();
var subject = new Subject(KIND, name, GROUP);
var userBuilder = User.withUsername(name)
.password(user.getSpec().getPassword())
.disabled(requireNonNullElse(user.getSpec().getDisabled(), false));
var setAuthorities = roleService.listRoleRefs(subject)
.filter(this::isRoleRef)
.map(RoleRef::getName)
var setAuthorities = roleService.getRolesByUsername(name)
// every authenticated user should have authenticated and anonymous roles.
.concatWithValues(AUTHENTICATED_ROLE_NAME, ANONYMOUS_ROLE_NAME)
.map(roleName -> new SimpleGrantedAuthority(ROLE_PREFIX + roleName))
@ -82,12 +73,6 @@ public class DefaultUserDetailService
});
}
private boolean isRoleRef(RoleRef roleRef) {
var roleGvk = new Role().groupVersionKind();
var gk = new GroupKind(roleRef.getApiGroup(), roleRef.getKind());
return gk.equals(roleGvk.groupKind());
}
private UserDetails withNewPassword(UserDetails userDetails, String newPassword) {
return User.withUserDetails(userDetails)
.password(newPassword)

View File

@ -39,8 +39,11 @@ public enum AuthorityUtils {
Collection<? extends GrantedAuthority> authorities) {
return authorities.stream()
.map(GrantedAuthority::getAuthority)
.filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX))
.map(authority -> StringUtils.removeStart(authority, ROLE_PREFIX))
.map(authority -> {
authority = StringUtils.removeStart(authority, SCOPE_PREFIX);
authority = StringUtils.removeStart(authority, ROLE_PREFIX);
return authority;
})
.collect(Collectors.toSet());
}

View File

@ -1,14 +1,10 @@
package run.halo.app.security.authorization;
import java.util.Collection;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.service.RoleService;
@ -30,7 +26,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
@Override
public Mono<AuthorizingVisitor> visitRules(Authentication authentication,
RequestInfo requestInfo) {
var roleNames = listBoundRoleNames(authentication.getAuthorities());
var roleNames = AuthorityUtils.authoritiesToRoles(authentication.getAuthorities());
var record = new AttributesRecord(authentication, requestInfo);
var visitor = new AuthorizingVisitor(record);
@ -75,15 +71,4 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver {
return String.format("Binding role [%s] to [%s]", roleName, subject);
}
private static Set<String> listBoundRoleNames(
Collection<? extends GrantedAuthority> authorities) {
return authorities.stream()
.map(GrantedAuthority::getAuthority)
.map(authority -> {
authority = StringUtils.removeStart(authority, AuthorityUtils.SCOPE_PREFIX);
authority = StringUtils.removeStart(authority, AuthorityUtils.ROLE_PREFIX);
return authority;
})
.collect(Collectors.toSet());
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.core.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ -40,4 +41,20 @@ class RoleBindingTest {
assertFalse(RoleBinding.containsUser("non-exist-fake-name").test(binding));
}
@Test
void subjectToStringTest() {
assertEquals("User/fake-name", createSubject("fake-name", "", "User").toString());
assertEquals(
"fake.group/User/fake-name",
createSubject("fake-name", "fake.group", "User").toString()
);
}
RoleBinding.Subject createSubject(String name, String apiGroup, String kind) {
var subject = new RoleBinding.Subject();
subject.setName(name);
subject.setApiGroup(apiGroup);
subject.setKind(kind);
return subject;
}
}

View File

@ -6,6 +6,8 @@ import static org.springframework.security.test.web.reactive.server.SecurityMock
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
@ -17,6 +19,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService;
@ -46,7 +49,7 @@ public class UserEndpointIntegrationTest {
.build();
var role = new Role();
role.setMetadata(new Metadata());
role.getMetadata().setName("super-role");
role.getMetadata().setName("fake-super-role");
role.setRules(List.of(rule));
when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(role));
webClient = webClient.mutateWith(csrf());
@ -68,6 +71,9 @@ public class UserEndpointIntegrationTest {
client.create(unexpectedUser2).block();
when(roleService.list(anySet())).thenReturn(Flux.empty());
when(roleService.getRolesByUsernames(
List.of("fake-user-2")
)).thenReturn(Mono.just(Map.of("fake-user-2", Set.of("fake-super-role"))));
webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users?keyword=Expected")
.exchange()
@ -92,6 +98,8 @@ public class UserEndpointIntegrationTest {
client.create(unexpectedUser2).block();
when(roleService.list(anySet())).thenReturn(Flux.empty());
when(roleService.getRolesByUsernames(List.of("fake-user")))
.thenReturn(Mono.just(Map.of("fake-user", Set.of("fake-super-role"))));
webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users?keyword=fake-user")
.exchange()

View File

@ -88,6 +88,8 @@ class UserEndpointTest {
@Test
void shouldListEmptyUsersWhenNoUsers() {
when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of()));
when(roleService.list(any())).thenReturn(Flux.empty());
when(client.listBy(same(User.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(ListResult.emptyResult()));
@ -109,6 +111,7 @@ class UserEndpointTest {
createUser("fake-user-3")
);
var expectResult = new ListResult<>(users);
when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of()));
when(roleService.list(anySet())).thenReturn(Flux.empty());
when(client.listBy(same(User.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(expectResult));
@ -144,6 +147,7 @@ class UserEndpointTest {
var expectResult = new ListResult<>(users);
when(client.listBy(same(User.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(expectResult));
when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of()));
when(roleService.list(anySet())).thenReturn(Flux.empty());
bindToRouterFunction(endpoint.endpoint())
@ -160,6 +164,7 @@ class UserEndpointTest {
var expectResult = new ListResult<>(List.of(expectUser));
when(client.listBy(same(User.class), any(), any(PageRequest.class)))
.thenReturn(Mono.just(expectResult));
when(roleService.getRolesByUsernames(any())).thenReturn(Mono.just(Map.of()));
when(roleService.list(anySet())).thenReturn(Flux.empty());
bindToRouterFunction(endpoint.endpoint())
@ -219,22 +224,19 @@ class UserEndpointTest {
metadata.setName("fake-user");
var user = new User();
user.setMetadata(metadata);
Map<String, String> annotations =
Map.of(User.ROLE_NAMES_ANNO, JsonUtils.objectToJson(Set.of("role-A")));
user.getMetadata().setAnnotations(annotations);
when(userService.getUser("fake-user")).thenReturn(Mono.just(user));
Role role = new Role();
role.setMetadata(new Metadata());
role.getMetadata().setName("role-A");
role.getMetadata().setName("fake-super-role");
role.setRules(List.of());
when(roleService.list(anySet())).thenReturn(Flux.just(role));
when(roleService.list(Set.of("fake-super-role"), true)).thenReturn(Flux.just(role));
webClient.get().uri("/users/-")
.exchange()
.expectStatus().isOk()
.expectHeader().contentType(MediaType.APPLICATION_JSON)
.expectBody(UserEndpoint.DetailedUser.class)
.isEqualTo(new UserEndpoint.DetailedUser(user, List.of(role)));
verify(roleService).list(eq(Set.of("role-A")));
// verify(roleService).list(eq(Set.of("role-A")));
}
}
@ -385,16 +387,17 @@ class UserEndpointTest {
"metadata": {
"name": "test-A",
"annotations": {
"rbac.authorization.halo.run/ui-permissions": "[\\"permission-A\\"]"
"rbac.authorization.halo.run/ui-permissions": \
"[\\"permission-A\\", \\"permission-A\\"]"
}
},
"rules": []
}
""", Role.class);
when(roleService.listPermissions(eq(Set.of("test-A")))).thenReturn(Flux.just(roleA));
when(userService.listRoles(eq("fake-user"))).thenReturn(
Flux.fromIterable(List.of(roleA)));
when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(roleA));
when(roleService.getRolesByUsername("fake-user")).thenReturn(Flux.just("test-A"));
when(roleService.list(Set.of("test-A"), true)).thenReturn(Flux.just(roleA));
webClient.get().uri("/users/fake-user/permissions")
.exchange()
@ -402,12 +405,10 @@ class UserEndpointTest {
.isOk()
.expectBody(UserEndpoint.UserPermission.class)
.value(userPermission -> {
assertEquals(Set.of(roleA), userPermission.getRoles());
assertEquals(List.of(roleA), userPermission.getRoles());
assertEquals(List.of(roleA), userPermission.getPermissions());
assertEquals(Set.of("permission-A"), userPermission.getUiPermissions());
assertEquals(List.of("permission-A"), userPermission.getUiPermissions());
});
verify(userService, times(1)).listRoles(eq("fake-user"));
}
}

View File

@ -1,195 +0,0 @@
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.User.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);
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);
when(client.fetch(RoleBinding.class, bindingName))
.thenReturn(Optional.of(binding));
when(binding.getSubjects()).thenReturn(List.of(subject));
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(roleRef.getApiGroup()).thenReturn("");
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;
}
}

View File

@ -1,14 +1,12 @@
package run.halo.app.core.extension.reconciler;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
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.User.GROUP;
import static run.halo.app.core.extension.User.KIND;
import java.net.URI;
import java.net.URISyntaxException;
@ -17,14 +15,11 @@ import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
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.core.extension.service.RoleService;
import run.halo.app.extension.ExtensionClient;
@ -60,58 +55,51 @@ class UserReconcilerTest {
@BeforeEach
void setUp() {
lenient().when(notificationCenter.unsubscribe(any(), any())).thenReturn(Mono.empty());
lenient().when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
}
@Test
void permalinkForFakeUser() throws URISyntaxException {
when(externalUrlSupplier.get()).thenReturn(new URI("http://localhost:8090"));
when(roleService.getRolesByUsername("fake-user"))
.thenReturn(Flux.empty());
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Optional.of(user("fake-user")));
userReconciler.reconcile(new Reconciler.Request("fake-user"));
verify(client, times(4)).update(any(User.class));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(4)).update(captor.capture());
assertThat(captor.getValue().getStatus().getPermalink())
.isEqualTo("http://localhost:8090/authors/fake-user");
verify(client).<User>update(assertArg(user ->
assertEquals(
"http://localhost:8090/authors/fake-user",
user.getStatus().getPermalink()
)
));
}
@Test
void permalinkForAnonymousUser() {
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
when(roleService.getRolesByUsername(AnonymousUserConst.PRINCIPAL)).thenReturn(Flux.empty());
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
verify(client, times(3)).update(any(User.class));
verify(client).update(any(User.class));
}
@Test
void ensureRoleNamesAnno() {
RoleBinding.RoleRef roleRef = new RoleBinding.RoleRef();
roleRef.setName("fake-role");
roleRef.setKind(Role.KIND);
roleRef.setApiGroup(Role.GROUP);
RoleBinding.RoleRef notworkRef = new RoleBinding.RoleRef();
notworkRef.setName("super-role");
notworkRef.setKind("Fake");
notworkRef.setApiGroup("fake.halo.run");
RoleBinding.Subject subject = new RoleBinding.Subject(KIND, "fake-user", GROUP);
when(roleService.listRoleRefs(eq(subject))).thenReturn(Flux.just(roleRef, notworkRef));
when(roleService.getRolesByUsername("fake-user")).thenReturn(Flux.just("fake-role"));
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Optional.of(user("fake-user")));
when(externalUrlSupplier.get()).thenReturn(URI.create("/"));
userReconciler.reconcile(new Reconciler.Request("fake-user"));
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
verify(client, times(4)).update(captor.capture());
User user = captor.getAllValues().get(1);
assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO))
.isEqualTo("[\"fake-role\"]");
verify(client).update(assertArg(user -> {
assertEquals("""
["fake-role"]\
""",
user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO));
}));
}
User user(String name) {

View File

@ -1,7 +1,6 @@
package run.halo.app.core.extension.service;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.never;
@ -22,10 +21,12 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
@ -39,7 +40,7 @@ import run.halo.app.infra.utils.JsonUtils;
@ExtendWith(MockitoExtension.class)
class DefaultRoleServiceTest {
@Mock
private ReactiveExtensionClient extensionClient;
private ReactiveExtensionClient client;
@InjectMocks
private DefaultRoleService roleService;
@ -56,7 +57,7 @@ class DefaultRoleServiceTest {
var roleNames = Set.of("role1");
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role1))
.thenReturn(Flux.just(role2))
.thenReturn(Flux.just(role3))
@ -73,7 +74,11 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(4)).list(same(Role.class), any(), any());
verify(client, times(4)).listAll(
same(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test
@ -86,7 +91,7 @@ class DefaultRoleServiceTest {
var roleNames = Set.of("role1");
// setup mocks
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role1))
.thenReturn(Flux.just(role2))
.thenReturn(Flux.just(role3))
@ -103,7 +108,11 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(4)).list(same(Role.class), any(), any());
verify(client, times(4)).listAll(
same(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test
@ -118,13 +127,12 @@ class DefaultRoleServiceTest {
var roleNames = Set.of("role1");
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role1))
.thenReturn(Flux.just(role2))
.thenReturn(Flux.just(role3))
.thenReturn(Flux.just(role4))
.thenReturn(Flux.empty())
;
.thenReturn(Flux.empty());
// call the method under test
var result = roleService.listDependenciesFlux(roleNames);
@ -138,7 +146,11 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(5)).list(same(Role.class), any(), any());
verify(client, times(5)).listAll(
same(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test
@ -153,7 +165,7 @@ class DefaultRoleServiceTest {
Set<String> roleNames = Set.of("role1");
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role1))
.thenReturn(Flux.just(role4, role2))
.thenReturn(Flux.just(role3))
@ -171,7 +183,7 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(4)).list(same(Role.class), any(), any());
verify(client, times(4)).listAll(same(Role.class), any(), any());
}
@Test
@ -186,7 +198,7 @@ class DefaultRoleServiceTest {
Set<String> roleNames = Set.of("role2");
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role2))
.thenReturn(Flux.just(role3))
.thenReturn(Flux.empty());
@ -201,17 +213,17 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(3)).list(same(Role.class), any(), any());
verify(client, times(3)).listAll(
same(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test
void listDependenciesWithNullParam() {
var result = roleService.listDependenciesFlux(null);
when(extensionClient.list(same(Role.class), any(), any()))
.thenReturn(Flux.empty())
.thenReturn(Flux.empty());
// verify the result
StepVerifier.create(result)
.verifyComplete();
@ -221,7 +233,11 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, never()).fetch(eq(Role.class), anyString());
verify(client, never()).listAll(
eq(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test
@ -232,7 +248,7 @@ class DefaultRoleServiceTest {
var roleNames = Set.of("role1");
when(extensionClient.list(same(Role.class), any(), any()))
when(client.listAll(same(Role.class), any(ListOptions.class), any(Sort.class)))
.thenReturn(Flux.just(role1))
.thenReturn(Flux.just(role2))
.thenReturn(Flux.just(role4))
@ -248,7 +264,11 @@ class DefaultRoleServiceTest {
.verifyComplete();
// verify the mock invocations
verify(extensionClient, times(4)).list(same(Role.class), any(), any());
verify(client, times(4)).listAll(
same(Role.class),
any(ListOptions.class),
any(Sort.class)
);
}
@Test

View File

@ -8,9 +8,7 @@ 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.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
@ -19,7 +17,6 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.GroupVersionKind.fromExtension;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@ -35,6 +32,7 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
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;
import run.halo.app.event.user.PasswordChangedEvent;
import run.halo.app.extension.Metadata;
@ -45,7 +43,6 @@ import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.DuplicateNameException;
import run.halo.app.infra.exception.UserNotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@ -62,6 +59,9 @@ class UserServiceImplTest {
@Mock
ApplicationEventPublisher eventPublisher;
@Mock
RoleService roleService;
@InjectMocks
UserServiceImpl userService;
@ -108,119 +108,6 @@ class UserServiceImplTest {
verify(eventPublisher).publishEvent(any(PasswordChangedEvent.class));
}
@Test
void shouldListRolesIfUserFoundInExtension() {
User fakeUser = new User();
Metadata metadata = new Metadata();
metadata.setName("faker");
fakeUser.setMetadata(metadata);
fakeUser.setSpec(new User.UserSpec());
when(client.list(eq(RoleBinding.class), any(), any())).thenReturn(
Flux.fromIterable(getRoleBindings()));
Role roleA = new Role();
Metadata metadataA = new Metadata();
metadataA.setName("test-A");
roleA.setMetadata(metadataA);
Role roleB = new Role();
Metadata metadataB = new Metadata();
metadataB.setName("test-B");
roleB.setMetadata(metadataB);
Role roleC = new Role();
Metadata metadataC = new Metadata();
metadataC.setName("ddd");
roleC.setMetadata(metadataC);
when(client.fetch(eq(Role.class), eq("test-A"))).thenReturn(Mono.just(roleA));
when(client.fetch(eq(Role.class), eq("test-B"))).thenReturn(Mono.just(roleB));
lenient().when(client.fetch(eq(Role.class), eq("ddd"))).thenReturn(Mono.just(roleC));
StepVerifier.create(userService.listRoles("faker"))
.expectNext(roleA)
.expectNext(roleB)
.verifyComplete();
verify(client, times(1)).list(eq(RoleBinding.class), any(), any());
verify(client, times(1)).fetch(eq(Role.class), eq("test-A"));
verify(client, times(1)).fetch(eq(Role.class), eq("test-B"));
verify(client, times(0)).fetch(eq(Role.class), eq("ddd"));
}
List<RoleBinding> getRoleBindings() {
String bindA = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-A"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
}],
"roleRef": {
"kind": "Role",
"name": "test-A",
"apiGroup": ""
}
}
""";
String bindB = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-B"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
},
{
"kind": "User",
"name": "zhangsan",
"apiGroup": ""
}],
"roleRef": {
"kind": "Role",
"name": "test-B",
"apiGroup": ""
}
}
""";
String bindC = """
{
"apiVersion": "v1alpha1",
"kind": "RoleBinding",
"metadata": {
"name": "bind-C"
},
"subjects": [{
"kind": "User",
"name": "faker",
"apiGroup": ""
}],
"roleRef": {
"kind": "Fake",
"name": "ddd",
"apiGroup": ""
}
}
""";
return List.of(JsonUtils.jsonToObject(bindA, RoleBinding.class),
JsonUtils.jsonToObject(bindB, RoleBinding.class),
JsonUtils.jsonToObject(bindC, RoleBinding.class));
}
@Nested
@DisplayName("UpdateWithRawPassword")
class UpdateWithRawPasswordTest {
@ -312,6 +199,9 @@ class UserServiceImplTest {
User createUser(String password) {
var user = new User();
Metadata metadata = new Metadata();
metadata.setName("fake-user");
user.setMetadata(metadata);
user.setSpec(new User.UserSpec());
user.getSpec().setPassword(password);
return user;
@ -336,46 +226,47 @@ class UserServiceImplTest {
@Test
void shouldCreateRoleBindingIfNotExist() {
var user = createUser("fake-password");
when(client.get(User.class, "fake-user"))
.thenReturn(Mono.just(createUser("fake-password")));
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(Flux.empty());
.thenReturn(Mono.just(user));
when(roleService.listRoleBindings(any(Subject.class))).thenReturn(Flux.empty());
when(client.create(isA(RoleBinding.class))).thenReturn(
Mono.just(mock(RoleBinding.class)));
when(client.update(user)).thenReturn(Mono.just(user));
var grantRolesMono = userService.grantRoles("fake-user", Set.of("fake-role"));
StepVerifier.create(grantRolesMono)
.expectNextCount(1)
.verifyComplete();
verify(client).get(User.class, "fake-user");
verify(client).list(same(RoleBinding.class), any(), any());
verify(client).create(isA(RoleBinding.class));
}
@Test
void shouldDeleteRoleBindingIfNotProvided() {
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(mock(User.class)));
var user = createUser("fake-password");
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user));
var notProvidedRoleBinding = RoleBinding.create("fake-user", "non-provided-fake-role");
var existingRoleBinding = RoleBinding.create("fake-user", "fake-role");
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(
Flux.fromIterable(List.of(notProvidedRoleBinding, existingRoleBinding)));
when(roleService.listRoleBindings(any(Subject.class)))
.thenReturn(Flux.just(notProvidedRoleBinding, existingRoleBinding));
when(client.delete(isA(RoleBinding.class)))
.thenReturn(Mono.just(mock(RoleBinding.class)));
when(client.update(user)).thenReturn(Mono.just(user));
StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role")))
.expectNextCount(1)
.verifyComplete();
verify(client).get(User.class, "fake-user");
verify(client).list(same(RoleBinding.class), any(), any());
verify(client).delete(notProvidedRoleBinding);
}
@Test
void shouldUpdateRoleBindingIfExists() {
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(mock(User.class)));
var user = createUser("fake-password");
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(user));
// add another subject
var anotherSubject = new RoleBinding.Subject();
var anotherSubject = new Subject();
anotherSubject.setName("another-fake-user");
anotherSubject.setKind(User.KIND);
anotherSubject.setApiGroup(User.GROUP);
@ -384,17 +275,16 @@ class UserServiceImplTest {
var existingRoleBinding = RoleBinding.create("fake-user", "fake-role");
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(
Flux.fromIterable(List.of(notProvidedRoleBinding, existingRoleBinding)));
when(roleService.listRoleBindings(any(Subject.class)))
.thenReturn(Flux.just(notProvidedRoleBinding, existingRoleBinding));
when(client.update(isA(RoleBinding.class)))
.thenReturn(Mono.just(mock(RoleBinding.class)));
when(client.update(user)).thenReturn(Mono.just(user));
StepVerifier.create(userService.grantRoles("fake-user", Set.of("fake-role")))
.expectNextCount(1)
.verifyComplete();
verify(client).get(User.class, "fake-user");
verify(client).list(same(RoleBinding.class), any(), any());
verify(client).update(notProvidedRoleBinding);
}
}

View File

@ -4,7 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ -24,8 +23,6 @@ import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.RoleBinding.RoleRef;
import run.halo.app.core.extension.RoleBinding.Subject;
import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
@ -85,17 +82,8 @@ class DefaultUserDetailServiceTest {
void shouldFindUserDetailsByExistingUsername() {
var foundUser = createFakeUser();
var roleGvk = new Role().groupVersionKind();
var roleRef = new RoleRef();
roleRef.setKind(roleGvk.kind());
roleRef.setApiGroup(roleGvk.group());
roleRef.setName("fake-role");
var userGvk = foundUser.groupVersionKind();
var subject = new Subject(userGvk.kind(), "faker", userGvk.group());
when(userService.getUser("faker")).thenReturn(Mono.just(foundUser));
when(roleService.listRoleRefs(subject)).thenReturn(Flux.just(roleRef));
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.just("fake-role"));
var userDetailsMono = userDetailService.findByUsername("faker");
@ -115,7 +103,7 @@ class DefaultUserDetailServiceTest {
void shouldFindHaloUserDetailsWith2faDisabledWhen2faNotEnabled() {
var fakeUser = createFakeUser();
when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser));
when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty());
userDetailService.findByUsername("faker")
.as(StepVerifier::create)
.assertNext(userDetails -> {
@ -130,7 +118,7 @@ class DefaultUserDetailServiceTest {
var fakeUser = createFakeUser();
fakeUser.getSpec().setTwoFactorAuthEnabled(true);
when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser));
when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty());
userDetailService.findByUsername("faker")
.as(StepVerifier::create)
.assertNext(userDetails -> {
@ -146,7 +134,7 @@ class DefaultUserDetailServiceTest {
fakeUser.getSpec().setTwoFactorAuthEnabled(true);
fakeUser.getSpec().setTotpEncryptedSecret("fake-totp-encrypted-secret");
when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser));
when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty());
userDetailService.findByUsername("faker")
.as(StepVerifier::create)
.assertNext(userDetails -> {
@ -163,7 +151,7 @@ class DefaultUserDetailServiceTest {
fakeUser.getSpec().setTwoFactorAuthEnabled(true);
fakeUser.getSpec().setTotpEncryptedSecret("fake-totp-encrypted-secret");
when(userService.getUser("faker")).thenReturn(Mono.just(fakeUser));
when(roleService.listRoleRefs(any())).thenReturn(Flux.empty());
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty());
userDetailService.findByUsername("faker")
.as(StepVerifier::create)
.assertNext(userDetails -> {
@ -173,50 +161,14 @@ class DefaultUserDetailServiceTest {
.verifyComplete();
}
@Test
void shouldFindUserDetailsByExistingUsernameButKindOfRoleRefIsNotRole() {
var foundUser = createFakeUser();
var roleRef = new RoleRef();
roleRef.setKind("FakeRole");
roleRef.setApiGroup("fake.halo.run");
roleRef.setName("fake-role");
var userGvk = foundUser.groupVersionKind();
var subject = new Subject(userGvk.kind(), "faker", userGvk.group());
when(userService.getUser("faker")).thenReturn(Mono.just(foundUser));
when(roleService.listRoleRefs(subject)).thenReturn(Flux.just(roleRef));
var userDetailsMono = userDetailService.findByUsername("faker");
StepVerifier.create(userDetailsMono)
.expectSubscription()
.assertNext(gotUser -> {
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword());
assertEquals(2, gotUser.getAuthorities().size());
assertEquals(
Set.of("ROLE_anonymous", "ROLE_authenticated"),
authorityListToSet(gotUser.getAuthorities())
);
})
.verifyComplete();
}
@Test
void shouldFindUserDetailsByExistingUsernameButWithoutAnyRoles() {
var foundUser = createFakeUser();
var userGvk = foundUser.groupVersionKind();
var subject = new Subject(userGvk.kind(), "faker", userGvk.group());
when(userService.getUser("faker")).thenReturn(Mono.just(foundUser));
when(roleService.listRoleRefs(subject)).thenReturn(Flux.empty());
when(roleService.getRolesByUsername("faker")).thenReturn(Flux.empty());
var userDetailsMono = userDetailService.findByUsername("faker");
StepVerifier.create(userDetailsMono)
StepVerifier.create(userDetailService.findByUsername("faker"))
.expectSubscription()
.assertNext(gotUser -> {
assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername());
@ -240,6 +192,13 @@ class DefaultUserDetailServiceTest {
.verify();
}
Role createRole(String roleName) {
var role = new Role();
role.setMetadata(new Metadata());
role.getMetadata().setName(roleName);
return role;
}
UserDetails createFakeUserDetails() {
return User.builder()
.username("faker")

View File

@ -29,7 +29,7 @@ class AuthorityUtilsTest {
var roles = authoritiesToRoles(authorities);
assertEquals(Set.of("admin", "owner", "manager"), roles);
assertEquals(Set.of("admin", "owner", "manager", "faker", "system:read"), roles);
}
@Test