mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-09 03:17:52 +00:00
feat(auth): add profile sync and provisioning policies
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
b4942b423f
commit
b217339012
48 changed files with 3358 additions and 268 deletions
|
|
@ -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<String> email,
|
||||
EmailAssurance emailAssurance,
|
||||
IdentityLoginContext requestContext
|
||||
IdentityLoginContext requestContext,
|
||||
IdentityAccessKind accessKind,
|
||||
Optional<UserStatus> 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 账号合并策略
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AdminUserMutationResponse> 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<AdminUserMutationResponse> approveUser(@PathVariable String userId) {
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
|
||||
public ApiResponse<AdminUserMutationResponse> 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<AdminUserMutationResponse> disableUser(@PathVariable String userId) {
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "DISABLED"));
|
||||
public ApiResponse<AdminUserMutationResponse> 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<AdminUserMutationResponse> enableUser(@PathVariable String userId) {
|
||||
return ok("response.success.updated", adminUserAppService.updateUserStatus(userId, "ACTIVE"));
|
||||
public ApiResponse<AdminUserMutationResponse> 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")
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,12 @@ skillhub:
|
|||
auth:
|
||||
mock:
|
||||
enabled: ${SKILLHUB_AUTH_MOCK_ENABLED:false}
|
||||
identity:
|
||||
# Provider-specific overrides bind below providers.<registration-id>.
|
||||
# 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:
|
||||
|
|
|
|||
|
|
@ -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) <> '';
|
||||
|
|
@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<Path> migrationFiles() throws IOException {
|
||||
Path root = repoRoot()
|
||||
.resolve("server")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@
|
|||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, ProviderPolicy> 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<String, ProviderPolicy> getProviders() {
|
||||
return providers;
|
||||
}
|
||||
|
||||
public void setProviders(Map<String, ProviderPolicy> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<ExternalSubject> revokedAliases) {
|
||||
Set<ExternalSubject> 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<String> 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<String> 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<UserStatus> 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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
public enum ProfileSyncMode {
|
||||
NEVER,
|
||||
INITIAL_ONLY,
|
||||
FILL_IF_EMPTY,
|
||||
PRESERVE_LOCAL,
|
||||
PROVIDER_AUTHORITATIVE
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, UserProfileFieldSource> 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<String> candidate,
|
||||
UserProfileFieldAssurance assurance,
|
||||
ProfileSyncMode mode,
|
||||
boolean accountCreated,
|
||||
String providerCode,
|
||||
Instant synchronizedAt,
|
||||
Instant updatedAt,
|
||||
Supplier<String> currentValue,
|
||||
Consumer<String> updateValue,
|
||||
Map<String, UserProfileFieldSource> 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<String, UserProfileFieldSource> 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<String, UserProfileFieldSource> 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<String, UserProfileFieldSource> 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<String, UserProfileFieldSource> sourcesByField(
|
||||
String userId) {
|
||||
LinkedHashMap<String, UserProfileFieldSource> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,9 @@ record ProviderDescriptor(
|
|||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> 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<String, SubjectCanonicalizer> subjectCanonicalizers,
|
||||
List<String> displayNameAttributes,
|
||||
List<String> emailAttributes,
|
||||
List<String> 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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
public enum ProvisioningMode {
|
||||
AUTO,
|
||||
APPROVAL,
|
||||
EXISTING_BINDING_ONLY
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.identity;
|
||||
|
||||
interface ProvisioningPolicy {
|
||||
|
||||
ProvisioningMode evaluate(ProvisioningPolicyContext context);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -35,24 +35,40 @@ class StaticTrustedProviderDescriptorSource
|
|||
|
||||
private final Map<String, ProviderDescriptor> descriptors;
|
||||
private final Map<String, ClientRegistration> trustedRegistrations;
|
||||
private final IdentityProviderPolicyProperties policyProperties;
|
||||
|
||||
@Autowired
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
OAuth2ClientProperties properties,
|
||||
ClientRegistrationRepository registrationRepository,
|
||||
List<OAuthClaimsExtractor> extractors) {
|
||||
List<OAuthClaimsExtractor> extractors,
|
||||
IdentityProviderPolicyProperties policyProperties) {
|
||||
this(
|
||||
properties,
|
||||
registrationRepository,
|
||||
extractors.stream()
|
||||
.map(OAuthClaimsExtractor::getProvider)
|
||||
.collect(Collectors.toUnmodifiableSet()));
|
||||
.collect(Collectors.toUnmodifiableSet()),
|
||||
policyProperties);
|
||||
}
|
||||
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
OAuth2ClientProperties properties,
|
||||
ClientRegistrationRepository registrationRepository,
|
||||
Set<String> extractorCodes) {
|
||||
this(
|
||||
properties,
|
||||
registrationRepository,
|
||||
extractorCodes,
|
||||
new IdentityProviderPolicyProperties());
|
||||
}
|
||||
|
||||
StaticTrustedProviderDescriptorSource(
|
||||
OAuth2ClientProperties properties,
|
||||
ClientRegistrationRepository registrationRepository,
|
||||
Set<String> extractorCodes,
|
||||
IdentityProviderPolicyProperties policyProperties) {
|
||||
this.policyProperties = policyProperties;
|
||||
Map<String, ProviderDescriptor> resolvedDescriptors =
|
||||
new LinkedHashMap<>();
|
||||
Map<String, ClientRegistration> 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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> email,
|
||||
EmailAssurance emailAssurance,
|
||||
IdentityLoginContext requestContext
|
||||
IdentityLoginContext requestContext,
|
||||
IdentityAccessKind accessKind,
|
||||
Optional<UserStatus> 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<String> email,
|
||||
EmailAssurance emailAssurance,
|
||||
IdentityLoginContext requestContext) {
|
||||
this(
|
||||
providerCode,
|
||||
subjectType,
|
||||
subjectValue,
|
||||
email,
|
||||
emailAssurance,
|
||||
requestContext,
|
||||
IdentityAccessKind.NEW_IDENTITY,
|
||||
Optional.empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
package com.iflytek.skillhub.auth.policy;
|
||||
|
||||
public enum IdentityAccessKind {
|
||||
NEW_IDENTITY,
|
||||
RETURNING_IDENTITY
|
||||
}
|
||||
|
|
@ -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<com.iflytek.skillhub.auth.policy.IdentityAccessContext>
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
.<ProvisioningPolicyContext>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<UserAccount> 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<IdentityAccessContext> 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<List<IdentityBindingSubject>> subjectsCaptor =
|
||||
|
|
@ -201,6 +347,16 @@ class IdentityResolutionTransactionTest {
|
|||
"github_user_id",
|
||||
"123456",
|
||||
true));
|
||||
ArgumentCaptor<IdentityAccessContext> 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<String, SubjectCanonicalizer> 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",
|
||||
|
|
|
|||
|
|
@ -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<UserProfileFieldSource> 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<UserProfileFieldSource> 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<UserProfileFieldSource> 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<UserProfileFieldSource> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<String, String> 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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import java.util.Optional;
|
|||
*/
|
||||
public interface UserAccountRepository {
|
||||
Optional<UserAccount> findById(String id);
|
||||
Optional<UserAccount> findByIdForUpdate(String id);
|
||||
List<UserAccount> findByIdIn(List<String> ids);
|
||||
Optional<UserAccount> findByEmailIgnoreCase(String email);
|
||||
Page<UserAccount> search(String keyword, UserStatus status, Pageable pageable);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
public enum UserProfileFieldAssurance {
|
||||
UNVERIFIED,
|
||||
PROVIDER_ASSERTED,
|
||||
VERIFIED,
|
||||
AUTHORITATIVE
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserProfileFieldSourceRepository {
|
||||
|
||||
Optional<UserProfileFieldSource> findByUserIdAndFieldName(
|
||||
String userId,
|
||||
String fieldName);
|
||||
|
||||
List<UserProfileFieldSource> findByUserId(String userId);
|
||||
|
||||
UserProfileFieldSource save(UserProfileFieldSource source);
|
||||
}
|
||||
|
|
@ -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<UserProfileFieldName> fields) {
|
||||
markLocal(
|
||||
userId,
|
||||
fields,
|
||||
UserProfileFieldSourceType.USER);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markAdminProvided(
|
||||
String userId,
|
||||
Collection<UserProfileFieldName> fields) {
|
||||
markLocal(
|
||||
userId,
|
||||
fields,
|
||||
UserProfileFieldSourceType.ADMIN);
|
||||
}
|
||||
|
||||
private void markLocal(
|
||||
String userId,
|
||||
Collection<UserProfileFieldName> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.domain.user;
|
||||
|
||||
public enum UserProfileFieldSourceType {
|
||||
PROVIDER,
|
||||
USER,
|
||||
ADMIN,
|
||||
LEGACY_LOCAL
|
||||
}
|
||||
|
|
@ -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<String, String> 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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<UserAccount, String>, JpaSpecificationExecutor<UserAccount>, UserAccountRepository {
|
||||
|
||||
@Override
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT u FROM UserAccount u WHERE u.id = :id")
|
||||
java.util.Optional<UserAccount> findByIdForUpdate(
|
||||
@Param("id") String id);
|
||||
|
||||
@Override
|
||||
@Query("""
|
||||
SELECT u
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue