diff --git a/openspec/changes/configure-auth-and-initial-roles/proposal.md b/openspec/changes/configure-auth-and-initial-roles/proposal.md index 93c92049..46ac2304 100644 --- a/openspec/changes/configure-auth-and-initial-roles/proposal.md +++ b/openspec/changes/configure-auth-and-initial-roles/proposal.md @@ -25,4 +25,4 @@ Issue #923 需要让采用外部身份认证的部署方分别关闭本地密码 ## Status -本变更记录已确认的产品边界和待实现验收条件;尚未实施。 +已按本变更实现功能,并完成相关单测、PostgreSQL 并发测试和本地 Compose/浏览器验收。真实第三方 OAuth 登录未在本地预览中接入,准入与角色授予由服务测试覆盖。 diff --git a/openspec/changes/configure-auth-and-initial-roles/tasks.md b/openspec/changes/configure-auth-and-initial-roles/tasks.md index 3e9a1330..63c3ed07 100644 --- a/openspec/changes/configure-auth-and-initial-roles/tasks.md +++ b/openspec/changes/configure-auth-and-initial-roles/tasks.md @@ -9,7 +9,7 @@ - [x] 在本地登录、direct 本地认证、注册和密码管理服务端入口执行对应开关;关闭密码登录时一并阻止会直接建会话的本地注册。 - [x] 在 OAuth 普通准入允许后的首次 ACTIVE 账号创建事务中匹配并消费角色规则,首次主体包含新角色。 - [x] 在统一身份核心的 `LEGACY`、`SHADOW`、`ACTIVE` 模式下核对公开 OAuth 接入点;保持旧身份绑定写入权威与同邮箱不自动合并的现有行为(`OAuthLoginFlowServiceTest`、`IdentityBindingServiceTest`)。 -- [ ] 保持已有账号、准入拒绝、未验证邮箱、停用规则、并发首次登录和人工角色修改的既定行为。 +- [x] 保持已有账号、准入拒绝、未验证邮箱、停用规则、并发首次登录和人工角色修改的既定行为(OAuth 和授权单测、PostgreSQL 并发首次登录与人工撤权测试)。 ## 3. API 与 Web @@ -20,6 +20,6 @@ ## 4. 验收 -- [ ] 验证两个开关的四种组合、直接 API 绕过尝试、已有会话、DB 故障与多实例设置可见性。 -- [ ] 验证首次登录授权、准入优先、已有账号不补授权、同邮箱独立账号、邮箱验证、规则停用、角色人工修改和并发首次登录。 -- [ ] 验证一次性部署初始化、角色规则删除后重启、非超管越权、审计记录和浏览器页面流程。 +- [x] 验证两个开关的四种组合、直接 API 绕过尝试、已有会话、DB 故障与多实例设置可见性(本地 Compose HTTP 实测;DB 故障与两个服务实例读取使用 `LocalAuthSettingsServiceTest`)。 +- [x] 验证首次登录授权、准入优先、已有账号不补授权、同邮箱独立账号、邮箱验证、规则停用、角色人工修改和并发首次登录(OAuth/授权单测与 PostgreSQL 真实事务测试;本地 Compose 未接入真实外部 IdP)。 +- [x] 验证一次性部署初始化、角色规则删除后重启、非超管越权、审计记录和浏览器页面流程(初始化单测、Compose 权限与审计实测、Playwright 浏览器操作)。 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SystemAuthSettingsPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SystemAuthSettingsPostgresTest.java index 28b67ce6..7175eb43 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SystemAuthSettingsPostgresTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/SystemAuthSettingsPostgresTest.java @@ -3,26 +3,45 @@ package com.iflytek.skillhub.repository; import static org.assertj.core.api.Assertions.assertThat; import com.iflytek.skillhub.auth.repository.RoleRepository; +import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; +import com.iflytek.skillhub.auth.repository.UserRoleBindingRepository; +import com.iflytek.skillhub.auth.identity.IdentityBindingService; +import com.iflytek.skillhub.auth.oauth.OAuthClaims; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRule; import com.iflytek.skillhub.auth.settings.ExternalRoleGrantRuleRepository; +import com.iflytek.skillhub.auth.settings.InitialExternalRoleGrantService; import com.iflytek.skillhub.auth.settings.SystemSetting; import com.iflytek.skillhub.auth.settings.SystemSettingRepository; +import com.iflytek.skillhub.domain.audit.AuditLogService; +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 jakarta.persistence.EntityManager; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.mockito.Mockito.mock; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -47,6 +66,9 @@ class SystemAuthSettingsPostgresTest { @Autowired private ExternalRoleGrantRuleRepository rules; @Autowired private RoleRepository roles; @Autowired private UserAccountRepository users; + @Autowired private IdentityBindingRepository identities; + @Autowired private UserRoleBindingRepository userRoles; + @Autowired private PlatformTransactionManager transactionManager; @Autowired private EntityManager entityManager; @Autowired private JdbcTemplate jdbc; @@ -91,4 +113,62 @@ class SystemAuthSettingsPostgresTest { assertThat(reloaded.getGrantedUserId()).isEqualTo(userId); assertThat(reloaded.getGrantedAt()).isNotNull(); } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + void concurrentFirstLoginConsumesRuleOnceAndDoesNotRestoreManuallyRemovedRole() throws Exception { + String suffix = UUID.randomUUID().toString(); + String email = suffix + "@example.com"; + String subject = "external-" + suffix; + TransactionTemplate transactions = new TransactionTemplate(transactionManager); + Long ruleId = transactions.execute(status -> rules.save(new ExternalRoleGrantRule( + "github", email, roles.findByCode("SUPER_ADMIN").orElseThrow(), "admin")).getId()); + IdentityBindingService bindingService = new IdentityBindingService( + identities, users, userRoles, mock(GlobalNamespaceMembershipService.class), + mock(ApplicationEventPublisher.class), transactionManager, + new InitialExternalRoleGrantService(rules, userRoles, mock(AuditLogService.class))); + OAuthClaims claims = new OAuthClaims("github", subject, email, true, "admin", Map.of()); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> firstLogin(bindingService, claims, ready, start)); + var second = executor.submit(() -> firstLogin(bindingService, claims, ready, start)); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + PlatformPrincipal firstPrincipal = first.get(20, TimeUnit.SECONDS); + PlatformPrincipal secondPrincipal = second.get(20, TimeUnit.SECONDS); + assertThat(firstPrincipal.userId()).isEqualTo(secondPrincipal.userId()); + assertThat(firstPrincipal.platformRoles()).contains("SUPER_ADMIN"); + assertThat(secondPrincipal.platformRoles()).contains("SUPER_ADMIN"); + + ExternalRoleGrantRule consumed = rules.findById(ruleId).orElseThrow(); + assertThat(consumed.getStatus()).isEqualTo(ExternalRoleGrantRule.Status.CONSUMED); + assertThat(consumed.getGrantedUserId()).isEqualTo(firstPrincipal.userId()); + assertThat(identities.findByProviderCodeAndSubject("github", subject)).isPresent(); + assertThat(userRoles.findByUserId(firstPrincipal.userId())).hasSize(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM identity_binding WHERE provider_code = ? AND subject = ?", + Long.class, "github", subject)).isEqualTo(1L); + + OAuthClaims anotherSubject = new OAuthClaims("github", "other-" + suffix, email, true, + "another-admin", Map.of()); + PlatformPrincipal independent = bindingService.bindOrCreate(anotherSubject, UserStatus.ACTIVE); + assertThat(independent.userId()).isNotEqualTo(firstPrincipal.userId()); + assertThat(independent.platformRoles()).doesNotContain("SUPER_ADMIN"); + assertThat(userRoles.findByUserId(independent.userId())).isEmpty(); + + transactions.executeWithoutResult(status -> userRoles.deleteByUserId(firstPrincipal.userId())); + PlatformPrincipal returning = bindingService.bindOrCreate(claims, UserStatus.ACTIVE); + assertThat(returning.platformRoles()).doesNotContain("SUPER_ADMIN"); + assertThat(userRoles.findByUserId(firstPrincipal.userId())).isEmpty(); + assertThat(rules.findById(ruleId).orElseThrow().getGrantedUserId()).isEqualTo(firstPrincipal.userId()); + } + } + + private static PlatformPrincipal firstLogin(IdentityBindingService service, OAuthClaims claims, + CountDownLatch ready, CountDownLatch start) throws Exception { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) throw new AssertionError("Timed out waiting for first login"); + return service.bindOrCreate(claims, UserStatus.ACTIVE); + } }