mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
fix(auth): provision global membership on user approval
Closes #632 Signed-off-by: ylhu16 <ylhu16@iflytek.com>
This commit is contained in:
parent
6817d98007
commit
075683963e
5 changed files with 171 additions and 4 deletions
|
|
@ -250,7 +250,7 @@
|
|||
|
||||
- 状态语义:
|
||||
- `ACTIVE`:正常使用
|
||||
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建)
|
||||
- `PENDING`:等待管理员审批(AccessPolicy 返回 PENDING_APPROVAL 时创建);批准时必须在同一事务补齐 `@global` membership 后转为 `ACTIVE`
|
||||
- `DISABLED`:管理员封禁,登录后拒绝所有操作,返回 403
|
||||
- `MERGED`:已合并到其他账号,保留记录不物理删除,登录时自动跳转到合并目标账号
|
||||
- 授权层在每次请求时检查用户状态,非 `ACTIVE` 用户拒绝所有写操作
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ astron:
|
|||
### 2.2 准入失败处理
|
||||
|
||||
- `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。
|
||||
- `PENDING_APPROVAL`:创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批后状态变为 `ACTIVE`,用户下次 OAuth 登录才会正常建立 Session。
|
||||
- `PENDING_APPROVAL`:创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批时,系统在同一事务内把状态变为 `ACTIVE` 并补齐 `@global` 的 `MEMBER` membership;任一步失败都回滚。用户下次 OAuth 登录才会正常建立 Session。
|
||||
|
||||
安全边界:PENDING / DISABLED 用户绝不会拥有有效的业务 Session,从根源上杜绝"待审批账号已认证"的风险。
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
|
|
@ -44,16 +45,19 @@ public class AdminUserAppService {
|
|||
private final UserAccountRepository userAccountRepository;
|
||||
private final UserRoleBindingRepository userRoleBindingRepository;
|
||||
private final RoleRepository roleRepository;
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
public AdminUserAppService(
|
||||
AdminUserSearchRepository adminUserSearchRepository,
|
||||
UserAccountRepository userAccountRepository,
|
||||
UserRoleBindingRepository userRoleBindingRepository,
|
||||
RoleRepository roleRepository) {
|
||||
RoleRepository roleRepository,
|
||||
GlobalNamespaceMembershipService globalNamespaceMembershipService) {
|
||||
this.adminUserSearchRepository = adminUserSearchRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
this.userRoleBindingRepository = userRoleBindingRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.globalNamespaceMembershipService = globalNamespaceMembershipService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
|
|
@ -111,6 +115,9 @@ public class AdminUserAppService {
|
|||
UserStatus nextStatus = parseManageableStatus(status);
|
||||
user.setStatus(nextStatus);
|
||||
userAccountRepository.save(user);
|
||||
if (nextStatus == UserStatus.ACTIVE) {
|
||||
globalNamespaceMembershipService.ensureMember(user.getId());
|
||||
}
|
||||
return new AdminUserMutationResponse(user.getId(), null, nextStatus.name());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.entity.Role;
|
|||
import com.iflytek.skillhub.auth.entity.UserRoleBinding;
|
||||
import com.iflytek.skillhub.auth.repository.RoleRepository;
|
||||
import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
|
|
@ -35,11 +36,14 @@ class AdminUserAppServiceTest {
|
|||
private final UserRoleBindingRepository userRoleBindingRepository = mock(UserRoleBindingRepository.class);
|
||||
private final RoleRepository roleRepository = mock(RoleRepository.class);
|
||||
private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class);
|
||||
private final GlobalNamespaceMembershipService globalNamespaceMembershipService =
|
||||
mock(GlobalNamespaceMembershipService.class);
|
||||
private final AdminUserAppService service = new AdminUserAppService(
|
||||
adminUserSearchRepository,
|
||||
userAccountRepository,
|
||||
userRoleBindingRepository,
|
||||
roleRepository
|
||||
roleRepository,
|
||||
globalNamespaceMembershipService
|
||||
);
|
||||
|
||||
@Test
|
||||
|
|
@ -159,10 +163,25 @@ class AdminUserAppServiceTest {
|
|||
var response = service.updateUserStatus("user-1", "DISABLED");
|
||||
|
||||
verify(userAccountRepository).save(user);
|
||||
verify(globalNamespaceMembershipService, never()).ensureMember(any());
|
||||
assertThat(user.getStatus()).isEqualTo(UserStatus.DISABLED);
|
||||
assertThat(response.status()).isEqualTo("DISABLED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserStatus_activatingUserEnsuresGlobalMembership() {
|
||||
UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.PENDING);
|
||||
when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user));
|
||||
when(userAccountRepository.save(user)).thenReturn(user);
|
||||
|
||||
var response = service.updateUserStatus("user-1", "ACTIVE");
|
||||
|
||||
verify(userAccountRepository).save(user);
|
||||
verify(globalNamespaceMembershipService).ensureMember("user-1");
|
||||
assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(response.status()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUserStatus_rejectsSystemAccount() {
|
||||
when(userAccountRepository.findById("builtin-skill-publisher"))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
import com.iflytek.skillhub.SkillhubApplication;
|
||||
import com.iflytek.skillhub.TestRedisConfig;
|
||||
import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceType;
|
||||
import com.iflytek.skillhub.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserStatus;
|
||||
import com.iflytek.skillhub.infra.jpa.NamespaceJpaRepository;
|
||||
import com.iflytek.skillhub.infra.jpa.NamespaceMemberJpaRepository;
|
||||
import com.iflytek.skillhub.infra.jpa.UserAccountJpaRepository;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.SpyBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
@SpringBootTest(classes = SkillhubApplication.class)
|
||||
@ActiveProfiles("test")
|
||||
@Import(TestRedisConfig.class)
|
||||
class AdminUserApprovalIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private AdminUserAppService adminUserAppService;
|
||||
|
||||
@Autowired
|
||||
private UserAccountJpaRepository userAccountRepository;
|
||||
|
||||
@Autowired
|
||||
private NamespaceJpaRepository namespaceRepository;
|
||||
|
||||
@Autowired
|
||||
private NamespaceMemberJpaRepository namespaceMemberRepository;
|
||||
|
||||
@Autowired
|
||||
private TransactionTemplate transactionTemplate;
|
||||
|
||||
@SpyBean
|
||||
private GlobalNamespaceMembershipService globalNamespaceMembershipService;
|
||||
|
||||
private String userId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
userId = "pending-" + UUID.randomUUID();
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
ensureGlobalNamespace();
|
||||
UserAccount user = new UserAccount(userId, "Pending User", null, null);
|
||||
user.setStatus(UserStatus.PENDING);
|
||||
userAccountRepository.saveAndFlush(user);
|
||||
});
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
namespaceMemberRepository.findByUserId(userId)
|
||||
.forEach(namespaceMemberRepository::delete);
|
||||
userAccountRepository.deleteById(userId);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void activatingPendingUser_createsGlobalMembershipInSameWorkflow() {
|
||||
adminUserAppService.updateUserStatus(userId, "ACTIVE");
|
||||
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
UserAccount approved = userAccountRepository.findAllById(List.of(userId))
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Namespace global = namespaceRepository.findBySlug("global").orElseThrow();
|
||||
|
||||
assertThat(approved.getStatus()).isEqualTo(UserStatus.ACTIVE);
|
||||
assertThat(namespaceMemberRepository.findByNamespaceIdAndUserId(global.getId(), userId))
|
||||
.get()
|
||||
.extracting(member -> member.getRole())
|
||||
.isEqualTo(NamespaceRole.MEMBER);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvingActiveUserAgain_keepsSingleGlobalMembership() {
|
||||
adminUserAppService.updateUserStatus(userId, "ACTIVE");
|
||||
adminUserAppService.updateUserStatus(userId, "ACTIVE");
|
||||
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
Namespace global = namespaceRepository.findBySlug("global").orElseThrow();
|
||||
assertThat(namespaceMemberRepository.findByUserId(userId))
|
||||
.filteredOn(member -> member.getNamespaceId().equals(global.getId()))
|
||||
.singleElement()
|
||||
.extracting(member -> member.getRole())
|
||||
.isEqualTo(NamespaceRole.MEMBER);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void membershipFailure_rollsBackPendingUserActivation() {
|
||||
doAnswer(invocation -> {
|
||||
userAccountRepository.flush();
|
||||
throw new IllegalStateException("membership write failed");
|
||||
}).when(globalNamespaceMembershipService).ensureMember(userId);
|
||||
|
||||
assertThatThrownBy(() -> adminUserAppService.updateUserStatus(userId, "ACTIVE"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("membership write failed");
|
||||
|
||||
transactionTemplate.executeWithoutResult(status -> {
|
||||
UserAccount user = userAccountRepository.findAllById(List.of(userId))
|
||||
.stream()
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
Namespace global = namespaceRepository.findBySlug("global").orElseThrow();
|
||||
|
||||
assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
|
||||
assertThat(namespaceMemberRepository.findByNamespaceIdAndUserId(global.getId(), userId))
|
||||
.isEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
private void ensureGlobalNamespace() {
|
||||
if (namespaceRepository.findBySlug("global").isPresent()) {
|
||||
return;
|
||||
}
|
||||
Namespace global = new Namespace("global", "Global", null);
|
||||
global.setType(NamespaceType.GLOBAL);
|
||||
namespaceRepository.saveAndFlush(global);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue