From b217339012c9bcd04e158b87e90db739487186a7 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:02:24 +0800 Subject: [PATCH] feat(auth): add profile sync and provisioning policies Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/03-authentication-design.md | 98 ++- .../admin/UserManagementController.java | 49 +- .../skillhub/service/AdminUserAppService.java | 79 ++- .../src/main/resources/application.yml | 6 + .../V47__user_profile_field_source.sql | 101 +++ ...leProvisioningPostgresIntegrationTest.java | 661 ++++++++++++++++++ ...ofileFieldSourceMigrationPostgresTest.java | 221 ++++++ .../admin/UserManagementControllerTest.java | 44 +- .../db/FlywayMigrationGuardrailTest.java | 16 + .../service/AdminUserAppServiceTest.java | 43 +- server/skillhub-auth/pom.xml | 4 + .../DefaultExternalIdentityLoginService.java | 75 +- .../DescriptorProvisioningPolicy.java | 13 + .../auth/identity/IdentityLoginMetrics.java | 56 ++ .../IdentityProviderPolicyProperties.java | 119 ++++ .../IdentityResolutionTransaction.java | 211 +++++- .../auth/identity/ProfileSyncMode.java | 9 + .../auth/identity/ProfileSyncPolicy.java | 22 + .../ProfileSynchronizationService.java | 282 ++++++++ .../auth/identity/ProviderDescriptor.java | 34 +- .../auth/identity/ProvisioningMode.java | 7 + .../auth/identity/ProvisioningPolicy.java | 6 + .../identity/ProvisioningPolicyContext.java | 15 + ...StaticTrustedProviderDescriptorSource.java | 26 +- .../skillhub/auth/policy/AccessDecision.java | 7 +- .../skillhub/auth/policy/AccessPolicy.java | 4 +- .../auth/policy/IdentityAccessContext.java | 37 +- .../auth/policy/IdentityAccessKind.java | 6 + ...faultExternalIdentityLoginServiceTest.java | 184 ++--- .../identity/IdentityLoginMetricsTest.java | 70 ++ .../IdentityResolutionTransactionTest.java | 236 ++++++- .../ProfileSynchronizationServiceTest.java | 383 ++++++++++ ...icTrustedProviderDescriptorSourceTest.java | 43 ++ .../auth/oauth/OAuthLoginFlowServiceTest.java | 35 + .../domain/user/ProfileReviewService.java | 10 +- .../domain/user/UserAccountRepository.java | 1 + .../user/UserProfileFieldAssurance.java | 8 + .../domain/user/UserProfileFieldName.java | 17 + .../domain/user/UserProfileFieldSource.java | 195 ++++++ .../domain/user/UserProfileFieldSourceId.java | 35 + .../UserProfileFieldSourceRepository.java | 15 + .../user/UserProfileFieldSourceService.java | 63 ++ .../user/UserProfileFieldSourceType.java | 8 + .../domain/user/UserProfileService.java | 9 +- .../domain/user/ProfileReviewServiceTest.java | 16 +- .../domain/user/UserProfileServiceTest.java | 24 +- .../infra/jpa/UserAccountJpaRepository.java | 8 + .../UserProfileFieldSourceJpaRepository.java | 15 + 48 files changed, 3358 insertions(+), 268 deletions(-) create mode 100644 server/skillhub-app/src/main/resources/db/migration/V47__user_profile_field_source.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityProfileProvisioningPostgresIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/UserProfileFieldSourceMigrationPostgresTest.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DescriptorProvisioningPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetrics.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderPolicyProperties.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncMode.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationService.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningMode.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicy.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicyContext.java create mode 100644 server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessKind.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetricsTest.java create mode 100644 server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationServiceTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldAssurance.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldName.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSource.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceId.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceType.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserProfileFieldSourceJpaRepository.java diff --git a/docs/03-authentication-design.md b/docs/03-authentication-design.md index 1edc16f5..8b371c1e 100644 --- a/docs/03-authentication-design.md +++ b/docs/03-authentication-design.md @@ -34,8 +34,8 @@ │ IdentityAssertion ▼ ┌─────────────────────────────┐ -│ Layer 3: Policy + Mapping │ 准入策略 + identity_binding -│ │ 兼容建号与资料同步 +│ Layer 3: Policy + Mapping │ Login / Provisioning Policy +│ │ Binding V2 + 字段来源与资料同步 └─────────────┬───────────────┘ │ PlatformPrincipal ▼ @@ -66,20 +66,21 @@ public record IdentityAccessContext( String subject, Optional email, EmailAssurance emailAssurance, - IdentityLoginContext requestContext + IdentityLoginContext requestContext, + IdentityAccessKind accessKind, + Optional existingAccountStatus ) {} public enum AccessDecision { - ALLOW, // 准入,继续创建/绑定平台用户 - DENY, // 拒绝,不建立 Session,重定向到拒绝页 - PENDING_APPROVAL // 等待管理员审批,不建立业务 Session + ALLOW, // 本次登录准入 + DENY // 拒绝,不建立 Session,重定向到拒绝页 } ``` ### 2.1 一期支持的策略(通过配置切换) ```yaml -astron: +skillhub: access-policy: mode: EMAIL_DOMAIN # OPEN / PROVIDER_ALLOWLIST / EMAIL_DOMAIN / SUBJECT_WHITELIST allowed-providers: @@ -99,12 +100,63 @@ astron: ### 2.2 准入失败处理 - `DENY`:抛出 `OAuth2AccessDeniedException`,由 `failureHandler` 重定向到 `/access-denied` 页面。不创建用户,不建立 Session。 -- `PENDING_APPROVAL`:首次登录创建 `user_account`(status=`PENDING`),但不建立业务 Session。抛出 `AccountPendingException`,由 `failureHandler` 重定向到 `/pending-approval` 页面(纯静态提示页,无需登录态)。管理员在后台审批时,系统在同一事务内把状态变为 `ACTIVE` 并补齐 `@global` 的 `MEMBER` membership;任一步失败都回滚。后续登录以已绑定账号的持久化状态为准:`ACTIVE` 正常建立 Session,`PENDING` 继续等待,`DISABLED` 拒绝登录;准入策略持续返回 `PENDING_APPROVAL` 不会覆盖已完成的管理员审批。 安全边界:PENDING / DISABLED / MERGED 用户和 system account 绝不会通过交互式登录获得 业务 Session。外部身份命中这些账号时,在更新用户资料或加载角色前直接拒绝。 -### 2.3 扩展性 +### 2.3 首次建号策略(Provisioning Policy) + +Login Policy 每次登录执行;Provisioning Policy 只在外部身份尚未绑定时执行。二者不能 +再通过 `AccessDecision` 混合表达。 + +| 模式 | 未绑定身份的行为 | +|------|------------------| +| `AUTO` | 创建 `ACTIVE` Account、Binding、typed Subjects 和 `@global MEMBER` | +| `APPROVAL` | 创建 `PENDING` Account、Binding 和 typed Subjects,不建立 Session | +| `EXISTING_BINDING_ONLY` | 不创建任何记录,返回 `ACCESS_DENIED` | + +`APPROVAL` 下,相同身份重复登录继续命中原 Binding 并返回 `ACCOUNT_PENDING`,不会重复 +建号。管理员批准时在同一事务把账号改为 `ACTIVE` 并补齐 `@global MEMBER`;拒绝时改为 +`DISABLED` 并保留 Binding,防止反复创建 PENDING 账号。 + +配置是受信 descriptor 的一部分,按 Provider Instance 生效: + +```yaml +skillhub: + auth: + identity: + providers: + corp-oidc: + provisioning-mode: APPROVAL + profile-sync: + display-name: PRESERVE_LOCAL + email: FILL_IF_EMPTY + avatar-url: INITIAL_ONLY +``` + +### 2.4 资料同步策略 + +`user_profile_field_source` 记录 `displayName`、`email`、`avatarUrl` 当前值来自 Provider、 +用户、管理员还是历史本地数据。升级迁移把已有非空值标记为 `LEGACY_LOCAL`,避免升级后 +第一次外部登录覆盖历史资料。 + +每个字段支持 `NEVER`、`INITIAL_ONLY`、`FILL_IF_EMPTY`、`PRESERVE_LOCAL` 和 +`PROVIDER_AUTHORITATIVE`。默认 displayName/avatarUrl 使用 `PRESERVE_LOCAL`,email 使用 +`FILL_IF_EMPTY`,且 email 只有 `VERIFIED` / `AUTHORITATIVE` 才能写入。显式设置 +`PROVIDER_AUTHORITATIVE` 后,登录 Provider 可以覆盖本地值;这项例外必须配置在具体 +Provider 和具体字段上。 + +### 2.5 Email 碰撞 + +未绑定身份携带可信 email,而平台已有相同 email 时,核心只返回 +`LinkRequired("EMAIL_COLLISION")`: + +- 不按 email 自动绑定账号; +- 不返回目标 userId、账号资料或可直接完成绑定的 token; +- 不创建 Account、Binding 或 Subject; +- PR 5 的显式 Identity Link 完成前只展示安全提示和已有账号登录入口。 + +### 2.6 扩展性 后续新增 OAuth Provider(Google、GitLab、微信)时,准入策略与 Provider 无关,统一在 AccessPolicy 层判定,不需要重做入驻逻辑。 @@ -137,15 +189,17 @@ CustomOAuth2UserService / CustomOidcUserService: ② 服务端路由解析 ResolvedProviderHandle ③ 统一身份核心读取受信 descriptor,执行 Authority pin/复核 ④ Assertion Factory 固定 provider/authority/subject/属性映射 - ⑤ AccessPolicy.evaluate(IdentityAccessContext) → 准入判定 + ⑤ 解析全部 Subject,锁定已有 Binding / Account + ⑥ Account Guard + AccessPolicy.evaluate(IdentityAccessContext) │ ├── DENY → 抛出 OAuth2AccessDeniedException → failureHandler 重定向 /access-denied(不建立 Session) - ├── PENDING_APPROVAL → 创建 PENDING 用户 → 抛出 AccountPendingException → failureHandler 重定向 /pending-approval(不建立 Session) └── ALLOW ↓ │ - ⑥ 查询 identity_binding 是否已绑定 - ├── 已绑定 → 加载平台用户,检查用户状态(DISABLED → 抛异常),同步最新头像/昵称 - └── 未绑定 → 创建 user_account(ACTIVE) + identity_binding + ⑦ 已绑定 → 按字段来源和 Profile Sync Policy 同步允许字段 + └── 未绑定 → Provisioning Policy + email collision 检查 + ├── AUTO → 创建 ACTIVE Account + Binding + Subjects + membership + ├── APPROVAL → 创建 PENDING Account + Binding + Subjects + └── EXISTING_BINDING_ONLY → 拒绝且不写入 │ ▼ AuthenticationSuccessHandler: @@ -162,9 +216,10 @@ OIDC 登录沿用同一条业务链路,但由 Spring Security 的 `oidcUserSer - 协议证据:只包含 `oidc`、认证时间和认证方法,不包含 token 或原始响应 - Provider code、issuer Authority 和最终属性映射由服务端受信 descriptor 固定 -现有 `identity_binding(provider_code, subject)` 继续保存历史和新登录绑定,不改变 -Subject 值。新增的 `identity_provider_state` 只保存 Provider code、protocol、 -canonical Authority、SHA-256 fingerprint 和状态,不保存 client secret 或 token。 +`identity_binding(provider_code, subject)` 保留兼容 primary 值, +`identity_binding_subject` 保存 typed primary/alias,并通过数据库约束保证一个 ACTIVE +Binding 恰有一个 ACTIVE primary。`identity_provider_state` 只保存 Provider code、 +protocol、canonical Authority、SHA-256 fingerprint 和状态,不保存 client secret 或 token。 同一 registration id 切换 issuer 时进入粘性的 `AUTHORITY_MISMATCH`,不展示登录方式, 也不接受回调;恢复旧 Authority 后仍需显式恢复操作。 @@ -340,15 +395,18 @@ Principal、Session、token、ticket、Cookie 或原始响应。核心内部按 Trusted descriptor → Authority Lock → IdentityAssertionFactory - → AccessPolicy - → identity_binding / 兼容建号 + → Binding / Subject resolution → AccountLoginGuard + → AccessPolicy + → ProvisioningPolicy / email collision + → ProfileSyncPolicy → PlatformPrincipalFactory → IdentityLoginOutcome ``` 只有 `IdentityLoginOutcome.Authenticated` 可以到达既有 `PlatformSessionService`。当前 -`identity_binding` 和 `PlatformPrincipal` 结构保持不变,以支持老版本升级和回滚。 +`PlatformPrincipal` 结构保持不变;Binding V2 和 profile source 使用 additive migration +及兼容列支持升级和回滚。 ### 4.1 多 Provider 账号合并策略 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java index 627d413c..b935e9af 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/UserManagementController.java @@ -12,6 +12,8 @@ import com.iflytek.skillhub.dto.ApiResponseFactory; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.exception.UnauthorizedException; import com.iflytek.skillhub.service.AdminUserAppService; +import com.iflytek.skillhub.service.AuditRequestContext; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -60,26 +62,57 @@ public class UserManagementController extends BaseApiController { @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") public ApiResponse updateUserStatus( @PathVariable String userId, - @Valid @RequestBody AdminUserStatusUpdateRequest request) { - return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, request.status())); + @AuthenticationPrincipal PlatformPrincipal principal, + @Valid @RequestBody AdminUserStatusUpdateRequest request, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminUserAppService.updateUserStatus( + userId, + request.status(), + principal.userId(), + AuditRequestContext.from(httpRequest))); } @PostMapping("/{userId}/approve") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") - public ApiResponse approveUser(@PathVariable String userId) { - return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + public ApiResponse approveUser( + @PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminUserAppService.updateUserStatus( + userId, + "ACTIVE", + principal.userId(), + AuditRequestContext.from(httpRequest))); } @PostMapping("/{userId}/disable") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") - public ApiResponse disableUser(@PathVariable String userId) { - return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED")); + public ApiResponse disableUser( + @PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminUserAppService.updateUserStatus( + userId, + "DISABLED", + principal.userId(), + AuditRequestContext.from(httpRequest))); } @PostMapping("/{userId}/enable") @PreAuthorize("hasAnyRole('USER_ADMIN', 'SUPER_ADMIN')") - public ApiResponse enableUser(@PathVariable String userId) { - return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE")); + public ApiResponse enableUser( + @PathVariable String userId, + @AuthenticationPrincipal PlatformPrincipal principal, + HttpServletRequest httpRequest) { + return ok("response.success.updated", + adminUserAppService.updateUserStatus( + userId, + "ACTIVE", + principal.userId(), + AuditRequestContext.from(httpRequest))); } @PostMapping("/{userId}/password-reset") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java index 33144089..38a0a6ba 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminUserAppService.java @@ -1,10 +1,13 @@ package com.iflytek.skillhub.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; 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.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -22,6 +25,7 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.slf4j.MDC; import java.util.List; import java.util.Locale; @@ -46,18 +50,24 @@ public class AdminUserAppService { private final UserRoleBindingRepository userRoleBindingRepository; private final RoleRepository roleRepository; private final GlobalNamespaceMembershipService globalNamespaceMembershipService; + private final AuditLogService auditLogService; + private final ObjectMapper objectMapper; public AdminUserAppService( AdminUserSearchRepository adminUserSearchRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, RoleRepository roleRepository, - GlobalNamespaceMembershipService globalNamespaceMembershipService) { + GlobalNamespaceMembershipService globalNamespaceMembershipService, + AuditLogService auditLogService, + ObjectMapper objectMapper) { this.adminUserSearchRepository = adminUserSearchRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.roleRepository = roleRepository; this.globalNamespaceMembershipService = globalNamespaceMembershipService; + this.auditLogService = auditLogService; + this.objectMapper = objectMapper; } @Transactional(readOnly = true) @@ -110,9 +120,23 @@ public class AdminUserAppService { @Transactional public AdminUserMutationResponse updateUserStatus(String userId, String status) { + return updateUserStatus( + userId, + status, + null, + null); + } + + @Transactional + public AdminUserMutationResponse updateUserStatus( + String userId, + String status, + String actorUserId, + AuditRequestContext auditContext) { UserAccount user = loadUser(userId); rejectSystemAccountMutation(user); UserStatus nextStatus = parseManageableStatus(status); + UserStatus previousStatus = user.getStatus(); if (nextStatus == UserStatus.ACTIVE && user.getStatus() == UserStatus.MERGED) { throw new DomainBadRequestException("error.admin.user.status.mergedCannotActivate"); } @@ -121,9 +145,62 @@ public class AdminUserAppService { if (nextStatus == UserStatus.ACTIVE) { globalNamespaceMembershipService.ensureMember(user.getId()); } + if (actorUserId != null) { + auditLogService.record( + actorUserId, + statusAuditAction( + previousStatus, + nextStatus), + "USER_ACCOUNT", + null, + MDC.get("requestId"), + auditContext != null + ? auditContext.clientIp() + : null, + auditContext != null + ? auditContext.userAgent() + : null, + statusAuditDetail( + userId, + previousStatus, + nextStatus)); + } return new AdminUserMutationResponse(user.getId(), null, nextStatus.name()); } + private String statusAuditAction( + UserStatus previousStatus, + UserStatus nextStatus) { + if (previousStatus == UserStatus.PENDING + && nextStatus == UserStatus.ACTIVE) { + return "IDENTITY_PROVISIONING_APPROVED"; + } + if (previousStatus == UserStatus.PENDING + && nextStatus == UserStatus.DISABLED) { + return "IDENTITY_PROVISIONING_REJECTED"; + } + return "USER_STATUS_UPDATED"; + } + + private String statusAuditDetail( + String userId, + UserStatus previousStatus, + UserStatus nextStatus) { + try { + return objectMapper.writeValueAsString(Map.of( + "userId", + userId, + "previousStatus", + previousStatus.name(), + "status", + nextStatus.name())); + } catch (JsonProcessingException exception) { + throw new IllegalStateException( + "Failed to serialize user status audit", + exception); + } + } + private UserStatus parseManageableStatus(String status) { UserStatus parsedStatus = parseStatus(status); if (!MANAGEABLE_STATUSES.contains(parsedStatus)) { diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 421e27f6..3cccb5dc 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -102,6 +102,12 @@ skillhub: auth: mock: enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false} + identity: + # Provider-specific overrides bind below providers.. + # Defaults: AUTO provisioning; PRESERVE_LOCAL displayName/avatarUrl; + # FILL_IF_EMPTY email. PROVIDER_AUTHORITATIVE must be enabled on the + # exact provider and field that owns the corresponding directory data. + providers: {} direct: enabled: ${SKILLHUB_AUTH_DIRECT_ENABLED:false} session-bootstrap: diff --git a/server/skillhub-app/src/main/resources/db/migration/V47__user_profile_field_source.sql b/server/skillhub-app/src/main/resources/db/migration/V47__user_profile_field_source.sql new file mode 100644 index 00000000..22ca216c --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V47__user_profile_field_source.sql @@ -0,0 +1,101 @@ +CREATE TABLE user_profile_field_source ( + user_id VARCHAR(128) NOT NULL, + field_name VARCHAR(32) NOT NULL, + source_type VARCHAR(32) NOT NULL, + provider_code VARCHAR(64), + assurance VARCHAR(32), + last_synchronized_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, field_name), + CONSTRAINT fk_user_profile_field_source_user + FOREIGN KEY (user_id) + REFERENCES user_account(id) + ON DELETE CASCADE, + CONSTRAINT fk_user_profile_field_source_provider + FOREIGN KEY (provider_code) + REFERENCES identity_provider_state(provider_code), + CONSTRAINT chk_user_profile_field_name + CHECK (field_name IN ('displayName', 'email', 'avatarUrl')), + CONSTRAINT chk_user_profile_field_source_type + CHECK ( + source_type IN ( + 'PROVIDER', + 'USER', + 'ADMIN', + 'LEGACY_LOCAL' + ) + ), + CONSTRAINT chk_user_profile_field_assurance + CHECK ( + assurance IS NULL + OR assurance IN ( + 'UNVERIFIED', + 'PROVIDER_ASSERTED', + 'VERIFIED', + 'AUTHORITATIVE' + ) + ), + CONSTRAINT chk_user_profile_field_provider_source + CHECK ( + ( + source_type = 'PROVIDER' + AND provider_code IS NOT NULL + AND assurance IS NOT NULL + AND last_synchronized_at IS NOT NULL + ) + OR + ( + source_type <> 'PROVIDER' + AND provider_code IS NULL + AND assurance IS NULL + AND last_synchronized_at IS NULL + ) + ) +); + +CREATE INDEX idx_user_profile_field_source_provider + ON user_profile_field_source(provider_code) + WHERE provider_code IS NOT NULL; + +INSERT INTO user_profile_field_source ( + user_id, + field_name, + source_type, + updated_at +) +SELECT + id, + 'displayName', + 'LEGACY_LOCAL', + updated_at +FROM user_account; + +INSERT INTO user_profile_field_source ( + user_id, + field_name, + source_type, + updated_at +) +SELECT + id, + 'email', + 'LEGACY_LOCAL', + updated_at +FROM user_account +WHERE email IS NOT NULL + AND btrim(email) <> ''; + +INSERT INTO user_profile_field_source ( + user_id, + field_name, + source_type, + updated_at +) +SELECT + id, + 'avatarUrl', + 'LEGACY_LOCAL', + updated_at +FROM user_account +WHERE avatar_url IS NOT NULL + AND btrim(avatar_url) <> ''; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityProfileProvisioningPostgresIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityProfileProvisioningPostgresIntegrationTest.java new file mode 100644 index 00000000..5ac7b1ad --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/IdentityProfileProvisioningPostgresIntegrationTest.java @@ -0,0 +1,661 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.iflytek.skillhub.service.AdminUserAppService; +import com.iflytek.skillhub.service.AuditRequestContext; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@EnabledIfEnvironmentVariable( + named = "IDENTITY_BINDING_V2_POSTGRES_URL", + matches = "jdbc:postgresql:.*") +class IdentityProfileProvisioningPostgresIntegrationTest { + + private static final String SCHEMA = + "identity_profile_p3_integration"; + private static final Set PROVIDERS = Set.of( + "profile-auto", + "profile-approval", + "profile-existing-only", + "profile-collision", + "profile-preserve"); + + @Autowired + private IdentityResolutionTransaction transaction; + + @Autowired + private AdminUserAppService adminUserAppService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @DynamicPropertySource + static void postgresProperties( + DynamicPropertyRegistry registry) { + String url = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"); + String username = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"); + String password = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD"); + createSchema(url, username, password); + registry.add( + "spring.datasource.url", + () -> withCurrentSchema(url)); + registry.add( + "spring.datasource.username", + () -> username); + registry.add( + "spring.datasource.password", + () -> password); + registry.add( + "spring.datasource.driver-class-name", + () -> "org.postgresql.Driver"); + registry.add( + "spring.jpa.database-platform", + () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add( + "spring.jpa.hibernate.ddl-auto", + () -> "validate"); + registry.add( + "spring.flyway.enabled", + () -> "true"); + registry.add( + "spring.flyway.default-schema", + () -> SCHEMA); + registry.add( + "spring.flyway.schemas", + () -> SCHEMA); + } + + @BeforeEach + void seedProviderStates() { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + id, + display_name, + status, + created_at, + updated_at + ) VALUES ( + 'profile-test-admin', + 'Profile Test Admin', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + ON CONFLICT (id) DO NOTHING + """); + for (String provider : PROVIDERS) { + jdbcTemplate.update( + """ + INSERT INTO identity_provider_state ( + provider_code, + protocol, + authority, + authority_fingerprint, + state + ) VALUES (?, 'oidc', ?, ?, 'READY') + ON CONFLICT (provider_code) DO NOTHING + """, + provider, + "https://" + provider + ".example.com", + "a".repeat(64)); + } + } + + @AfterAll + static void dropSchema() { + String url = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"); + String username = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"); + String password = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD"); + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute( + "DROP SCHEMA IF EXISTS " + + SCHEMA + + " CASCADE"); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to remove identity profile test schema", + exception); + } + } + + @Test + void approvalIsIdempotentAndAdminApprovalAddsMembership() { + String provider = "profile-approval"; + String subject = "approval-user"; + ProviderDescriptor descriptor = descriptor( + provider, + ProvisioningMode.APPROVAL, + ProfileSyncPolicy.defaults()); + IdentityAssertion assertion = assertion( + provider, + subject, + "Approval User", + "approval@example.com", + EmailAssurance.VERIFIED); + + IdentityLoginOutcome first = transaction.resolve( + assertion, + descriptor, + IdentityLoginContext.empty()); + IdentityLoginOutcome repeated = transaction.resolve( + assertion, + descriptor, + IdentityLoginContext.empty()); + + assertThat(first).isEqualTo( + new IdentityLoginOutcome.PendingApproval( + "ACCOUNT_PENDING")); + assertThat(repeated).isEqualTo(first); + String userId = userId(provider, subject); + assertThat(count( + "SELECT COUNT(*) FROM user_account WHERE id = ? AND status = 'PENDING'", + userId)).isEqualTo(1L); + assertThat(count( + "SELECT COUNT(*) FROM identity_binding WHERE user_id = ?", + userId)).isEqualTo(1L); + assertThat(count( + "SELECT COUNT(*) FROM user_profile_field_source WHERE user_id = ?", + userId)).isEqualTo(3L); + assertThat(count( + "SELECT COUNT(*) FROM namespace_member WHERE user_id = ?", + userId)).isZero(); + + adminUserAppService.updateUserStatus( + userId, + "ACTIVE", + "profile-test-admin", + new AuditRequestContext( + "127.0.0.1", + "identity-profile-test")); + + assertThat(count( + """ + SELECT COUNT(*) + FROM namespace_member member + JOIN namespace namespace + ON namespace.id = member.namespace_id + WHERE member.user_id = ? + AND namespace.slug = 'global' + AND member.role = 'MEMBER' + """, + userId)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE actor_user_id = 'profile-test-admin' + AND action = + 'IDENTITY_PROVISIONING_APPROVED' + AND detail_json ->> 'userId' = ? + """, + userId)).isEqualTo(1L); + assertThat(transaction.resolve( + assertion, + descriptor, + IdentityLoginContext.empty())) + .isInstanceOf( + IdentityLoginOutcome.Authenticated.class); + } + + @Test + void rejectedPendingAccountKeepsBindingAndCannotReprovision() { + String provider = "profile-approval"; + String subject = "rejected-user"; + ProviderDescriptor descriptor = descriptor( + provider, + ProvisioningMode.APPROVAL, + ProfileSyncPolicy.defaults()); + IdentityAssertion assertion = assertion( + provider, + subject, + "Rejected User", + "rejected@example.com", + EmailAssurance.VERIFIED); + transaction.resolve( + assertion, + descriptor, + IdentityLoginContext.empty()); + String userId = userId(provider, subject); + + adminUserAppService.updateUserStatus( + userId, + "DISABLED", + "profile-test-admin", + new AuditRequestContext( + "127.0.0.1", + "identity-profile-test")); + + assertThatThrownBy(() -> transaction.resolve( + assertion, + descriptor, + IdentityLoginContext.empty())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo( + IdentityFailureCode.ACCOUNT_DISABLED); + assertThat(count( + "SELECT COUNT(*) FROM identity_binding WHERE user_id = ? AND status = 'ACTIVE'", + userId)).isEqualTo(1L); + assertThat(count( + """ + SELECT COUNT(*) + FROM audit_log + WHERE actor_user_id = 'profile-test-admin' + AND action = + 'IDENTITY_PROVISIONING_REJECTED' + AND detail_json ->> 'userId' = ? + """, + userId)).isEqualTo(1L); + } + + @Test + void existingBindingOnlyLeavesNoAccountOrBinding() { + String provider = "profile-existing-only"; + String subject = "unknown-user"; + + assertThatThrownBy(() -> transaction.resolve( + assertion( + provider, + subject, + "Unknown User", + "unknown@example.com", + EmailAssurance.VERIFIED), + descriptor( + provider, + ProvisioningMode.EXISTING_BINDING_ONLY, + ProfileSyncPolicy.defaults()), + IdentityLoginContext.empty())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.ACCESS_DENIED); + assertThat(count( + "SELECT COUNT(*) FROM identity_binding WHERE provider_code = ?", + provider)).isZero(); + assertThat(count( + "SELECT COUNT(*) FROM user_account WHERE email = ?", + "unknown@example.com")).isZero(); + } + + @Test + void verifiedEmailCollisionReturnsSafeOutcomeWithoutWriting() { + String provider = "profile-collision"; + jdbcTemplate.update( + """ + INSERT INTO user_account ( + id, + display_name, + email, + status, + created_at, + updated_at + ) VALUES ( + 'existing-collision-user', + 'Existing User', + 'collision@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + + IdentityLoginOutcome outcome = transaction.resolve( + assertion( + provider, + "collision-subject", + "Collision User", + "collision@example.com", + EmailAssurance.VERIFIED), + descriptor( + provider, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()), + IdentityLoginContext.empty()); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.LinkRequired( + "EMAIL_COLLISION")); + assertThat(count( + "SELECT COUNT(*) FROM identity_binding WHERE provider_code = ?", + provider)).isZero(); + assertThat(count( + "SELECT COUNT(*) FROM user_account WHERE email = ?", + "collision@example.com")).isEqualTo(1L); + } + + @Test + void returningLoginPreservesLocallyMaintainedDisplayName() { + String provider = "profile-preserve"; + String subject = "preserve-user"; + ProviderDescriptor descriptor = descriptor( + provider, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()); + transaction.resolve( + assertion( + provider, + subject, + "Provider Name", + "preserve@example.com", + EmailAssurance.VERIFIED), + descriptor, + IdentityLoginContext.empty()); + String userId = userId(provider, subject); + jdbcTemplate.update( + """ + UPDATE user_account + SET display_name = 'Local Name' + WHERE id = ? + """, + userId); + jdbcTemplate.update( + """ + UPDATE user_profile_field_source + SET + source_type = 'USER', + provider_code = NULL, + assurance = NULL, + last_synchronized_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + AND field_name = 'displayName' + """, + userId); + + transaction.resolve( + assertion( + provider, + subject, + "Changed Provider Name", + "preserve@example.com", + EmailAssurance.VERIFIED), + descriptor, + IdentityLoginContext.empty()); + + assertThat(jdbcTemplate.queryForObject( + """ + SELECT display_name + FROM user_account + WHERE id = ? + """, + String.class, + userId)).isEqualTo("Local Name"); + assertThat(jdbcTemplate.queryForObject( + """ + SELECT source_type + FROM user_profile_field_source + WHERE user_id = ? + AND field_name = 'displayName' + """, + String.class, + userId)).isEqualTo("USER"); + } + + @Test + void returningLoginBackfillsRowsCreatedDuringRollbackWindow() { + String provider = "profile-preserve"; + String subject = "rollback-window-user"; + jdbcTemplate.update( + """ + INSERT INTO user_account ( + id, + display_name, + email, + status, + created_at, + updated_at + ) VALUES ( + 'rollback-window-account', + 'Rollback Window Name', + 'rollback-window@example.com', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + jdbcTemplate.update( + """ + WITH created_binding AS ( + INSERT INTO identity_binding ( + user_id, + provider_code, + subject, + login_name, + status, + created_at, + updated_at + ) VALUES ( + 'rollback-window-account', + ?, + ?, + 'rollback-window', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + RETURNING id, provider_code + ) + INSERT INTO identity_binding_subject ( + binding_id, + provider_code, + subject_type, + subject_value, + is_primary, + status, + created_at + ) + SELECT + id, + provider_code, + 'oidc_sub', + ?, + TRUE, + 'ACTIVE', + CURRENT_TIMESTAMP + FROM created_binding + """, + provider, + subject, + subject); + + transaction.resolve( + assertion( + provider, + subject, + "New Provider Name", + "new-provider@example.com", + EmailAssurance.VERIFIED), + descriptor( + provider, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()), + IdentityLoginContext.empty()); + + assertThat(jdbcTemplate.queryForObject( + """ + SELECT display_name + FROM user_account + WHERE id = 'rollback-window-account' + """, + String.class)).isEqualTo( + "Rollback Window Name"); + assertThat(count( + """ + SELECT COUNT(*) + FROM user_profile_field_source + WHERE user_id = 'rollback-window-account' + AND source_type = 'LEGACY_LOCAL' + """)).isEqualTo(2L); + } + + @Test + void profileSourceFailureRollsBackAccountAndBinding() { + String provider = "profile-missing-provider"; + String subject = "rollback-user"; + + assertThatThrownBy(() -> transaction.resolve( + assertion( + provider, + subject, + "Rollback User", + "rollback@example.com", + EmailAssurance.VERIFIED), + descriptor( + provider, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()), + IdentityLoginContext.empty())) + .isInstanceOf(RuntimeException.class); + + assertThat(count( + "SELECT COUNT(*) FROM identity_binding WHERE provider_code = ?", + provider)).isZero(); + assertThat(count( + "SELECT COUNT(*) FROM user_account WHERE email = ?", + "rollback@example.com")).isZero(); + assertThat(count( + "SELECT COUNT(*) FROM user_profile_field_source WHERE provider_code = ?", + provider)).isZero(); + } + + private String userId( + String provider, + String subject) { + return jdbcTemplate.queryForObject( + """ + SELECT user_id + FROM identity_binding + WHERE provider_code = ? + AND subject = ? + """, + String.class, + provider, + subject); + } + + private long count(String sql, Object... arguments) { + Long result = jdbcTemplate.queryForObject( + sql, + Long.class, + arguments); + return result == null ? 0L : result; + } + + private static ProviderDescriptor descriptor( + String provider, + ProvisioningMode provisioningMode, + ProfileSyncPolicy profileSyncPolicy) { + return new ProviderDescriptor( + provider, + "oidc", + "https://" + provider + ".example.com", + provider, + "oidc_sub", + "oidc_sub", + Map.of( + "oidc_sub", + SubjectCanonicalizer.EXACT), + List.of("name"), + List.of("email"), + List.of(), + EmailAssurance.VERIFIED, + provisioningMode, + profileSyncPolicy); + } + + private static IdentityAssertion assertion( + String provider, + String subject, + String displayName, + String email, + EmailAssurance assurance) { + return new IdentityAssertion( + new ProviderReference( + provider, + "oidc", + "https://" + provider + ".example.com"), + new ExternalSubject("oidc_sub", subject), + Set.of(), + new ExternalProfile( + displayName, + Optional.of(new EmailClaim( + email, + assurance)), + Optional.empty()), + Map.of(), + new AuthenticationEvidence( + "oidc", + Instant.now(), + Set.of("oidc_authorization_code"))); + } + + private static void createSchema( + String url, + String username, + String password) { + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute( + "CREATE SCHEMA IF NOT EXISTS " + SCHEMA); + } catch (Exception exception) { + throw new IllegalStateException( + "Failed to create identity profile test schema", + exception); + } + } + + private static String withCurrentSchema(String url) { + return url + + (url.contains("?") ? "&" : "?") + + "currentSchema=" + + SCHEMA; + } + + private static String requiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "Missing required environment variable " + name); + } + return value; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/UserProfileFieldSourceMigrationPostgresTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/UserProfileFieldSourceMigrationPostgresTest.java new file mode 100644 index 00000000..7fb876b7 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/auth/identity/UserProfileFieldSourceMigrationPostgresTest.java @@ -0,0 +1,221 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.MigrationVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable( + named = "IDENTITY_BINDING_V2_POSTGRES_URL", + matches = "jdbc:postgresql:.*") +class UserProfileFieldSourceMigrationPostgresTest { + + private static final String SCHEMA = + "identity_profile_v47_migration"; + + @Test + void backfillsLegacyFieldsAndEnforcesSourceMetadata() + throws Exception { + String url = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_URL"); + String username = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_USERNAME"); + String password = requiredEnvironment( + "IDENTITY_BINDING_V2_POSTGRES_PASSWORD"); + dropSchema(url, username, password); + try { + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration") + .schemas(SCHEMA) + .defaultSchema(SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("46")) + .load() + .migrate(); + + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute("SET search_path TO " + SCHEMA); + statement.executeUpdate(""" + INSERT INTO user_account ( + id, + display_name, + email, + avatar_url, + status, + created_at, + updated_at + ) VALUES + ( + 'legacy-complete', + 'Legacy Complete', + 'legacy@example.com', + 'https://example.com/avatar.png', + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ), + ( + 'legacy-minimal', + 'Legacy Minimal', + NULL, + NULL, + 'ACTIVE', + CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP + ) + """); + } + + Flyway.configure() + .dataSource(url, username, password) + .locations("classpath:db/migration") + .schemas(SCHEMA) + .defaultSchema(SCHEMA) + .createSchemas(true) + .target(MigrationVersion.fromVersion("47")) + .load() + .migrate(); + + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute("SET search_path TO " + SCHEMA); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM user_profile_field_source + WHERE user_id = 'legacy-complete' + AND source_type = 'LEGACY_LOCAL' + AND provider_code IS NULL + AND assurance IS NULL + AND last_synchronized_at IS NULL + """)).isEqualTo(3L); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM user_profile_field_source + WHERE user_id = 'legacy-minimal' + AND field_name = 'displayName' + AND source_type = 'LEGACY_LOCAL' + """)).isEqualTo(1L); + + statement.executeUpdate(""" + INSERT INTO identity_provider_state ( + provider_code, + protocol, + authority, + authority_fingerprint, + state + ) VALUES ( + 'github', + 'oauth2-github', + 'https://github.com', + repeat('a', 64), + 'READY' + ) + """); + statement.executeUpdate(""" + UPDATE user_profile_field_source + SET + source_type = 'PROVIDER', + provider_code = 'github', + assurance = 'VERIFIED', + last_synchronized_at = CURRENT_TIMESTAMP + WHERE user_id = 'legacy-complete' + AND field_name = 'email' + """); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM user_profile_field_source + WHERE user_id = 'legacy-complete' + AND field_name = 'email' + AND source_type = 'PROVIDER' + AND provider_code = 'github' + AND assurance = 'VERIFIED' + """)).isEqualTo(1L); + + assertThatThrownBy(() -> statement.executeUpdate(""" + UPDATE user_profile_field_source + SET + source_type = 'USER', + provider_code = 'github' + WHERE user_id = 'legacy-complete' + AND field_name = 'avatarUrl' + """)).hasMessageContaining( + "chk_user_profile_field_provider_source"); + + statement.executeUpdate(""" + DELETE FROM user_account + WHERE id = 'legacy-minimal' + """); + assertThat(singleLong( + statement, + """ + SELECT COUNT(*) + FROM user_profile_field_source + WHERE user_id = 'legacy-minimal' + """)).isZero(); + } + } finally { + dropSchema(url, username, password); + } + } + + private static long singleLong( + Statement statement, + String sql) throws Exception { + try (ResultSet result = statement.executeQuery(sql)) { + assertThat(result.next()).isTrue(); + return result.getLong(1); + } + } + + private static String requiredEnvironment(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException( + "Missing required environment variable " + name); + } + return value; + } + + private static void dropSchema( + String url, + String username, + String password) throws Exception { + try (Connection connection = + DriverManager.getConnection( + url, + username, + password); + Statement statement = + connection.createStatement()) { + statement.execute( + "DROP SCHEMA IF EXISTS " + + SCHEMA + + " CASCADE"); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 1f01ff04..b3e6e1d4 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -9,6 +9,7 @@ import com.iflytek.skillhub.dto.AdminUserMutationResponse; import com.iflytek.skillhub.dto.AdminUserSummaryResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.service.AdminUserAppService; +import com.iflytek.skillhub.service.AuditRequestContext; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -34,6 +35,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; @SpringBootTest @AutoConfigureMockMvc @@ -165,7 +167,11 @@ class UserManagementControllerTest { String requestBody = "{\"status\":\"DISABLED\"}"; - when(adminUserAppService.updateUserStatus("user-123", "DISABLED")) + when(adminUserAppService.updateUserStatus( + "user-123", + "DISABLED", + "user-42", + any(AuditRequestContext.class))) .thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED")); mockMvc.perform(put("/api/v1/admin/users/user-123/status") @@ -188,7 +194,11 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - when(adminUserAppService.updateUserStatus("user-123", "ACTIVE")) + when(adminUserAppService.updateUserStatus( + "user-123", + "ACTIVE", + "user-42", + any(AuditRequestContext.class))) .thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE")); mockMvc.perform(post("/api/v1/admin/users/user-123/approve") @@ -199,7 +209,11 @@ class UserManagementControllerTest { .andExpect(jsonPath("$.data.userId").value("user-123")) .andExpect(jsonPath("$.data.status").value("ACTIVE")); - verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); + verify(adminUserAppService).updateUserStatus( + "user-123", + "ACTIVE", + "user-42", + any(AuditRequestContext.class)); } @Test @@ -211,7 +225,11 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - when(adminUserAppService.updateUserStatus("user-123", "DISABLED")) + when(adminUserAppService.updateUserStatus( + "user-123", + "DISABLED", + "user-42", + any(AuditRequestContext.class))) .thenReturn(new AdminUserMutationResponse("user-123", null, "DISABLED")); mockMvc.perform(post("/api/v1/admin/users/user-123/disable") @@ -222,7 +240,11 @@ class UserManagementControllerTest { .andExpect(jsonPath("$.data.userId").value("user-123")) .andExpect(jsonPath("$.data.status").value("DISABLED")); - verify(adminUserAppService).updateUserStatus("user-123", "DISABLED"); + verify(adminUserAppService).updateUserStatus( + "user-123", + "DISABLED", + "user-42", + any(AuditRequestContext.class)); } @Test @@ -234,7 +256,11 @@ class UserManagementControllerTest { principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER_ADMIN")) ); - when(adminUserAppService.updateUserStatus("user-123", "ACTIVE")) + when(adminUserAppService.updateUserStatus( + "user-123", + "ACTIVE", + "user-42", + any(AuditRequestContext.class))) .thenReturn(new AdminUserMutationResponse("user-123", null, "ACTIVE")); mockMvc.perform(post("/api/v1/admin/users/user-123/enable") @@ -245,7 +271,11 @@ class UserManagementControllerTest { .andExpect(jsonPath("$.data.userId").value("user-123")) .andExpect(jsonPath("$.data.status").value("ACTIVE")); - verify(adminUserAppService).updateUserStatus("user-123", "ACTIVE"); + verify(adminUserAppService).updateUserStatus( + "user-123", + "ACTIVE", + "user-42", + any(AuditRequestContext.class)); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java index 2029103c..eb8627af 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/db/FlywayMigrationGuardrailTest.java @@ -115,6 +115,22 @@ class FlywayMigrationGuardrailTest { .contains("Binding V2 contract preflight failed"); } + @Test + void profileFieldSourceMigration_mustBackfillLegacyValues() + throws IOException { + String migration = Files.readString( + migrationPath( + "V47__user_profile_field_source.sql")); + + assertThat(migration) + .contains("LEGACY_LOCAL") + .contains("'displayName'") + .contains("'email'") + .contains("'avatarUrl'") + .contains( + "chk_user_profile_field_provider_source"); + } + private List migrationFiles() throws IOException { Path root = repoRoot() .resolve("server") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index a832b948..f07b822b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -1,10 +1,12 @@ package com.iflytek.skillhub.service; +import com.fasterxml.jackson.databind.ObjectMapper; 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.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -38,12 +40,16 @@ class AdminUserAppServiceTest { private final UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); private final GlobalNamespaceMembershipService globalNamespaceMembershipService = mock(GlobalNamespaceMembershipService.class); + private final AuditLogService auditLogService = + mock(AuditLogService.class); private final AdminUserAppService service = new AdminUserAppService( adminUserSearchRepository, userAccountRepository, userRoleBindingRepository, roleRepository, - globalNamespaceMembershipService + globalNamespaceMembershipService, + auditLogService, + new ObjectMapper() ); @Test @@ -182,6 +188,41 @@ class AdminUserAppServiceTest { assertThat(response.status()).isEqualTo("ACTIVE"); } + @Test + void updateUserStatus_approvingPendingAccountRecordsAudit() { + 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); + + service.updateUserStatus( + "user-1", + "ACTIVE", + "admin-1", + new AuditRequestContext( + "127.0.0.1", + "test-agent")); + + verify(auditLogService).record( + eq("admin-1"), + eq("IDENTITY_PROVISIONING_APPROVED"), + eq("USER_ACCOUNT"), + isNull(), + any(), + eq("127.0.0.1"), + eq("test-agent"), + argThat(detail -> detail.contains( + "\"userId\":\"user-1\"") + && detail.contains( + "\"previousStatus\":\"PENDING\"") + && detail.contains( + "\"status\":\"ACTIVE\""))); + } + @Test void updateUserStatus_rejectsReactivatingMergedAccount() { UserAccount user = user("user-1", "alice", "alice@example.com", UserStatus.MERGED); diff --git a/server/skillhub-auth/pom.xml b/server/skillhub-auth/pom.xml index 3bee6a9e..83f59530 100644 --- a/server/skillhub-auth/pom.xml +++ b/server/skillhub-auth/pom.xml @@ -39,6 +39,10 @@ org.springframework.boot spring-boot-starter-mail + + io.micrometer + micrometer-core + org.springframework.boot spring-boot-configuration-processor diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java index eef0953e..94def7a7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginService.java @@ -1,9 +1,5 @@ package com.iflytek.skillhub.auth.identity; -import com.iflytek.skillhub.auth.policy.AccessDecision; -import com.iflytek.skillhub.auth.policy.AccessPolicy; -import com.iflytek.skillhub.auth.policy.IdentityAccessContext; -import com.iflytek.skillhub.domain.user.UserStatus; import java.sql.SQLException; import java.util.Objects; import org.springframework.dao.DataIntegrityViolationException; @@ -16,20 +12,20 @@ class DefaultExternalIdentityLoginService private final TrustedProviderDescriptorSource descriptorSource; private final ProviderAuthorityLockService authorityLockService; private final IdentityAssertionFactory assertionFactory; - private final AccessPolicy accessPolicy; private final IdentityResolutionTransaction resolutionTransaction; + private final IdentityLoginMetrics metrics; DefaultExternalIdentityLoginService( TrustedProviderDescriptorSource descriptorSource, ProviderAuthorityLockService authorityLockService, IdentityAssertionFactory assertionFactory, - AccessPolicy accessPolicy, - IdentityResolutionTransaction resolutionTransaction) { + IdentityResolutionTransaction resolutionTransaction, + IdentityLoginMetrics metrics) { this.descriptorSource = descriptorSource; this.authorityLockService = authorityLockService; this.assertionFactory = assertionFactory; - this.accessPolicy = accessPolicy; this.resolutionTransaction = resolutionTransaction; + this.metrics = metrics; } @Override @@ -41,26 +37,42 @@ class DefaultExternalIdentityLoginService Objects.requireNonNull(result, "result"); Objects.requireNonNull(context, "context"); - ProviderDescriptor descriptor = descriptorSource.require(provider); - authorityLockService.requirePinnedAuthority(descriptor); - IdentityAssertion assertion = - assertionFactory.create(descriptor, result); - AccessDecision decision = accessPolicy.evaluate( - toAccessContext(assertion, context)); - if (decision == AccessDecision.DENY) { - throw new IdentityCoreException( - IdentityFailureCode.ACCESS_DENIED); + String metricProvider = "unresolved"; + try { + ProviderDescriptor descriptor = + descriptorSource.require(provider); + metricProvider = descriptor.providerCode(); + authorityLockService.requirePinnedAuthority(descriptor); + IdentityAssertion assertion = + assertionFactory.create(descriptor, result); + IdentityLoginOutcome outcome = resolveWithRetry( + assertion, + descriptor, + context); + metrics.recordOutcome( + descriptor.providerCode(), + outcome); + return outcome; + } catch (IdentityCoreException exception) { + metrics.recordFailure( + metricProvider, + exception.getReasonCode()); + throw exception; + } catch (RuntimeException exception) { + metrics.recordSystemError(metricProvider); + throw exception; } + } - UserStatus initialStatus = - decision == AccessDecision.PENDING_APPROVAL - ? UserStatus.PENDING - : UserStatus.ACTIVE; + private IdentityLoginOutcome resolveWithRetry( + IdentityAssertion assertion, + ProviderDescriptor descriptor, + IdentityLoginContext context) { try { return resolutionTransaction.resolve( assertion, - initialStatus, - descriptor.legacyPrimarySubjectType()); + descriptor, + context); } catch (DataIntegrityViolationException firstConflict) { if (!isUniqueConstraintViolation(firstConflict)) { throw firstConflict; @@ -68,8 +80,8 @@ class DefaultExternalIdentityLoginService try { return resolutionTransaction.resolve( assertion, - initialStatus, - descriptor.legacyPrimarySubjectType()); + descriptor, + context); } catch (DataIntegrityViolationException repeatedConflict) { if (!isUniqueConstraintViolation(repeatedConflict)) { repeatedConflict.addSuppressed(firstConflict); @@ -99,17 +111,4 @@ class DefaultExternalIdentityLoginService return false; } - private IdentityAccessContext toAccessContext( - IdentityAssertion assertion, - IdentityLoginContext context) { - return new IdentityAccessContext( - assertion.provider().providerCode(), - assertion.primarySubject().type(), - assertion.primarySubject().value(), - assertion.profile().email().map(EmailClaim::value), - assertion.profile().email() - .map(EmailClaim::assurance) - .orElse(EmailAssurance.UNVERIFIED), - context); - } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DescriptorProvisioningPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DescriptorProvisioningPolicy.java new file mode 100644 index 00000000..3d432afb --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/DescriptorProvisioningPolicy.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.auth.identity; + +import org.springframework.stereotype.Component; + +@Component +class DescriptorProvisioningPolicy implements ProvisioningPolicy { + + @Override + public ProvisioningMode evaluate( + ProvisioningPolicyContext context) { + return context.configuredMode(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetrics.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetrics.java new file mode 100644 index 00000000..08a8eb2e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetrics.java @@ -0,0 +1,56 @@ +package com.iflytek.skillhub.auth.identity; + +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Locale; +import org.springframework.stereotype.Component; + +@Component +class IdentityLoginMetrics { + + private final MeterRegistry meterRegistry; + + IdentityLoginMetrics(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + void recordOutcome( + String providerCode, + IdentityLoginOutcome outcome) { + String result; + if (outcome instanceof + IdentityLoginOutcome.Authenticated authenticated) { + result = authenticated.accountCreated() + ? "provisioned" + : "authenticated"; + } else if (outcome instanceof + IdentityLoginOutcome.PendingApproval) { + result = "pending"; + } else { + result = "link_required"; + } + counter(providerCode, result); + } + + void recordFailure( + String providerCode, + IdentityFailureCode failureCode) { + counter( + providerCode, + failureCode.name().toLowerCase(Locale.ROOT)); + } + + void recordSystemError(String providerCode) { + counter(providerCode, "system_error"); + } + + private void counter( + String providerCode, + String result) { + meterRegistry.counter( + "skillhub.identity.login", + "provider", + providerCode, + "result", + result).increment(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderPolicyProperties.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderPolicyProperties.java new file mode 100644 index 00000000..48222db2 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityProviderPolicyProperties.java @@ -0,0 +1,119 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "skillhub.auth.identity") +class IdentityProviderPolicyProperties { + + private Map providers = + new LinkedHashMap<>(); + + ProviderIdentityPolicy resolve(String providerCode) { + ProviderPolicy configured = providers.get(providerCode); + if (configured == null) { + return ProviderIdentityPolicy.defaults(); + } + return new ProviderIdentityPolicy( + configured.getProvisioningMode(), + new ProfileSyncPolicy( + configured.getProfileSync().getDisplayName(), + configured.getProfileSync().getEmail(), + configured.getProfileSync().getAvatarUrl())); + } + + public Map getProviders() { + return providers; + } + + public void setProviders(Map providers) { + this.providers = providers == null + ? new LinkedHashMap<>() + : new LinkedHashMap<>(providers); + } + + static final class ProviderPolicy { + + private ProvisioningMode provisioningMode = + ProvisioningMode.AUTO; + private ProfilePolicy profileSync = new ProfilePolicy(); + + public ProvisioningMode getProvisioningMode() { + return provisioningMode; + } + + public void setProvisioningMode( + ProvisioningMode provisioningMode) { + this.provisioningMode = provisioningMode; + } + + public ProfilePolicy getProfileSync() { + return profileSync; + } + + public void setProfileSync(ProfilePolicy profileSync) { + this.profileSync = profileSync == null + ? new ProfilePolicy() + : profileSync; + } + } + + static final class ProfilePolicy { + + private ProfileSyncMode displayName = + ProfileSyncMode.PRESERVE_LOCAL; + private ProfileSyncMode email = + ProfileSyncMode.FILL_IF_EMPTY; + private ProfileSyncMode avatarUrl = + ProfileSyncMode.PRESERVE_LOCAL; + + public ProfileSyncMode getDisplayName() { + return displayName; + } + + public void setDisplayName(ProfileSyncMode displayName) { + this.displayName = displayName; + } + + public ProfileSyncMode getEmail() { + return email; + } + + public void setEmail(ProfileSyncMode email) { + this.email = email; + } + + public ProfileSyncMode getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(ProfileSyncMode avatarUrl) { + this.avatarUrl = avatarUrl; + } + } + + record ProviderIdentityPolicy( + ProvisioningMode provisioningMode, + ProfileSyncPolicy profileSyncPolicy + ) { + ProviderIdentityPolicy { + if (provisioningMode == null) { + throw new IllegalArgumentException( + "Provisioning mode is required"); + } + if (profileSyncPolicy == null) { + throw new IllegalArgumentException( + "Profile sync policy is required"); + } + } + + static ProviderIdentityPolicy defaults() { + return new ProviderIdentityPolicy( + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()); + } + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java index 6fd7db7d..bb921ee0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransaction.java @@ -6,6 +6,11 @@ import com.iflytek.skillhub.auth.entity.IdentityBindingSubject; import com.iflytek.skillhub.auth.entity.IdentityBindingSubjectStatus; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository; +import com.iflytek.skillhub.auth.policy.AccessDecision; +import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.policy.IdentityAccessContext; +import com.iflytek.skillhub.auth.policy.IdentityAccessKind; +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; @@ -33,6 +38,7 @@ import org.springframework.transaction.annotation.Transactional; class IdentityResolutionTransaction { private static final String ACCOUNT_PENDING = "ACCOUNT_PENDING"; + private static final String EMAIL_COLLISION = "EMAIL_COLLISION"; private static final String LEGACY_SUBJECT_TYPE = "legacy_subject"; private final IdentityBindingRepository bindingRepository; @@ -41,6 +47,10 @@ class IdentityResolutionTransaction { private final GlobalNamespaceMembershipService membershipService; private final AccountLoginGuard accountLoginGuard; private final PlatformPrincipalFactory principalFactory; + private final AccessPolicy accessPolicy; + private final ProvisioningPolicy provisioningPolicy; + private final ProfileSynchronizationService profileSyncService; + private final AuditLogService auditLogService; IdentityResolutionTransaction( IdentityBindingRepository bindingRepository, @@ -48,29 +58,50 @@ class IdentityResolutionTransaction { UserAccountRepository userRepository, GlobalNamespaceMembershipService membershipService, AccountLoginGuard accountLoginGuard, - PlatformPrincipalFactory principalFactory) { + PlatformPrincipalFactory principalFactory, + AccessPolicy accessPolicy, + ProvisioningPolicy provisioningPolicy, + ProfileSynchronizationService profileSyncService, + AuditLogService auditLogService) { this.bindingRepository = bindingRepository; this.subjectRepository = subjectRepository; this.userRepository = userRepository; this.membershipService = membershipService; this.accountLoginGuard = accountLoginGuard; this.principalFactory = principalFactory; + this.accessPolicy = accessPolicy; + this.provisioningPolicy = provisioningPolicy; + this.profileSyncService = profileSyncService; + this.auditLogService = auditLogService; } @Transactional public IdentityLoginOutcome resolve( IdentityAssertion assertion, - UserStatus initialStatus, - String legacyPrimarySubjectType) { + ProviderDescriptor descriptor, + IdentityLoginContext context) { ExternalSubject legacySubject = - assertion.requireUniqueSubject(legacyPrimarySubjectType); + assertion.requireUniqueSubject( + descriptor.legacyPrimarySubjectType()); MatchResolution initialMatches = resolveMatches(assertion, legacySubject); if (initialMatches.bindingId() == null) { + requireAccessAllowed( + assertion, + context, + IdentityAccessKind.NEW_IDENTITY, + Optional.empty()); + ProvisioningMode mode = provisioningPolicy.evaluate( + new ProvisioningPolicyContext( + assertion.provider(), + descriptor.provisioningMode(), + context)); return createAccount( assertion, legacySubject, - initialStatus); + descriptor, + mode, + context); } IdentityBinding binding = bindingRepository @@ -87,7 +118,9 @@ class IdentityResolutionTransaction { assertion, legacySubject, binding, - lockedMatches.revokedAliases()); + lockedMatches.revokedAliases(), + descriptor, + context); } private MatchResolution resolveMatches( @@ -175,14 +208,17 @@ class IdentityResolutionTransaction { IdentityAssertion assertion, ExternalSubject legacySubject, IdentityBinding binding, - Set revokedAliases) { + Set revokedAliases, + ProviderDescriptor descriptor, + IdentityLoginContext context) { if (!binding.getProviderCode().equals( assertion.provider().providerCode()) || !binding.getSubject().equals(legacySubject.value())) { throw identifierConflict(); } - UserAccount user = userRepository.findById(binding.getUserId()) + UserAccount user = userRepository + .findByIdForUpdate(binding.getUserId()) .orElseThrow(() -> new IllegalStateException( "User not found for identity binding")); AccountLoginDecision decision = @@ -192,6 +228,30 @@ class IdentityResolutionTransaction { requireAllowed(decision); } + if (decision == AccountLoginDecision.PENDING) { + reconcileSubjects( + binding, + assertion, + revokedAliases); + binding.recordAuthentication( + assertion.evidence().authenticatedAt()); + bindingRepository.save(binding); + recordAudit( + user.getId(), + "IDENTITY_LOGIN_PENDING", + binding.getId(), + assertion.provider().providerCode(), + "pending", + context); + return new IdentityLoginOutcome.PendingApproval( + ACCOUNT_PENDING); + } + + requireAccessAllowed( + assertion, + context, + IdentityAccessKind.RETURNING_IDENTITY, + Optional.of(user.getStatus())); reconcileSubjects( binding, assertion, @@ -200,16 +260,22 @@ class IdentityResolutionTransaction { assertion.evidence().authenticatedAt()); bindingRepository.save(binding); - if (decision == AccountLoginDecision.PENDING) { - return new IdentityLoginOutcome.PendingApproval( - ACCOUNT_PENDING); - } - - synchronizeCompatibilityProfile(user, assertion.profile()); + profileSyncService.synchronize( + user, + assertion, + descriptor, + false); user = userRepository.save(user); binding.recordSynchronization( assertion.evidence().authenticatedAt()); bindingRepository.save(binding); + recordAudit( + user.getId(), + "IDENTITY_LOGIN_SUCCEEDED", + binding.getId(), + assertion.provider().providerCode(), + "authenticated", + context); return new IdentityLoginOutcome.Authenticated( principalFactory.create( user, @@ -304,18 +370,47 @@ class IdentityResolutionTransaction { private IdentityLoginOutcome createAccount( IdentityAssertion assertion, ExternalSubject legacySubject, - UserStatus initialStatus) { - ExternalProfile profile = assertion.profile(); + ProviderDescriptor descriptor, + ProvisioningMode mode, + IdentityLoginContext context) { + if (mode == ProvisioningMode.EXISTING_BINDING_ONLY) { + throw accessDenied(); + } + + Optional email = trustedEmail(assertion.profile()); + if (email.flatMap(userRepository::findByEmailIgnoreCase) + .isPresent()) { + recordAudit( + null, + "IDENTITY_EMAIL_COLLISION", + null, + assertion.provider().providerCode(), + "link_required", + context); + return new IdentityLoginOutcome.LinkRequired( + EMAIL_COLLISION); + } + + UserStatus initialStatus = + mode == ProvisioningMode.APPROVAL + ? UserStatus.PENDING + : UserStatus.ACTIVE; + String userId = "usr_" + UUID.randomUUID(); UserAccount user = new UserAccount( - "usr_" + UUID.randomUUID(), - profile.displayName(), - trustedEmail(profile).orElse(null), - profile.avatarUrl() - .map(Object::toString) - .orElse(null)); + userId, + userId, + null, + null); user.setStatus(initialStatus); user = userRepository.save(user); + profileSyncService.synchronize( + user, + assertion, + descriptor, + true); + user = userRepository.save(user); + if (initialStatus == UserStatus.ACTIVE) { membershipService.ensureMember(user.getId()); } @@ -323,7 +418,7 @@ class IdentityResolutionTransaction { user.getId(), assertion.provider().providerCode(), legacySubject.value(), - profile.displayName()); + assertion.profile().displayName()); binding.recordAuthentication( assertion.evidence().authenticatedAt()); IdentityBinding savedBinding = @@ -348,6 +443,13 @@ class IdentityResolutionTransaction { subjectRepository.saveAll(subjects); if (initialStatus == UserStatus.PENDING) { + recordAudit( + user.getId(), + "IDENTITY_PROVISIONING_PENDING", + savedBinding.getId(), + assertion.provider().providerCode(), + "pending", + context); return new IdentityLoginOutcome.PendingApproval( ACCOUNT_PENDING); } @@ -355,6 +457,13 @@ class IdentityResolutionTransaction { savedBinding.recordSynchronization( assertion.evidence().authenticatedAt()); bindingRepository.save(savedBinding); + recordAudit( + user.getId(), + "IDENTITY_ACCOUNT_PROVISIONED", + savedBinding.getId(), + assertion.provider().providerCode(), + "authenticated", + context); return new IdentityLoginOutcome.Authenticated( principalFactory.create( user, @@ -383,16 +492,6 @@ class IdentityResolutionTransaction { subject.getSubjectValue()); } - private void synchronizeCompatibilityProfile( - UserAccount user, - ExternalProfile profile) { - user.setDisplayName(profile.displayName()); - trustedEmail(profile).ifPresent(user::setEmail); - profile.avatarUrl() - .map(Object::toString) - .ifPresent(user::setAvatarUrl); - } - private Optional trustedEmail(ExternalProfile profile) { return profile.email() .filter(claim -> claim.assurance() @@ -400,6 +499,52 @@ class IdentityResolutionTransaction { .map(EmailClaim::value); } + private void requireAccessAllowed( + IdentityAssertion assertion, + IdentityLoginContext context, + IdentityAccessKind accessKind, + Optional accountStatus) { + AccessDecision decision = accessPolicy.evaluate( + new IdentityAccessContext( + assertion.provider().providerCode(), + assertion.primarySubject().type(), + assertion.primarySubject().value(), + assertion.profile().email() + .map(EmailClaim::value), + assertion.profile().email() + .map(EmailClaim::assurance) + .orElse( + EmailAssurance.UNVERIFIED), + context, + accessKind, + accountStatus)); + if (decision == AccessDecision.DENY) { + throw accessDenied(); + } + } + + private void recordAudit( + String actorUserId, + String action, + Long bindingId, + String providerCode, + String result, + IdentityLoginContext context) { + auditLogService.record( + actorUserId, + action, + "IDENTITY_BINDING", + bindingId, + context.requestId(), + context.clientIp(), + context.userAgent(), + "{\"providerCode\":\"" + + providerCode + + "\",\"result\":\"" + + result + + "\"}"); + } + private void requireAllowed(AccountLoginDecision decision) { IdentityFailureCode failureCode = switch (decision) { case ALLOWED -> null; diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncMode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncMode.java new file mode 100644 index 00000000..ee6d2914 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncMode.java @@ -0,0 +1,9 @@ +package com.iflytek.skillhub.auth.identity; + +public enum ProfileSyncMode { + NEVER, + INITIAL_ONLY, + FILL_IF_EMPTY, + PRESERVE_LOCAL, + PROVIDER_AUTHORITATIVE +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncPolicy.java new file mode 100644 index 00000000..40b2e86e --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSyncPolicy.java @@ -0,0 +1,22 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record ProfileSyncPolicy( + ProfileSyncMode displayName, + ProfileSyncMode email, + ProfileSyncMode avatarUrl +) { + ProfileSyncPolicy { + Objects.requireNonNull(displayName, "displayName"); + Objects.requireNonNull(email, "email"); + Objects.requireNonNull(avatarUrl, "avatarUrl"); + } + + static ProfileSyncPolicy defaults() { + return new ProfileSyncPolicy( + ProfileSyncMode.PRESERVE_LOCAL, + ProfileSyncMode.FILL_IF_EMPTY, + ProfileSyncMode.PRESERVE_LOCAL); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationService.java new file mode 100644 index 00000000..3a35a41b --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationService.java @@ -0,0 +1,282 @@ +package com.iflytek.skillhub.auth.identity; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserProfileFieldAssurance; +import com.iflytek.skillhub.domain.user.UserProfileFieldName; +import com.iflytek.skillhub.domain.user.UserProfileFieldSource; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceRepository; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceType; +import java.time.Clock; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; +import org.springframework.stereotype.Service; + +@Service +class ProfileSynchronizationService { + + private final UserProfileFieldSourceRepository sourceRepository; + private final Clock clock; + + ProfileSynchronizationService( + UserProfileFieldSourceRepository sourceRepository, + Clock clock) { + this.sourceRepository = sourceRepository; + this.clock = clock; + } + + void synchronize( + UserAccount user, + IdentityAssertion assertion, + ProviderDescriptor descriptor, + boolean accountCreated) { + Map sources = + sourcesByField(user.getId()); + Instant synchronizedAt = + assertion.evidence().authenticatedAt(); + Instant updatedAt = Instant.now(clock); + ExternalProfile profile = assertion.profile(); + String providerCode = + assertion.provider().providerCode(); + ProfileSyncPolicy policy = descriptor.profileSyncPolicy(); + if (!accountCreated) { + backfillMissingLocalSources( + user, + updatedAt, + sources); + } + + synchronizeField( + user, + UserProfileFieldName.DISPLAY_NAME, + Optional.of(profile.displayName()), + UserProfileFieldAssurance.PROVIDER_ASSERTED, + policy.displayName(), + accountCreated, + providerCode, + synchronizedAt, + updatedAt, + user::getDisplayName, + user::setDisplayName, + sources); + synchronizeField( + user, + UserProfileFieldName.EMAIL, + trustedEmail(profile), + profile.email() + .map(EmailClaim::assurance) + .map(this::profileAssurance) + .orElse( + UserProfileFieldAssurance.UNVERIFIED), + policy.email(), + accountCreated, + providerCode, + synchronizedAt, + updatedAt, + user::getEmail, + user::setEmail, + sources); + synchronizeField( + user, + UserProfileFieldName.AVATAR_URL, + profile.avatarUrl().map(Object::toString), + UserProfileFieldAssurance.PROVIDER_ASSERTED, + policy.avatarUrl(), + accountCreated, + providerCode, + synchronizedAt, + updatedAt, + user::getAvatarUrl, + user::setAvatarUrl, + sources); + + if (accountCreated + && policy.displayName() == ProfileSyncMode.NEVER) { + markFallbackDisplayName( + user, + updatedAt, + sources); + } + } + + private void synchronizeField( + UserAccount user, + UserProfileFieldName field, + Optional candidate, + UserProfileFieldAssurance assurance, + ProfileSyncMode mode, + boolean accountCreated, + String providerCode, + Instant synchronizedAt, + Instant updatedAt, + Supplier currentValue, + Consumer updateValue, + Map sources) { + if (candidate.isEmpty()) { + return; + } + UserProfileFieldSource currentSource = + sources.get(field.databaseValue()); + if (!shouldSynchronize( + mode, + accountCreated, + currentValue.get(), + currentSource, + providerCode)) { + return; + } + + updateValue.accept(candidate.orElseThrow()); + UserProfileFieldSource source = currentSource == null + ? UserProfileFieldSource.provider( + user.getId(), + field, + providerCode, + assurance, + synchronizedAt, + updatedAt) + : currentSource; + if (currentSource != null) { + source.markProvider( + providerCode, + assurance, + synchronizedAt, + updatedAt); + } + source = sourceRepository.save(source); + sources.put(field.databaseValue(), source); + } + + private boolean shouldSynchronize( + ProfileSyncMode mode, + boolean accountCreated, + String currentValue, + UserProfileFieldSource source, + String providerCode) { + return switch (mode) { + case NEVER -> false; + case INITIAL_ONLY -> accountCreated; + case FILL_IF_EMPTY -> + accountCreated || isBlank(currentValue); + case PRESERVE_LOCAL -> accountCreated + || source == null && isBlank(currentValue) + || sameProvider(source, providerCode); + case PROVIDER_AUTHORITATIVE -> true; + }; + } + + private boolean sameProvider( + UserProfileFieldSource source, + String providerCode) { + return source != null + && source.getSourceType() + == UserProfileFieldSourceType.PROVIDER + && providerCode.equals(source.getProviderCode()); + } + + private void markFallbackDisplayName( + UserAccount user, + Instant updatedAt, + Map sources) { + if (sources.containsKey( + UserProfileFieldName.DISPLAY_NAME.databaseValue())) { + return; + } + UserProfileFieldSource fallback = + UserProfileFieldSource.local( + user.getId(), + UserProfileFieldName.DISPLAY_NAME, + UserProfileFieldSourceType.LEGACY_LOCAL, + updatedAt); + fallback = sourceRepository.save(fallback); + sources.put(fallback.getFieldName(), fallback); + } + + private void backfillMissingLocalSources( + UserAccount user, + Instant updatedAt, + Map sources) { + backfillMissingLocalSource( + user, + UserProfileFieldName.DISPLAY_NAME, + user.getDisplayName(), + updatedAt, + sources); + backfillMissingLocalSource( + user, + UserProfileFieldName.EMAIL, + user.getEmail(), + updatedAt, + sources); + backfillMissingLocalSource( + user, + UserProfileFieldName.AVATAR_URL, + user.getAvatarUrl(), + updatedAt, + sources); + } + + private void backfillMissingLocalSource( + UserAccount user, + UserProfileFieldName field, + String value, + Instant updatedAt, + Map sources) { + if (isBlank(value) + || sources.containsKey(field.databaseValue())) { + return; + } + UserProfileFieldSource source = + UserProfileFieldSource.local( + user.getId(), + field, + UserProfileFieldSourceType.LEGACY_LOCAL, + updatedAt); + source = sourceRepository.save(source); + sources.put(field.databaseValue(), source); + } + + private Map sourcesByField( + String userId) { + LinkedHashMap sources = + new LinkedHashMap<>(); + for (UserProfileFieldSource source : + sourceRepository.findByUserId(userId)) { + UserProfileFieldSource duplicate = + sources.put(source.getFieldName(), source); + if (duplicate != null) { + throw new IllegalStateException( + "Duplicate profile field source"); + } + } + return sources; + } + + private Optional trustedEmail(ExternalProfile profile) { + return profile.email() + .filter(claim -> claim.assurance() + .isVerifiedOrAuthoritative()) + .map(EmailClaim::value); + } + + private UserProfileFieldAssurance profileAssurance( + EmailAssurance assurance) { + return switch (assurance) { + case UNVERIFIED -> + UserProfileFieldAssurance.UNVERIFIED; + case PROVIDER_ASSERTED -> + UserProfileFieldAssurance.PROVIDER_ASSERTED; + case VERIFIED -> + UserProfileFieldAssurance.VERIFIED; + case AUTHORITATIVE -> + UserProfileFieldAssurance.AUTHORITATIVE; + }; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java index 053a791e..34c96aa7 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProviderDescriptor.java @@ -16,7 +16,9 @@ record ProviderDescriptor( List displayNameAttributes, List emailAttributes, List avatarAttributes, - EmailAssurance emailAssuranceLimit + EmailAssurance emailAssuranceLimit, + ProvisioningMode provisioningMode, + ProfileSyncPolicy profileSyncPolicy ) { private static final Pattern PROVIDER_CODE_PATTERN = Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); @@ -39,6 +41,8 @@ record ProviderDescriptor( Objects.requireNonNull(emailAttributes, "emailAttributes"); Objects.requireNonNull(avatarAttributes, "avatarAttributes"); Objects.requireNonNull(emailAssuranceLimit, "emailAssuranceLimit"); + Objects.requireNonNull(provisioningMode, "provisioningMode"); + Objects.requireNonNull(profileSyncPolicy, "profileSyncPolicy"); if (!PROVIDER_CODE_PATTERN.matcher(providerCode).matches()) { throw new IllegalArgumentException("Invalid provider code"); @@ -65,6 +69,34 @@ record ProviderDescriptor( avatarAttributes = List.copyOf(avatarAttributes); } + ProviderDescriptor( + String providerCode, + String protocol, + String canonicalAuthority, + String displayName, + String primarySubjectType, + String legacyPrimarySubjectType, + Map subjectCanonicalizers, + List displayNameAttributes, + List emailAttributes, + List avatarAttributes, + EmailAssurance emailAssuranceLimit) { + this( + providerCode, + protocol, + canonicalAuthority, + displayName, + primarySubjectType, + legacyPrimarySubjectType, + subjectCanonicalizers, + displayNameAttributes, + emailAttributes, + avatarAttributes, + emailAssuranceLimit, + ProvisioningMode.AUTO, + ProfileSyncPolicy.defaults()); + } + SubjectCanonicalizer canonicalizerFor(String subjectType) { SubjectCanonicalizer canonicalizer = subjectCanonicalizers.get(subjectType); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningMode.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningMode.java new file mode 100644 index 00000000..0cae411f --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningMode.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.auth.identity; + +public enum ProvisioningMode { + AUTO, + APPROVAL, + EXISTING_BINDING_ONLY +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicy.java new file mode 100644 index 00000000..13131955 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicy.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.identity; + +interface ProvisioningPolicy { + + ProvisioningMode evaluate(ProvisioningPolicyContext context); +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicyContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicyContext.java new file mode 100644 index 00000000..54cb3d93 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/ProvisioningPolicyContext.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.auth.identity; + +import java.util.Objects; + +record ProvisioningPolicyContext( + ProviderReference provider, + ProvisioningMode configuredMode, + IdentityLoginContext requestContext +) { + ProvisioningPolicyContext { + Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(configuredMode, "configuredMode"); + Objects.requireNonNull(requestContext, "requestContext"); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java index b9ded715..2ea73ca0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSource.java @@ -35,24 +35,40 @@ class StaticTrustedProviderDescriptorSource private final Map descriptors; private final Map trustedRegistrations; + private final IdentityProviderPolicyProperties policyProperties; @Autowired StaticTrustedProviderDescriptorSource( OAuth2ClientProperties properties, ClientRegistrationRepository registrationRepository, - List extractors) { + List extractors, + IdentityProviderPolicyProperties policyProperties) { this( properties, registrationRepository, extractors.stream() .map(OAuthClaimsExtractor::getProvider) - .collect(Collectors.toUnmodifiableSet())); + .collect(Collectors.toUnmodifiableSet()), + policyProperties); } StaticTrustedProviderDescriptorSource( OAuth2ClientProperties properties, ClientRegistrationRepository registrationRepository, Set extractorCodes) { + this( + properties, + registrationRepository, + extractorCodes, + new IdentityProviderPolicyProperties()); + } + + StaticTrustedProviderDescriptorSource( + OAuth2ClientProperties properties, + ClientRegistrationRepository registrationRepository, + Set extractorCodes, + IdentityProviderPolicyProperties policyProperties) { + this.policyProperties = policyProperties; Map resolvedDescriptors = new LinkedHashMap<>(); Map resolvedRegistrations = @@ -239,6 +255,8 @@ class StaticTrustedProviderDescriptorSource || configuredDisplayName.isBlank() ? providerCode : configuredDisplayName; + IdentityProviderPolicyProperties.ProviderIdentityPolicy policy = + policyProperties.resolve(providerCode); return new ProviderDescriptor( providerCode, protocol, @@ -250,7 +268,9 @@ class StaticTrustedProviderDescriptorSource displayNameAttributes, emailAttributes, avatarAttributes, - EmailAssurance.VERIFIED); + EmailAssurance.VERIFIED, + policy.provisioningMode(), + policy.profileSyncPolicy()); } private void validatePublicGithubEndpoints( diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java index 2c33fcd3..ac7c9c51 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessDecision.java @@ -1,9 +1,10 @@ package com.iflytek.skillhub.auth.policy; /** - * Possible outcomes when evaluating whether an externally authenticated user may access the - * platform. + * Per-login access decision. First-login provisioning is decided separately + * by the provisioning policy. */ public enum AccessDecision { - ALLOW, DENY, PENDING_APPROVAL + ALLOW, + DENY } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java index 208f751b..18264cb5 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/AccessPolicy.java @@ -1,7 +1,9 @@ package com.iflytek.skillhub.auth.policy; /** - * Policy contract for deciding whether externally authenticated users may enter the platform. + * Login-policy contract evaluated for both new and returning external + * identities. It does not decide whether a new account is auto-provisioned or + * requires approval. */ public interface AccessPolicy { AccessDecision evaluate(IdentityAccessContext context); diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java index 3cd64ebc..6e1165f1 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessContext.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.auth.policy; import com.iflytek.skillhub.auth.identity.EmailAssurance; import com.iflytek.skillhub.auth.identity.IdentityLoginContext; +import com.iflytek.skillhub.domain.user.UserStatus; import java.util.Objects; import java.util.Optional; @@ -14,7 +15,9 @@ public record IdentityAccessContext( String subjectValue, Optional email, EmailAssurance emailAssurance, - IdentityLoginContext requestContext + IdentityLoginContext requestContext, + IdentityAccessKind accessKind, + Optional existingAccountStatus ) { public IdentityAccessContext { Objects.requireNonNull(providerCode, "providerCode"); @@ -23,5 +26,37 @@ public record IdentityAccessContext( Objects.requireNonNull(email, "email"); Objects.requireNonNull(emailAssurance, "emailAssurance"); Objects.requireNonNull(requestContext, "requestContext"); + Objects.requireNonNull(accessKind, "accessKind"); + Objects.requireNonNull( + existingAccountStatus, + "existingAccountStatus"); + if (accessKind == IdentityAccessKind.NEW_IDENTITY + && existingAccountStatus.isPresent()) { + throw new IllegalArgumentException( + "New identity cannot have an existing account status"); + } + if (accessKind == IdentityAccessKind.RETURNING_IDENTITY + && existingAccountStatus.isEmpty()) { + throw new IllegalArgumentException( + "Returning identity requires account status"); + } + } + + public IdentityAccessContext( + String providerCode, + String subjectType, + String subjectValue, + Optional email, + EmailAssurance emailAssurance, + IdentityLoginContext requestContext) { + this( + providerCode, + subjectType, + subjectValue, + email, + emailAssurance, + requestContext, + IdentityAccessKind.NEW_IDENTITY, + Optional.empty()); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessKind.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessKind.java new file mode 100644 index 00000000..a219d9f4 --- /dev/null +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/policy/IdentityAccessKind.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.auth.policy; + +public enum IdentityAccessKind { + NEW_IDENTITY, + RETURNING_IDENTITY +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java index d53af467..30fb6b01 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/DefaultExternalIdentityLoginServiceTest.java @@ -5,14 +5,10 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import com.iflytek.skillhub.auth.policy.AccessDecision; -import com.iflytek.skillhub.auth.policy.AccessPolicy; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; -import com.iflytek.skillhub.domain.user.UserStatus; import java.sql.SQLException; import java.time.Instant; import java.util.List; @@ -21,7 +17,6 @@ import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InOrder; -import org.mockito.ArgumentCaptor; import org.springframework.dao.DataIntegrityViolationException; class DefaultExternalIdentityLoginServiceTest { @@ -29,145 +24,94 @@ class DefaultExternalIdentityLoginServiceTest { private final ProviderDescriptor descriptor = descriptor(); private TrustedProviderDescriptorSource descriptorSource; private ProviderAuthorityLockService authorityLockService; - private AccessPolicy accessPolicy; private IdentityResolutionTransaction resolutionTransaction; + private IdentityLoginMetrics metrics; private DefaultExternalIdentityLoginService service; @BeforeEach void setUp() { - descriptorSource = mock(TrustedProviderDescriptorSource.class); - authorityLockService = mock(ProviderAuthorityLockService.class); - accessPolicy = mock(AccessPolicy.class); - resolutionTransaction = mock(IdentityResolutionTransaction.class); + descriptorSource = + mock(TrustedProviderDescriptorSource.class); + authorityLockService = + mock(ProviderAuthorityLockService.class); + resolutionTransaction = + mock(IdentityResolutionTransaction.class); + metrics = mock(IdentityLoginMetrics.class); service = new DefaultExternalIdentityLoginService( descriptorSource, authorityLockService, new IdentityAssertionFactory(), - accessPolicy, - resolutionTransaction); + resolutionTransaction, + metrics); } @Test - void resolvesTrustedProviderBeforeAuthorityAssertionPolicyAndTransaction() { + void resolvesTrustedProviderBeforeAuthorityAssertionAndTransaction() { ResolvedProviderHandle handle = new DefaultResolvedProviderHandle("github"); ProviderAuthenticationResult result = result(); IdentityLoginContext context = IdentityLoginContext.empty(); - PlatformPrincipal principal = new PlatformPrincipal( - "usr_1", - "alice", - "alice@example.com", - null, - "github", - Set.of("USER")); - IdentityLoginOutcome expected = - new IdentityLoginOutcome.Authenticated( - principal, - false, - false); + IdentityLoginOutcome expected = authenticated(); when(descriptorSource.require(handle)).thenReturn(descriptor); - when(accessPolicy.evaluate(any())).thenReturn(AccessDecision.ALLOW); when(resolutionTransaction.resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq("github_user_id"))) + org.mockito.ArgumentMatchers.eq(descriptor), + org.mockito.ArgumentMatchers.eq(context))) .thenReturn(expected); IdentityLoginOutcome outcome = service.authenticate(handle, result, context); assertThat(outcome).isSameAs(expected); - ArgumentCaptor - accessContext = ArgumentCaptor.forClass( - com.iflytek.skillhub.auth.policy.IdentityAccessContext.class); - verify(accessPolicy).evaluate(accessContext.capture()); - assertThat(accessContext.getValue().requestContext()) - .isSameAs(context); InOrder order = inOrder( descriptorSource, authorityLockService, - accessPolicy, - resolutionTransaction); + resolutionTransaction, + metrics); order.verify(descriptorSource).require(handle); - order.verify(authorityLockService).requirePinnedAuthority(descriptor); - order.verify(accessPolicy).evaluate(any()); + order.verify(authorityLockService) + .requirePinnedAuthority(descriptor); order.verify(resolutionTransaction).resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq(UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq("github_user_id")); + org.mockito.ArgumentMatchers.eq(descriptor), + org.mockito.ArgumentMatchers.eq(context)); + order.verify(metrics).recordOutcome("github", expected); } @Test - void pendingPolicyUsesCompatibilityPendingProvisioningMode() { + void recordsPendingOutcomeWithoutReinterpretingIt() { ResolvedProviderHandle handle = new DefaultResolvedProviderHandle("github"); - when(descriptorSource.require(handle)).thenReturn(descriptor); - when(accessPolicy.evaluate(any())) - .thenReturn(AccessDecision.PENDING_APPROVAL); IdentityLoginOutcome pending = - new IdentityLoginOutcome.PendingApproval("ACCOUNT_PENDING"); + new IdentityLoginOutcome.PendingApproval( + "ACCOUNT_PENDING"); + when(descriptorSource.require(handle)).thenReturn(descriptor); when(resolutionTransaction.resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq(UserStatus.PENDING), - org.mockito.ArgumentMatchers.eq("github_user_id"))) + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class))) .thenReturn(pending); - IdentityLoginOutcome outcome = - service.authenticate( - handle, - result(), - IdentityLoginContext.empty()); - - assertThat(outcome).isSameAs(pending); - } - - @Test - void deniedPolicyNeverReachesProvisioningTransaction() { - ResolvedProviderHandle handle = - new DefaultResolvedProviderHandle("github"); - when(descriptorSource.require(handle)).thenReturn(descriptor); - when(accessPolicy.evaluate(any())).thenReturn(AccessDecision.DENY); - - assertThatThrownBy(() -> service.authenticate( + IdentityLoginOutcome outcome = service.authenticate( handle, result(), - IdentityLoginContext.empty())) - .isInstanceOf(IdentityCoreException.class) - .extracting("reasonCode") - .isEqualTo(IdentityFailureCode.ACCESS_DENIED); + IdentityLoginContext.empty()); - verify(resolutionTransaction, never()).resolve( - any(), - any(), - any()); + assertThat(outcome).isSameAs(pending); + verify(metrics).recordOutcome("github", pending); } @Test void retriesConcurrentFirstLoginInANewResolutionTransaction() { ResolvedProviderHandle handle = new DefaultResolvedProviderHandle("github"); - PlatformPrincipal principal = new PlatformPrincipal( - "usr_1", - "alice", - "alice@example.com", - null, - "github", - Set.of("USER")); - IdentityLoginOutcome expected = - new IdentityLoginOutcome.Authenticated( - principal, - false, - false); + IdentityLoginOutcome expected = authenticated(); when(descriptorSource.require(handle)) .thenReturn(descriptor); - when(accessPolicy.evaluate(any())) - .thenReturn(AccessDecision.ALLOW); when(resolutionTransaction.resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id"))) + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class))) .thenThrow(uniqueViolation()) .thenReturn(expected); @@ -180,26 +124,21 @@ class DefaultExternalIdentityLoginServiceTest { verify(resolutionTransaction, org.mockito.Mockito.times(2)).resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id")); + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class)); + verify(metrics).recordOutcome("github", expected); } @Test - void repeatedUniqueConflictFailsClosed() { + void repeatedUniqueConflictFailsClosedAndRecordsReason() { ResolvedProviderHandle handle = new DefaultResolvedProviderHandle("github"); when(descriptorSource.require(handle)) .thenReturn(descriptor); - when(accessPolicy.evaluate(any())) - .thenReturn(AccessDecision.ALLOW); when(resolutionTransaction.resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id"))) + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class))) .thenThrow(uniqueViolation(), uniqueViolation()); assertThatThrownBy(() -> service.authenticate( @@ -211,13 +150,9 @@ class DefaultExternalIdentityLoginServiceTest { .isEqualTo( IdentityFailureCode .IDENTITY_IDENTIFIER_CONFLICT); - verify(resolutionTransaction, - org.mockito.Mockito.times(2)).resolve( - any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id")); + verify(metrics).recordFailure( + "github", + IdentityFailureCode.IDENTITY_IDENTIFIER_CONFLICT); } @Test @@ -232,14 +167,10 @@ class DefaultExternalIdentityLoginServiceTest { "23514")); when(descriptorSource.require(handle)) .thenReturn(descriptor); - when(accessPolicy.evaluate(any())) - .thenReturn(AccessDecision.ALLOW); when(resolutionTransaction.resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id"))) + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class))) .thenThrow(checkViolation); assertThatThrownBy(() -> service.authenticate( @@ -249,10 +180,23 @@ class DefaultExternalIdentityLoginServiceTest { .isSameAs(checkViolation); verify(resolutionTransaction).resolve( any(IdentityAssertion.class), - org.mockito.ArgumentMatchers.eq( - UserStatus.ACTIVE), - org.mockito.ArgumentMatchers.eq( - "github_user_id")); + org.mockito.ArgumentMatchers.eq(descriptor), + any(IdentityLoginContext.class)); + verify(metrics).recordSystemError("github"); + } + + private static IdentityLoginOutcome authenticated() { + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "alice", + "alice@example.com", + null, + "github", + Set.of("USER")); + return new IdentityLoginOutcome.Authenticated( + principal, + false, + false); } private static DataIntegrityViolationException uniqueViolation() { @@ -265,7 +209,9 @@ class DefaultExternalIdentityLoginServiceTest { private static ProviderAuthenticationResult result() { return new ProviderAuthenticationResult( - new SubjectCandidate("github_user_id", "123456"), + new SubjectCandidate( + "github_user_id", + "123456"), List.of(), Map.of( "login", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetricsTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetricsTest.java new file mode 100644 index 00000000..4a0c34bf --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityLoginMetricsTest.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.auth.identity; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class IdentityLoginMetricsTest { + + @Test + void recordsBoundedOutcomeAndFailureTags() { + SimpleMeterRegistry registry = + new SimpleMeterRegistry(); + IdentityLoginMetrics metrics = + new IdentityLoginMetrics(registry); + PlatformPrincipal principal = new PlatformPrincipal( + "usr_1", + "alice", + null, + null, + "github", + Set.of("USER")); + + metrics.recordOutcome( + "github", + new IdentityLoginOutcome.Authenticated( + principal, + true, + true)); + metrics.recordOutcome( + "github", + new IdentityLoginOutcome.PendingApproval( + "ACCOUNT_PENDING")); + metrics.recordOutcome( + "github", + new IdentityLoginOutcome.LinkRequired( + "EMAIL_COLLISION")); + metrics.recordFailure( + "github", + IdentityFailureCode.ACCESS_DENIED); + + assertThat(counter( + registry, + "provisioned")).isEqualTo(1.0); + assertThat(counter( + registry, + "pending")).isEqualTo(1.0); + assertThat(counter( + registry, + "link_required")).isEqualTo(1.0); + assertThat(counter( + registry, + "access_denied")).isEqualTo(1.0); + } + + private static double counter( + SimpleMeterRegistry registry, + String result) { + return registry.get("skillhub.identity.login") + .tags( + "provider", + "github", + "result", + result) + .counter() + .count(); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java index 60469e02..b0c0c32f 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/IdentityResolutionTransactionTest.java @@ -15,6 +15,11 @@ import com.iflytek.skillhub.auth.entity.IdentityBindingSubjectStatus; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.repository.IdentityBindingRepository; import com.iflytek.skillhub.auth.repository.IdentityBindingSubjectRepository; +import com.iflytek.skillhub.auth.policy.AccessDecision; +import com.iflytek.skillhub.auth.policy.AccessPolicy; +import com.iflytek.skillhub.auth.policy.IdentityAccessContext; +import com.iflytek.skillhub.auth.policy.IdentityAccessKind; +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; @@ -41,6 +46,10 @@ class IdentityResolutionTransactionTest { private GlobalNamespaceMembershipService membershipService; private AccountLoginGuard accountLoginGuard; private PlatformPrincipalFactory principalFactory; + private AccessPolicy accessPolicy; + private ProvisioningPolicy provisioningPolicy; + private ProfileSynchronizationService profileSyncService; + private AuditLogService auditLogService; private IdentityResolutionTransaction transaction; @BeforeEach @@ -53,13 +62,22 @@ class IdentityResolutionTransactionTest { mock(GlobalNamespaceMembershipService.class); accountLoginGuard = new AccountLoginGuard(); principalFactory = mock(PlatformPrincipalFactory.class); + accessPolicy = mock(AccessPolicy.class); + provisioningPolicy = mock(ProvisioningPolicy.class); + profileSyncService = + mock(ProfileSynchronizationService.class); + auditLogService = mock(AuditLogService.class); transaction = new IdentityResolutionTransaction( bindingRepository, subjectRepository, userRepository, membershipService, accountLoginGuard, - principalFactory); + principalFactory, + accessPolicy, + provisioningPolicy, + profileSyncService, + auditLogService); when(subjectRepository.findMatchingSubjects(any(), any())) .thenReturn(List.of()); when(bindingRepository.findByProviderCodeAndSubject( @@ -67,6 +85,12 @@ class IdentityResolutionTransactionTest { any())).thenReturn(Optional.empty()); when(userRepository.save(any(UserAccount.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + when(accessPolicy.evaluate(any())) + .thenReturn(AccessDecision.ALLOW); + when(provisioningPolicy.evaluate(any())) + .thenAnswer(invocation -> invocation + .getArgument(0) + .configuredMode()); when(bindingRepository.save(any(IdentityBinding.class))) .thenAnswer(invocation -> { IdentityBinding binding = @@ -100,8 +124,12 @@ class IdentityResolutionTransactionTest { IdentityLoginOutcome outcome = transaction.resolve( assertion, - UserStatus.ACTIVE, - "legacy_id"); + descriptor( + "provider", + "stable_id", + "legacy_id", + ProvisioningMode.AUTO), + IdentityLoginContext.empty()); assertThat(outcome).isEqualTo( new IdentityLoginOutcome.Authenticated( @@ -146,6 +174,124 @@ class IdentityResolutionTransactionTest { verify(membershipService).ensureMember(any()); } + @Test + void approvalProvisioningCreatesOnePendingAccountWithoutMembership() { + IdentityLoginOutcome outcome = transaction.resolve( + githubAssertion(Set.of()), + githubDescriptor(ProvisioningMode.APPROVAL), + IdentityLoginContext.empty()); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.PendingApproval( + "ACCOUNT_PENDING")); + ArgumentCaptor userCaptor = + ArgumentCaptor.forClass(UserAccount.class); + verify(userRepository, org.mockito.Mockito.atLeastOnce()) + .save(userCaptor.capture()); + assertThat(userCaptor.getValue().getStatus()) + .isEqualTo(UserStatus.PENDING); + verify(membershipService, never()).ensureMember(any()); + verify(profileSyncService).synchronize( + any(UserAccount.class), + any(IdentityAssertion.class), + any(ProviderDescriptor.class), + org.mockito.ArgumentMatchers.eq(true)); + } + + @Test + void existingBindingOnlyRejectsUnknownIdentityWithoutWriting() { + assertThatThrownBy(() -> transaction.resolve( + githubAssertion(Set.of()), + githubDescriptor( + ProvisioningMode.EXISTING_BINDING_ONLY), + IdentityLoginContext.empty())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.ACCESS_DENIED); + + verify(userRepository, never()).save(any()); + verify(bindingRepository, never()) + .save(any(IdentityBinding.class)); + verify(profileSyncService, never()).synchronize( + any(), + any(), + any(), + org.mockito.ArgumentMatchers.anyBoolean()); + } + + @Test + void verifiedEmailCollisionReturnsOnlyStableLinkReason() { + when(userRepository.findByEmailIgnoreCase( + "alice@example.com")) + .thenReturn(Optional.of(user( + "usr_existing", + UserStatus.ACTIVE, + false))); + + IdentityLoginOutcome outcome = transaction.resolve( + githubAssertion(Set.of()), + githubDescriptor(ProvisioningMode.AUTO), + new IdentityLoginContext( + "request-1", + "127.0.0.1", + "test")); + + assertThat(outcome).isEqualTo( + new IdentityLoginOutcome.LinkRequired( + "EMAIL_COLLISION")); + verify(userRepository, never()).save(any()); + verify(bindingRepository, never()) + .save(any(IdentityBinding.class)); + verify(auditLogService).record( + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq( + "IDENTITY_EMAIL_COLLISION"), + org.mockito.ArgumentMatchers.eq( + "IDENTITY_BINDING"), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq("request-1"), + org.mockito.ArgumentMatchers.eq("127.0.0.1"), + org.mockito.ArgumentMatchers.eq("test"), + org.mockito.ArgumentMatchers.eq( + "{\"providerCode\":\"github\",\"result\":\"link_required\"}")); + } + + @Test + void loginPolicyReceivesNewIdentityContextBeforeProvisioning() { + transaction.resolve( + githubAssertion(Set.of()), + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty()); + + ArgumentCaptor context = + ArgumentCaptor.forClass( + IdentityAccessContext.class); + verify(accessPolicy).evaluate(context.capture()); + assertThat(context.getValue().accessKind()) + .isEqualTo(IdentityAccessKind.NEW_IDENTITY); + assertThat(context.getValue().existingAccountStatus()) + .isEmpty(); + } + + @Test + void deniedLoginPolicyDoesNotProvisionUnknownIdentity() { + when(accessPolicy.evaluate(any())) + .thenReturn(AccessDecision.DENY); + + assertThatThrownBy(() -> transaction.resolve( + githubAssertion(Set.of()), + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty())) + .isInstanceOf(IdentityCoreException.class) + .extracting("reasonCode") + .isEqualTo(IdentityFailureCode.ACCESS_DENIED); + + verify(provisioningPolicy, never()).evaluate(any()); + verify(userRepository, never()).save(any()); + verify(bindingRepository, never()) + .save(any(IdentityBinding.class)); + } + @Test void upgradesLegacyPrimaryInOneTransaction() { IdentityBinding binding = binding( @@ -172,15 +318,15 @@ class IdentityResolutionTransactionTest { 1L, IdentityBindingSubjectStatus.ACTIVE)) .thenReturn(List.of(legacy)); - when(userRepository.findById("usr_1")) + when(userRepository.findByIdForUpdate("usr_1")) .thenReturn(Optional.of(user)); when(principalFactory.create(user, "github")) .thenReturn(principal("usr_1")); transaction.resolve( githubAssertion(Set.of()), - UserStatus.ACTIVE, - "github_user_id"); + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty()); @SuppressWarnings("unchecked") ArgumentCaptor> subjectsCaptor = @@ -201,6 +347,16 @@ class IdentityResolutionTransactionTest { "github_user_id", "123456", true)); + ArgumentCaptor accessContext = + ArgumentCaptor.forClass( + IdentityAccessContext.class); + verify(accessPolicy).evaluate(accessContext.capture()); + assertThat(accessContext.getValue().accessKind()) + .isEqualTo( + IdentityAccessKind.RETURNING_IDENTITY); + assertThat(accessContext.getValue() + .existingAccountStatus()) + .contains(UserStatus.ACTIVE); } @Test @@ -248,15 +404,19 @@ class IdentityResolutionTransactionTest { IdentityBindingSubjectStatus.ACTIVE)) .thenReturn(List.of(alias, stable)); UserAccount user = user("usr_1", UserStatus.ACTIVE, false); - when(userRepository.findById("usr_1")) + when(userRepository.findByIdForUpdate("usr_1")) .thenReturn(Optional.of(user)); when(principalFactory.create(user, "provider")) .thenReturn(principal("usr_1")); transaction.resolve( assertion, - UserStatus.ACTIVE, - "legacy_id"); + descriptor( + "provider", + "stable_id", + "legacy_id", + ProvisioningMode.AUTO), + IdentityLoginContext.empty()); assertThat(alias.isPrimary()).isFalse(); assertThat(stable.isPrimary()).isTrue(); @@ -291,8 +451,12 @@ class IdentityResolutionTransactionTest { assertThatThrownBy(() -> transaction.resolve( assertion, - UserStatus.ACTIVE, - "legacy_id")) + descriptor( + "provider", + "stable_id", + "legacy_id", + ProvisioningMode.AUTO), + IdentityLoginContext.empty())) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo( @@ -324,8 +488,8 @@ class IdentityResolutionTransactionTest { assertThatThrownBy(() -> transaction.resolve( githubAssertion(Set.of()), - UserStatus.ACTIVE, - "github_user_id")) + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty())) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(IdentityFailureCode.ACCESS_DENIED); @@ -352,13 +516,13 @@ class IdentityResolutionTransactionTest { 1L, IdentityBindingSubjectStatus.ACTIVE)) .thenReturn(List.of()); - when(userRepository.findById("usr_1")) + when(userRepository.findByIdForUpdate("usr_1")) .thenReturn(Optional.of(user)); IdentityLoginOutcome outcome = transaction.resolve( githubAssertion(Set.of()), - UserStatus.ACTIVE, - "github_user_id"); + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty()); assertThat(outcome).isEqualTo( new IdentityLoginOutcome.PendingApproval( @@ -403,13 +567,13 @@ class IdentityResolutionTransactionTest { 1L, IdentityBindingStatus.ACTIVE)) .thenReturn(Optional.of(binding)); - when(userRepository.findById("usr_blocked")) + when(userRepository.findByIdForUpdate("usr_blocked")) .thenReturn(Optional.of(user)); assertThatThrownBy(() -> transaction.resolve( githubAssertion(Set.of()), - UserStatus.ACTIVE, - "github_user_id")) + githubDescriptor(ProvisioningMode.AUTO), + IdentityLoginContext.empty())) .isInstanceOf(IdentityCoreException.class) .extracting("reasonCode") .isEqualTo(expectedCode); @@ -450,6 +614,40 @@ class IdentityResolutionTransactionTest { evidence("oidc")); } + private static ProviderDescriptor githubDescriptor( + ProvisioningMode mode) { + return descriptor( + "github", + "github_user_id", + "github_user_id", + mode); + } + + private static ProviderDescriptor descriptor( + String providerCode, + String primaryType, + String legacyType, + ProvisioningMode mode) { + Map canonicalizers = + new java.util.LinkedHashMap<>(); + canonicalizers.put(primaryType, SubjectCanonicalizer.EXACT); + canonicalizers.put(legacyType, SubjectCanonicalizer.EXACT); + return new ProviderDescriptor( + providerCode, + "oidc", + "https://id.example.com", + providerCode, + primaryType, + legacyType, + canonicalizers, + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED, + mode, + ProfileSyncPolicy.defaults()); + } + private static ExternalProfile profile() { return new ExternalProfile( "alice", diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationServiceTest.java new file mode 100644 index 00000000..74972565 --- /dev/null +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/ProfileSynchronizationServiceTest.java @@ -0,0 +1,383 @@ +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.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.user.UserAccount; +import com.iflytek.skillhub.domain.user.UserProfileFieldName; +import com.iflytek.skillhub.domain.user.UserProfileFieldSource; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceRepository; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceType; +import java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class ProfileSynchronizationServiceTest { + + private static final Instant AUTHENTICATED_AT = + Instant.parse("2026-07-30T08:00:00Z"); + private static final Instant UPDATED_AT = + Instant.parse("2026-07-30T08:01:00Z"); + + private UserProfileFieldSourceRepository sourceRepository; + private ProfileSynchronizationService service; + + @BeforeEach + void setUp() { + sourceRepository = + mock(UserProfileFieldSourceRepository.class); + service = new ProfileSynchronizationService( + sourceRepository, + Clock.fixed(UPDATED_AT, ZoneOffset.UTC)); + when(sourceRepository.findByUserId(any())) + .thenReturn(List.of()); + when(sourceRepository.save( + any(UserProfileFieldSource.class))) + .thenAnswer(invocation -> + invocation.getArgument(0)); + } + + @Test + void defaultPolicyInitializesNewAccountAndRecordsProviderSources() { + UserAccount user = new UserAccount( + "usr_1", + "usr_1", + null, + null); + + service.synchronize( + user, + assertion(EmailAssurance.VERIFIED), + descriptor(ProfileSyncPolicy.defaults()), + true); + + assertThat(user.getDisplayName()).isEqualTo("alice"); + assertThat(user.getEmail()) + .isEqualTo("alice@example.com"); + assertThat(user.getAvatarUrl()) + .isEqualTo( + "https://avatars.example/alice.png"); + ArgumentCaptor sources = + ArgumentCaptor.forClass( + UserProfileFieldSource.class); + verify(sourceRepository, + org.mockito.Mockito.times(3)) + .save(sources.capture()); + assertThat(sources.getAllValues()) + .allSatisfy(source -> { + assertThat(source.getSourceType()) + .isEqualTo( + UserProfileFieldSourceType + .PROVIDER); + assertThat(source.getProviderCode()) + .isEqualTo("github"); + assertThat(source.getLastSynchronizedAt()) + .isEqualTo(AUTHENTICATED_AT); + assertThat(source.getUpdatedAt()) + .isEqualTo(UPDATED_AT); + }); + } + + @Test + void preserveLocalDoesNotOverwriteUserMaintainedFields() { + UserAccount user = new UserAccount( + "usr_1", + "local name", + "local@example.com", + "https://local.example/avatar.png"); + when(sourceRepository.findByUserId("usr_1")) + .thenReturn(List.of( + local( + UserProfileFieldName.DISPLAY_NAME, + UserProfileFieldSourceType.USER), + local( + UserProfileFieldName.EMAIL, + UserProfileFieldSourceType.ADMIN), + local( + UserProfileFieldName.AVATAR_URL, + UserProfileFieldSourceType + .LEGACY_LOCAL))); + + service.synchronize( + user, + assertion(EmailAssurance.VERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.PRESERVE_LOCAL, + ProfileSyncMode.PRESERVE_LOCAL, + ProfileSyncMode.PRESERVE_LOCAL)), + false); + + assertThat(user.getDisplayName()) + .isEqualTo("local name"); + assertThat(user.getEmail()) + .isEqualTo("local@example.com"); + assertThat(user.getAvatarUrl()) + .isEqualTo( + "https://local.example/avatar.png"); + verify(sourceRepository, never()).save(any()); + } + + @Test + void preserveLocalAllowsSameProviderToRefreshItsOwnField() { + UserAccount user = new UserAccount( + "usr_1", + "old provider name", + null, + null); + UserProfileFieldSource source = + UserProfileFieldSource.provider( + "usr_1", + UserProfileFieldName.DISPLAY_NAME, + "github", + com.iflytek.skillhub.domain.user + .UserProfileFieldAssurance + .PROVIDER_ASSERTED, + AUTHENTICATED_AT.minusSeconds(60), + UPDATED_AT.minusSeconds(60)); + when(sourceRepository.findByUserId("usr_1")) + .thenReturn(List.of(source)); + + service.synchronize( + user, + assertion(EmailAssurance.UNVERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.PRESERVE_LOCAL, + ProfileSyncMode.NEVER, + ProfileSyncMode.NEVER)), + false); + + assertThat(user.getDisplayName()).isEqualTo("alice"); + assertThat(source.getLastSynchronizedAt()) + .isEqualTo(AUTHENTICATED_AT); + verify(sourceRepository).save(source); + } + + @Test + void fillIfEmptyIgnoresUnverifiedProviderEmail() { + UserAccount user = new UserAccount( + "usr_1", + "local", + null, + null); + when(sourceRepository.findByUserId("usr_1")) + .thenReturn(List.of(local( + UserProfileFieldName.DISPLAY_NAME, + UserProfileFieldSourceType.USER))); + + service.synchronize( + user, + assertion(EmailAssurance.UNVERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.NEVER, + ProfileSyncMode.FILL_IF_EMPTY, + ProfileSyncMode.NEVER)), + false); + + assertThat(user.getEmail()).isNull(); + verify(sourceRepository, never()).save(any()); + } + + @Test + void initialOnlyDoesNotRefreshReturningAccount() { + UserAccount user = new UserAccount( + "usr_1", + "initial name", + "initial@example.com", + "https://initial.example/avatar.png"); + when(sourceRepository.findByUserId("usr_1")) + .thenReturn(List.of( + local( + UserProfileFieldName.DISPLAY_NAME, + UserProfileFieldSourceType.USER), + local( + UserProfileFieldName.EMAIL, + UserProfileFieldSourceType.USER), + local( + UserProfileFieldName.AVATAR_URL, + UserProfileFieldSourceType.USER))); + + service.synchronize( + user, + assertion(EmailAssurance.VERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.INITIAL_ONLY, + ProfileSyncMode.INITIAL_ONLY, + ProfileSyncMode.INITIAL_ONLY)), + false); + + assertThat(user.getDisplayName()) + .isEqualTo("initial name"); + assertThat(user.getEmail()) + .isEqualTo("initial@example.com"); + assertThat(user.getAvatarUrl()) + .isEqualTo( + "https://initial.example/avatar.png"); + verify(sourceRepository, never()).save(any()); + } + + @Test + void returningAccountBackfillsSourceGapsFromRollbackWindow() { + UserAccount user = new UserAccount( + "usr_1", + "legacy name", + "legacy@example.com", + null); + + service.synchronize( + user, + assertion(EmailAssurance.VERIFIED), + descriptor(ProfileSyncPolicy.defaults()), + false); + + assertThat(user.getDisplayName()) + .isEqualTo("legacy name"); + assertThat(user.getEmail()) + .isEqualTo("legacy@example.com"); + ArgumentCaptor sources = + ArgumentCaptor.forClass( + UserProfileFieldSource.class); + verify(sourceRepository, + org.mockito.Mockito.times(2)) + .save(sources.capture()); + assertThat(sources.getAllValues()) + .extracting( + UserProfileFieldSource::getFieldName, + UserProfileFieldSource::getSourceType) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple( + "displayName", + UserProfileFieldSourceType + .LEGACY_LOCAL), + org.assertj.core.groups.Tuple.tuple( + "email", + UserProfileFieldSourceType + .LEGACY_LOCAL)); + } + + @Test + void providerAuthoritativeExplicitlyOverwritesLocalValue() { + UserAccount user = new UserAccount( + "usr_1", + "local name", + null, + null); + when(sourceRepository.findByUserId("usr_1")) + .thenReturn(List.of(local( + UserProfileFieldName.DISPLAY_NAME, + UserProfileFieldSourceType.USER))); + + service.synchronize( + user, + assertion(EmailAssurance.UNVERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.PROVIDER_AUTHORITATIVE, + ProfileSyncMode.NEVER, + ProfileSyncMode.NEVER)), + false); + + assertThat(user.getDisplayName()).isEqualTo("alice"); + ArgumentCaptor source = + ArgumentCaptor.forClass( + UserProfileFieldSource.class); + verify(sourceRepository).save(source.capture()); + assertThat(source.getValue().getSourceType()) + .isEqualTo(UserProfileFieldSourceType.PROVIDER); + assertThat(source.getValue().getProviderCode()) + .isEqualTo("github"); + } + + @Test + void neverUsesNeutralFallbackForRequiredDisplayName() { + UserAccount user = new UserAccount( + "usr_1", + "usr_1", + null, + null); + + service.synchronize( + user, + assertion(EmailAssurance.VERIFIED), + descriptor(new ProfileSyncPolicy( + ProfileSyncMode.NEVER, + ProfileSyncMode.NEVER, + ProfileSyncMode.NEVER)), + true); + + assertThat(user.getDisplayName()).isEqualTo("usr_1"); + ArgumentCaptor source = + ArgumentCaptor.forClass( + UserProfileFieldSource.class); + verify(sourceRepository).save(source.capture()); + assertThat(source.getValue().getSourceType()) + .isEqualTo( + UserProfileFieldSourceType.LEGACY_LOCAL); + assertThat(source.getValue().getProviderCode()).isNull(); + } + + private static UserProfileFieldSource local( + UserProfileFieldName field, + UserProfileFieldSourceType sourceType) { + return UserProfileFieldSource.local( + "usr_1", + field, + sourceType, + UPDATED_AT.minusSeconds(60)); + } + + private static IdentityAssertion assertion( + EmailAssurance emailAssurance) { + return new IdentityAssertion( + new ProviderReference( + "github", + "oauth2-github", + "https://github.com"), + new ExternalSubject( + "github_user_id", + "123456"), + Set.of(), + new ExternalProfile( + "alice", + Optional.of(new EmailClaim( + "alice@example.com", + emailAssurance)), + Optional.of(URI.create( + "https://avatars.example/alice.png"))), + Map.of(), + new AuthenticationEvidence( + "oauth2-github", + AUTHENTICATED_AT, + Set.of("oauth2_authorization_code"))); + } + + private static ProviderDescriptor descriptor( + ProfileSyncPolicy profileSyncPolicy) { + return new ProviderDescriptor( + "github", + "oauth2-github", + "https://github.com", + "GitHub", + "github_user_id", + "github_user_id", + Map.of( + "github_user_id", + SubjectCanonicalizer.DECIMAL), + List.of("login"), + List.of("email"), + List.of("avatar_url"), + EmailAssurance.VERIFIED, + ProvisioningMode.AUTO, + profileSyncPolicy); + } +} diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java index 64d3b1a3..48c29e3a 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/identity/StaticTrustedProviderDescriptorSourceTest.java @@ -32,6 +32,49 @@ class StaticTrustedProviderDescriptorSourceTest { .isEqualTo("github_user_id"); } + @Test + void attachesProviderScopedProvisioningAndProfilePolicies() { + ClientRegistration github = github(); + OAuth2ClientProperties properties = + new OAuth2ClientProperties(); + properties.getRegistration().put( + "github", + properties("client-id", "GitHub")); + IdentityProviderPolicyProperties.ProviderPolicy configured = + new IdentityProviderPolicyProperties.ProviderPolicy(); + configured.setProvisioningMode( + ProvisioningMode.APPROVAL); + IdentityProviderPolicyProperties.ProfilePolicy profile = + new IdentityProviderPolicyProperties.ProfilePolicy(); + profile.setDisplayName( + ProfileSyncMode.INITIAL_ONLY); + profile.setEmail( + ProfileSyncMode.PROVIDER_AUTHORITATIVE); + profile.setAvatarUrl(ProfileSyncMode.NEVER); + configured.setProfileSync(profile); + IdentityProviderPolicyProperties policies = + new IdentityProviderPolicyProperties(); + policies.setProviders(Map.of("github", configured)); + StaticTrustedProviderDescriptorSource source = + new StaticTrustedProviderDescriptorSource( + properties, + new InMemoryClientRegistrationRepository( + github), + Set.of("github"), + policies); + + ProviderDescriptor descriptor = + source.require(source.resolve(github)); + + assertThat(descriptor.provisioningMode()) + .isEqualTo(ProvisioningMode.APPROVAL); + assertThat(descriptor.profileSyncPolicy()) + .isEqualTo(new ProfileSyncPolicy( + ProfileSyncMode.INITIAL_ONLY, + ProfileSyncMode.PROVIDER_AUTHORITATIVE, + ProfileSyncMode.NEVER)); + } + @Test void rejectsReconstructedRegistrationEvenWhenVisibleFieldsMatch() { ClientRegistration trustedGithub = github(); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java index e4a8de90..99f8ffad 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/oauth/OAuthLoginFlowServiceTest.java @@ -85,6 +85,41 @@ class OAuthLoginFlowServiceTest { .isInstanceOf(AccountPendingException.class); } + @Test + void linkRequiredOutcomeExposesOnlyGenericOAuthFailure() { + TrustedProviderRouteResolver resolver = + mock(TrustedProviderRouteResolver.class); + ExternalIdentityLoginService identityLoginService = + mock(ExternalIdentityLoginService.class); + OAuthLoginFlowService service = + new OAuthLoginFlowService( + List.of(), + resolver, + identityLoginService); + when(identityLoginService.authenticate(any(), any(), any())) + .thenReturn(new IdentityLoginOutcome.LinkRequired( + "EMAIL_COLLISION")); + + assertThatThrownBy(() -> + service.authenticate( + registration(), + result(), + context())) + .isInstanceOfSatisfying( + OAuth2AuthenticationException.class, + exception -> { + assertThat(exception.getError() + .getErrorCode()) + .isEqualTo("link_required"); + assertThat(exception.getError() + .getDescription()) + .isEqualTo( + "Additional account verification is required") + .doesNotContain( + "EMAIL_COLLISION"); + }); + } + @Test void authorityMismatchIsMappedToStableOAuthFailure() { TrustedProviderRouteResolver resolver = diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileReviewService.java index 237ad79d..d9b4be91 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/ProfileReviewService.java @@ -30,13 +30,16 @@ public class ProfileReviewService { private final ProfileChangeRequestRepository changeRequestRepository; private final UserAccountRepository userAccountRepository; + private final UserProfileFieldSourceService fieldSourceService; private final AuditLogService auditLogService; public ProfileReviewService(ProfileChangeRequestRepository changeRequestRepository, UserAccountRepository userAccountRepository, + UserProfileFieldSourceService fieldSourceService, AuditLogService auditLogService) { this.changeRequestRepository = changeRequestRepository; this.userAccountRepository = userAccountRepository; + this.fieldSourceService = fieldSourceService; this.auditLogService = auditLogService; } @@ -77,7 +80,8 @@ public class ProfileReviewService { var request = findPendingOrThrow(requestId); // Apply field changes to user account - var user = userAccountRepository.findById(request.getUserId()) + var user = userAccountRepository + .findByIdForUpdate(request.getUserId()) .orElseThrow(() -> new DomainNotFoundException("error.user.notFound")); applyChanges(user, parseJson(request.getChanges())); userAccountRepository.save(user); @@ -137,6 +141,10 @@ public class ProfileReviewService { private void applyChanges(UserAccount user, Map changes) { if (changes.containsKey("displayName")) { user.setDisplayName(changes.get("displayName")); + fieldSourceService.markUserProvided( + user.getId(), + java.util.List.of( + UserProfileFieldName.DISPLAY_NAME)); } // Future: avatarUrl, bio, etc. } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java index c0f4f295..5c081c7a 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccountRepository.java @@ -11,6 +11,7 @@ import java.util.Optional; */ public interface UserAccountRepository { Optional findById(String id); + Optional findByIdForUpdate(String id); List findByIdIn(List ids); Optional findByEmailIgnoreCase(String email); Page search(String keyword, UserStatus status, Pageable pageable); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldAssurance.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldAssurance.java new file mode 100644 index 00000000..a04230dd --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldAssurance.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.user; + +public enum UserProfileFieldAssurance { + UNVERIFIED, + PROVIDER_ASSERTED, + VERIFIED, + AUTHORITATIVE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldName.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldName.java new file mode 100644 index 00000000..bdae0e69 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldName.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.domain.user; + +public enum UserProfileFieldName { + DISPLAY_NAME("displayName"), + EMAIL("email"), + AVATAR_URL("avatarUrl"); + + private final String databaseValue; + + UserProfileFieldName(String databaseValue) { + this.databaseValue = databaseValue; + } + + public String databaseValue() { + return databaseValue; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSource.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSource.java new file mode 100644 index 00000000..7ed8e877 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSource.java @@ -0,0 +1,195 @@ +package com.iflytek.skillhub.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.Instant; +import java.util.Objects; + +@Entity +@Table(name = "user_profile_field_source") +@IdClass(UserProfileFieldSourceId.class) +public class UserProfileFieldSource { + + @Id + @Column(name = "user_id", length = 128) + private String userId; + + @Id + @Column(name = "field_name", length = 32) + private String fieldName; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 32) + private UserProfileFieldSourceType sourceType; + + @Column(name = "provider_code", length = 64) + private String providerCode; + + @Enumerated(EnumType.STRING) + @Column(length = 32) + private UserProfileFieldAssurance assurance; + + @Column(name = "last_synchronized_at") + private Instant lastSynchronizedAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + protected UserProfileFieldSource() { + } + + private UserProfileFieldSource( + String userId, + UserProfileFieldName fieldName, + UserProfileFieldSourceType sourceType, + String providerCode, + UserProfileFieldAssurance assurance, + Instant lastSynchronizedAt, + Instant updatedAt) { + this.userId = requireUserId(userId); + this.fieldName = Objects.requireNonNull( + fieldName, + "fieldName").databaseValue(); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt"); + update( + sourceType, + providerCode, + assurance, + lastSynchronizedAt, + updatedAt); + } + + public static UserProfileFieldSource provider( + String userId, + UserProfileFieldName fieldName, + String providerCode, + UserProfileFieldAssurance assurance, + Instant synchronizedAt, + Instant updatedAt) { + return new UserProfileFieldSource( + userId, + fieldName, + UserProfileFieldSourceType.PROVIDER, + providerCode, + assurance, + synchronizedAt, + updatedAt); + } + + public static UserProfileFieldSource local( + String userId, + UserProfileFieldName fieldName, + UserProfileFieldSourceType sourceType, + Instant updatedAt) { + if (sourceType == UserProfileFieldSourceType.PROVIDER) { + throw new IllegalArgumentException( + "Provider source requires provider metadata"); + } + return new UserProfileFieldSource( + userId, + fieldName, + sourceType, + null, + null, + null, + updatedAt); + } + + public void markProvider( + String providerCode, + UserProfileFieldAssurance assurance, + Instant synchronizedAt, + Instant updatedAt) { + update( + UserProfileFieldSourceType.PROVIDER, + providerCode, + assurance, + synchronizedAt, + updatedAt); + } + + public void markLocal( + UserProfileFieldSourceType sourceType, + Instant updatedAt) { + if (sourceType == UserProfileFieldSourceType.PROVIDER) { + throw new IllegalArgumentException( + "Provider source requires provider metadata"); + } + update(sourceType, null, null, null, updatedAt); + } + + private void update( + UserProfileFieldSourceType sourceType, + String providerCode, + UserProfileFieldAssurance assurance, + Instant lastSynchronizedAt, + Instant updatedAt) { + this.sourceType = Objects.requireNonNull( + sourceType, + "sourceType"); + this.updatedAt = Objects.requireNonNull(updatedAt, "updatedAt"); + if (sourceType == UserProfileFieldSourceType.PROVIDER) { + this.providerCode = requireProviderCode(providerCode); + this.assurance = Objects.requireNonNull( + assurance, + "assurance"); + this.lastSynchronizedAt = Objects.requireNonNull( + lastSynchronizedAt, + "lastSynchronizedAt"); + return; + } + this.providerCode = null; + this.assurance = null; + this.lastSynchronizedAt = null; + } + + private static String requireUserId(String value) { + Objects.requireNonNull(value, "userId"); + if (value.isBlank() || value.length() > 128) { + throw new IllegalArgumentException("Invalid user id"); + } + return value; + } + + private static String requireProviderCode(String value) { + Objects.requireNonNull(value, "providerCode"); + if (value.isBlank() || value.length() > 64) { + throw new IllegalArgumentException( + "Invalid identity provider code"); + } + return value; + } + + public String getUserId() { + return userId; + } + + public String getFieldName() { + return fieldName; + } + + public UserProfileFieldSourceType getSourceType() { + return sourceType; + } + + public String getProviderCode() { + return providerCode; + } + + public UserProfileFieldAssurance getAssurance() { + return assurance; + } + + public Instant getLastSynchronizedAt() { + return lastSynchronizedAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceId.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceId.java new file mode 100644 index 00000000..90c90741 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceId.java @@ -0,0 +1,35 @@ +package com.iflytek.skillhub.domain.user; + +import java.io.Serializable; +import java.util.Objects; + +public class UserProfileFieldSourceId implements Serializable { + + private String userId; + private String fieldName; + + public UserProfileFieldSourceId() { + } + + public UserProfileFieldSourceId(String userId, String fieldName) { + this.userId = userId; + this.fieldName = fieldName; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof UserProfileFieldSourceId that)) { + return false; + } + return Objects.equals(userId, that.userId) + && Objects.equals(fieldName, that.fieldName); + } + + @Override + public int hashCode() { + return Objects.hash(userId, fieldName); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceRepository.java new file mode 100644 index 00000000..578e1d29 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.domain.user; + +import java.util.List; +import java.util.Optional; + +public interface UserProfileFieldSourceRepository { + + Optional findByUserIdAndFieldName( + String userId, + String fieldName); + + List findByUserId(String userId); + + UserProfileFieldSource save(UserProfileFieldSource source); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceService.java new file mode 100644 index 00000000..17ca46f6 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceService.java @@ -0,0 +1,63 @@ +package com.iflytek.skillhub.domain.user; + +import java.time.Clock; +import java.time.Instant; +import java.util.Collection; +import java.util.Objects; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserProfileFieldSourceService { + + private final UserProfileFieldSourceRepository repository; + private final Clock clock; + + public UserProfileFieldSourceService( + UserProfileFieldSourceRepository repository, + Clock clock) { + this.repository = repository; + this.clock = clock; + } + + @Transactional + public void markUserProvided( + String userId, + Collection fields) { + markLocal( + userId, + fields, + UserProfileFieldSourceType.USER); + } + + @Transactional + public void markAdminProvided( + String userId, + Collection fields) { + markLocal( + userId, + fields, + UserProfileFieldSourceType.ADMIN); + } + + private void markLocal( + String userId, + Collection fields, + UserProfileFieldSourceType sourceType) { + Objects.requireNonNull(fields, "fields"); + Instant updatedAt = Instant.now(clock); + for (UserProfileFieldName field : fields) { + UserProfileFieldSource source = repository + .findByUserIdAndFieldName( + userId, + field.databaseValue()) + .orElseGet(() -> UserProfileFieldSource.local( + userId, + field, + sourceType, + updatedAt)); + source.markLocal(sourceType, updatedAt); + repository.save(source); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceType.java new file mode 100644 index 00000000..3bf4fdc0 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileFieldSourceType.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.domain.user; + +public enum UserProfileFieldSourceType { + PROVIDER, + USER, + ADMIN, + LEGACY_LOCAL +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index c6f037c3..de3361fc 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -31,6 +31,7 @@ public class UserProfileService { private final ProfileModerationService moderationService; private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; + private final UserProfileFieldSourceService fieldSourceService; private final AuditLogService auditLogService; private final ApplicationEventPublisher eventPublisher; @@ -39,6 +40,7 @@ public class UserProfileService { ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, + UserProfileFieldSourceService fieldSourceService, AuditLogService auditLogService, ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; @@ -46,6 +48,7 @@ public class UserProfileService { this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; + this.fieldSourceService = fieldSourceService; this.auditLogService = auditLogService; this.eventPublisher = eventPublisher; } @@ -73,7 +76,8 @@ public class UserProfileService { String requestId, String clientIp, String userAgent) { - UserAccount user = userAccountRepository.findById(userId) + UserAccount user = userAccountRepository + .findByIdForUpdate(userId) .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); // 1. Build snapshot of old values for audit and rollback @@ -141,6 +145,9 @@ public class UserProfileService { private void applyChanges(UserAccount user, Map changes) { if (changes.containsKey("displayName")) { user.setDisplayName(changes.get("displayName")); + fieldSourceService.markUserProvided( + user.getId(), + List.of(UserProfileFieldName.DISPLAY_NAME)); } // Future: avatarUrl, etc. userAccountRepository.save(user); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/ProfileReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/ProfileReviewServiceTest.java index 432dd55f..a4ca605a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/ProfileReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/ProfileReviewServiceTest.java @@ -24,13 +24,19 @@ class ProfileReviewServiceTest { @Mock private UserAccountRepository userAccountRepository; @Mock + private UserProfileFieldSourceService fieldSourceService; + @Mock private AuditLogService auditLogService; private ProfileReviewService service; @BeforeEach void setUp() { - service = new ProfileReviewService(changeRequestRepository, userAccountRepository, auditLogService); + service = new ProfileReviewService( + changeRequestRepository, + userAccountRepository, + fieldSourceService, + auditLogService); } private ProfileChangeRequest pendingRequest(String userId) { @@ -56,7 +62,7 @@ class ProfileReviewServiceTest { var user = new UserAccount("user-1", "OldName", "u@example.com", null); when(changeRequestRepository.findById(1L)).thenReturn(Optional.of(request)); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); var result = service.approve(1L, "admin-1", "req-1", "127.0.0.1", "TestAgent"); @@ -65,6 +71,10 @@ class ProfileReviewServiceTest { assertNotNull(result.getReviewedAt()); assertEquals("NewName", user.getDisplayName()); verify(userAccountRepository).save(user); + verify(fieldSourceService).markUserProvided( + "user-1", + java.util.List.of( + UserProfileFieldName.DISPLAY_NAME)); verify(changeRequestRepository).save(request); verify(auditLogService).record(eq("admin-1"), eq("PROFILE_REVIEW_APPROVE"), eq("PROFILE_CHANGE_REQUEST"), eq(1L), any(), any(), any(), any()); @@ -116,7 +126,7 @@ class ProfileReviewServiceTest { void approve_userNotFound_throwsNotFoundException() { var request = pendingRequest("deleted-user"); when(changeRequestRepository.findById(1L)).thenReturn(Optional.of(request)); - when(userAccountRepository.findById("deleted-user")).thenReturn(Optional.empty()); + when(userAccountRepository.findByIdForUpdate("deleted-user")).thenReturn(Optional.empty()); assertThrows(DomainNotFoundException.class, () -> service.approve(1L, "admin-1", "req-1", "127.0.0.1", "TestAgent")); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index 1346e6a2..cf34f02f 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -43,6 +43,9 @@ class UserProfileServiceTest { @Mock private ProfileFieldPolicyConfig fieldPolicyConfig; + @Mock + private UserProfileFieldSourceService fieldSourceService; + @Mock private AuditLogService auditLogService; @@ -84,7 +87,7 @@ class UserProfileServiceTest { @Test void updateProfile_noModeration_shouldApplyImmediately() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(false); stubFieldPolicies(false); @@ -97,6 +100,9 @@ class UserProfileServiceTest { // user_account should be updated assertEquals("NewName", user.getDisplayName()); verify(userAccountRepository).save(user); + verify(fieldSourceService).markUserProvided( + "user-1", + List.of(UserProfileFieldName.DISPLAY_NAME)); // Change request should be saved as APPROVED var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class); @@ -113,7 +119,7 @@ class UserProfileServiceTest { @Test void updateProfile_sameValue_shouldSucceed() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(false); stubFieldPolicies(false); @@ -129,7 +135,7 @@ class UserProfileServiceTest { @Test void updateProfile_humanReviewEnabled_shouldCreatePendingRequest() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(false); when(moderationConfig.humanReview()).thenReturn(true); stubFieldPolicies(true); @@ -145,6 +151,8 @@ class UserProfileServiceTest { // user_account should NOT be updated assertEquals("OldName", user.getDisplayName()); verify(userAccountRepository, never()).save(any()); + verify(fieldSourceService, never()) + .markUserProvided(any(), any()); // Change request should be saved as PENDING var captor = ArgumentCaptor.forClass(ProfileChangeRequest.class); @@ -159,7 +167,7 @@ class UserProfileServiceTest { @Test void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(false); when(moderationConfig.humanReview()).thenReturn(true); stubFieldPolicies(true); @@ -190,7 +198,7 @@ class UserProfileServiceTest { var oldRequest = new ProfileChangeRequest("user-1", "{\"displayName\":\"PendingName\"}", "{\"displayName\":\"OldName\"}", ProfileChangeStatus.PENDING, "SKIPPED", null); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(false); when(moderationConfig.humanReview()).thenReturn(true); stubFieldPolicies(true); @@ -212,7 +220,7 @@ class UserProfileServiceTest { @Test void updateProfile_machinePassAndHumanReview_shouldCreatePendingWithPassResult() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(true); when(moderationConfig.humanReview()).thenReturn(true); when(moderationService.moderate("user-1", displayNameChange("NewName"))) @@ -236,7 +244,7 @@ class UserProfileServiceTest { @Test void updateProfile_machineRejected_shouldThrowAndSaveRejection() { var user = testUser(); - when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(userAccountRepository.findByIdForUpdate("user-1")).thenReturn(Optional.of(user)); when(moderationConfig.machineReview()).thenReturn(true); when(moderationService.moderate("user-1", displayNameChange("BadWord"))) .thenReturn(ModerationResult.rejected("Contains sensitive content")); @@ -261,7 +269,7 @@ class UserProfileServiceTest { @Test void updateProfile_userNotFound_shouldThrow() { - when(userAccountRepository.findById("nonexistent")).thenReturn(Optional.empty()); + when(userAccountRepository.findByIdForUpdate("nonexistent")).thenReturn(Optional.empty()); assertThrows(IllegalArgumentException.class, () -> userProfileService.updateProfile( diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java index f2d4835c..6c8011be 100644 --- a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserAccountJpaRepository.java @@ -3,10 +3,12 @@ package com.iflytek.skillhub.infra.jpa; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -18,6 +20,12 @@ import org.springframework.stereotype.Repository; public interface UserAccountJpaRepository extends JpaRepository, JpaSpecificationExecutor, UserAccountRepository { + @Override + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT u FROM UserAccount u WHERE u.id = :id") + java.util.Optional findByIdForUpdate( + @Param("id") String id); + @Override @Query(""" SELECT u diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserProfileFieldSourceJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserProfileFieldSourceJpaRepository.java new file mode 100644 index 00000000..93c6fd5e --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserProfileFieldSourceJpaRepository.java @@ -0,0 +1,15 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.user.UserProfileFieldSource; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceId; +import com.iflytek.skillhub.domain.user.UserProfileFieldSourceRepository; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserProfileFieldSourceJpaRepository + extends JpaRepository< + UserProfileFieldSource, + UserProfileFieldSourceId>, + UserProfileFieldSourceRepository { +}