feat(auth): add profile sync and provisioning policies

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-30 22:02:24 +08:00
parent b4942b423f
commit b217339012
48 changed files with 3358 additions and 268 deletions

View file

@ -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 账号合并策略

View file

@ -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")

View file

@ -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)) {

View file

@ -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:

View file

@ -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) <> '';

View file

@ -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;
}
}

View file

@ -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");
}
}
}

View file

@ -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

View file

@ -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")

View file

@ -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);

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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());
}
}
}

View file

@ -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;

View file

@ -0,0 +1,9 @@
package com.iflytek.skillhub.auth.identity;
public enum ProfileSyncMode {
NEVER,
INITIAL_ONLY,
FILL_IF_EMPTY,
PRESERVE_LOCAL,
PROVIDER_AUTHORITATIVE
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.auth.identity;
public enum ProvisioningMode {
AUTO,
APPROVAL,
EXISTING_BINDING_ONLY
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.auth.identity;
interface ProvisioningPolicy {
ProvisioningMode evaluate(ProvisioningPolicyContext context);
}

View file

@ -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");
}
}

View file

@ -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(

View file

@ -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
}

View file

@ -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);

View file

@ -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());
}
}

View file

@ -0,0 +1,6 @@
package com.iflytek.skillhub.auth.policy;
public enum IdentityAccessKind {
NEW_IDENTITY,
RETURNING_IDENTITY
}

View file

@ -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",

View file

@ -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();
}
}

View file

@ -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",

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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 =

View file

@ -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.
}

View file

@ -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);

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.user;
public enum UserProfileFieldAssurance {
UNVERIFIED,
PROVIDER_ASSERTED,
VERIFIED,
AUTHORITATIVE
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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);
}
}
}

View file

@ -0,0 +1,8 @@
package com.iflytek.skillhub.domain.user;
public enum UserProfileFieldSourceType {
PROVIDER,
USER,
ADMIN,
LEGACY_LOCAL
}

View file

@ -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);

View file

@ -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"));

View file

@ -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(

View file

@ -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

View file

@ -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 {
}