fix(auth): grant global membership to new users

This commit is contained in:
vsxd 2026-03-13 11:29:01 +08:00
parent 0c5e4200cb
commit 5bb2eb0d98
6 changed files with 213 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
@ -20,13 +21,16 @@ public class IdentityBindingService {
private final IdentityBindingRepository bindingRepo;
private final UserAccountRepository userRepo;
private final UserRoleBindingRepository roleBindingRepo;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
public IdentityBindingService(IdentityBindingRepository bindingRepo,
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo) {
UserAccountRepository userRepo,
UserRoleBindingRepository roleBindingRepo,
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
this.bindingRepo = bindingRepo;
this.userRepo = userRepo;
this.roleBindingRepo = roleBindingRepo;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
}
@Transactional
@ -54,6 +58,9 @@ public class IdentityBindingService {
);
user.setStatus(initialStatus);
user = userRepo.save(user);
if (initialStatus == UserStatus.ACTIVE) {
globalNamespaceMembershipService.ensureMember(user.getId());
}
binding = new IdentityBinding(user.getId(), claims.provider(), claims.subject(), claims.providerLogin());
bindingRepo.save(binding);

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.auth.local;
import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
@ -28,17 +29,20 @@ public class LocalAuthService {
private final LocalCredentialRepository credentialRepository;
private final UserAccountRepository userAccountRepository;
private final UserRoleBindingRepository userRoleBindingRepository;
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
private final PasswordPolicyValidator passwordPolicyValidator;
private final PasswordEncoder passwordEncoder;
public LocalAuthService(LocalCredentialRepository credentialRepository,
UserAccountRepository userAccountRepository,
UserRoleBindingRepository userRoleBindingRepository,
GlobalNamespaceMembershipService globalNamespaceMembershipService,
PasswordPolicyValidator passwordPolicyValidator,
PasswordEncoder passwordEncoder) {
this.credentialRepository = credentialRepository;
this.userAccountRepository = userAccountRepository;
this.userRoleBindingRepository = userRoleBindingRepository;
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
this.passwordPolicyValidator = passwordPolicyValidator;
this.passwordEncoder = passwordEncoder;
}
@ -76,6 +80,7 @@ public class LocalAuthService {
normalizedUsername,
passwordEncoder.encode(password)
));
globalNamespaceMembershipService.ensureMember(user.getId());
return buildPrincipal(user);
}

View file

@ -0,0 +1,92 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.entity.IdentityBinding;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.repository.IdentityBindingRepository;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class IdentityBindingServiceTest {
@Mock
private IdentityBindingRepository bindingRepo;
@Mock
private UserAccountRepository userRepo;
@Mock
private UserRoleBindingRepository roleBindingRepo;
@Mock
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
private IdentityBindingService service;
@BeforeEach
void setUp() {
service = new IdentityBindingService(bindingRepo, userRepo, roleBindingRepo, globalNamespaceMembershipService);
}
@Test
void bindOrCreate_assignsGlobalMembershipForActiveNewUsers() {
OAuthClaims claims = new OAuthClaims(
"github",
"gh_1",
"alice@example.com",
true,
"alice",
Map.of("avatar_url", "https://example.test/a.png")
);
when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty());
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(roleBindingRepo.findByUserId(any())).thenReturn(List.of());
PlatformPrincipal principal = service.bindOrCreate(claims, UserStatus.ACTIVE);
ArgumentCaptor<UserAccount> userCaptor = ArgumentCaptor.forClass(UserAccount.class);
verify(userRepo).save(userCaptor.capture());
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
verify(bindingRepo).save(any(IdentityBinding.class));
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.oauthProvider()).isEqualTo("github");
}
@Test
void bindOrCreate_doesNotAssignGlobalMembershipForPendingUsers() {
OAuthClaims claims = new OAuthClaims(
"github",
"gh_1",
"alice@example.com",
true,
"alice",
Map.of()
);
when(bindingRepo.findByProviderCodeAndSubject("github", "gh_1")).thenReturn(Optional.empty());
when(userRepo.save(any(UserAccount.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(roleBindingRepo.findByUserId(any())).thenReturn(List.of());
service.bindOrCreate(claims, UserStatus.PENDING);
verify(globalNamespaceMembershipService, never()).ensureMember(any());
}
}

View file

@ -11,6 +11,7 @@ import com.iflytek.skillhub.auth.exception.AuthFlowException;
import com.iflytek.skillhub.auth.entity.Role;
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.domain.user.UserStatus;
@ -38,6 +39,9 @@ class LocalAuthServiceTest {
@Mock
private UserRoleBindingRepository userRoleBindingRepository;
@Mock
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
@Mock
private PasswordEncoder passwordEncoder;
@ -49,6 +53,7 @@ class LocalAuthServiceTest {
credentialRepository,
userAccountRepository,
userRoleBindingRepository,
globalNamespaceMembershipService,
new PasswordPolicyValidator(),
passwordEncoder
);
@ -70,6 +75,7 @@ class LocalAuthServiceTest {
assertThat(principal.displayName()).isEqualTo("alice");
assertThat(principal.email()).isEqualTo("alice@example.com");
verify(credentialRepository).save(any(LocalCredential.class));
verify(globalNamespaceMembershipService).ensureMember(userCaptor.getValue().getId());
}
@Test

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.domain.namespace;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class GlobalNamespaceMembershipService {
private static final String GLOBAL_NAMESPACE_SLUG = "global";
private final NamespaceRepository namespaceRepository;
private final NamespaceMemberRepository namespaceMemberRepository;
public GlobalNamespaceMembershipService(NamespaceRepository namespaceRepository,
NamespaceMemberRepository namespaceMemberRepository) {
this.namespaceRepository = namespaceRepository;
this.namespaceMemberRepository = namespaceMemberRepository;
}
@Transactional
public void ensureMember(String userId) {
Namespace globalNamespace = namespaceRepository.findBySlug(GLOBAL_NAMESPACE_SLUG)
.orElseThrow(() -> new IllegalStateException("Missing built-in global namespace"));
namespaceMemberRepository.findByNamespaceIdAndUserId(globalNamespace.getId(), userId)
.orElseGet(() -> namespaceMemberRepository.save(
new NamespaceMember(globalNamespace.getId(), userId, NamespaceRole.MEMBER)
));
}
}

View file

@ -0,0 +1,71 @@
package com.iflytek.skillhub.domain.namespace;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.lang.reflect.Field;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(MockitoExtension.class)
class GlobalNamespaceMembershipServiceTest {
@Mock
private NamespaceRepository namespaceRepository;
@Mock
private NamespaceMemberRepository namespaceMemberRepository;
private GlobalNamespaceMembershipService service;
@BeforeEach
void setUp() {
service = new GlobalNamespaceMembershipService(namespaceRepository, namespaceMemberRepository);
}
@Test
void ensureMember_createsGlobalMembershipWhenMissing() throws Exception {
Namespace global = new Namespace("global", "Global", "system");
setNamespaceId(global, 1L);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.empty());
service.ensureMember("usr_1");
ArgumentCaptor<NamespaceMember> memberCaptor = ArgumentCaptor.forClass(NamespaceMember.class);
verify(namespaceMemberRepository).save(memberCaptor.capture());
assertThat(memberCaptor.getValue().getNamespaceId()).isEqualTo(1L);
assertThat(memberCaptor.getValue().getUserId()).isEqualTo("usr_1");
assertThat(memberCaptor.getValue().getRole()).isEqualTo(NamespaceRole.MEMBER);
}
@Test
void ensureMember_keepsExistingGlobalMembership() throws Exception {
Namespace global = new Namespace("global", "Global", "system");
setNamespaceId(global, 1L);
NamespaceMember existing = new NamespaceMember(1L, "usr_1", NamespaceRole.ADMIN);
when(namespaceRepository.findBySlug("global")).thenReturn(Optional.of(global));
when(namespaceMemberRepository.findByNamespaceIdAndUserId(1L, "usr_1")).thenReturn(Optional.of(existing));
service.ensureMember("usr_1");
verify(namespaceMemberRepository, never()).save(any());
}
private void setNamespaceId(Namespace namespace, Long id) throws Exception {
Field field = Namespace.class.getDeclaredField("id");
field.setAccessible(true);
field.set(namespace, id);
}
}