This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
@@ -1,18 +0,0 @@
package com.baiyue.iam.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
@FeignClient(name = "event-bridge-service", contextId = "iamSecurityEventBridgeClient",
url = "${baiyue.security-event.event-bridge-url:}")
public interface SecurityEventBridgeClient {
@PostMapping("/internal/event-bridge/events")
ApiResponse<Acknowledgement> receive(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody EventEnvelope envelope);
record Acknowledgement(String eventId, String result, String receivedAt) { }
}
@@ -42,6 +42,14 @@ public final class AuthorizationDtos {
private Long permissionVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
private Long tenantVersion;
public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
Long permissionVersion, Long overseasWarehouseId, Long domesticWarehouseId) {
this(userId, tenantId, membershipId, appCode, clientType, roles, permissions,
permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L);
}
public SnapshotResponse(Long userId, Long tenantId, Long membershipId, String appCode,
String clientType, Set<String> roles, Set<String> permissions,
@@ -0,0 +1,14 @@
package com.baiyue.iam.dto;
import lombok.Data;
/** Rows from one MVCC statement; all identity/authorization facts share one read view. */
@Data
public class AuthorizationSnapshotRow {
private String roleCode;
private String permissionCode;
private Long permissionVersion;
private Long tenantVersion;
private Long overseasWarehouseId;
private Long domesticWarehouseId;
}
@@ -12,105 +12,27 @@ import java.util.List;
@Mapper
public interface AuthorizationQueryMapper {
@Select("""
SELECT COUNT(1)
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
WHERE u.auth_user_id = #{userId}
AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
AND m.status = 'ACTIVE' AND m.deleted = 0
AND (m.tenant_id = 0 OR EXISTS (
SELECT 1 FROM iam_tenant t WHERE t.id = m.tenant_id AND t.deleted = 0
AND t.status IN ('ACTIVE', 'PROFILE_REQUIRED', 'PENDING', 'REJECTED')
))
SELECT r.role_code AS roleCode, p.permission_code AS permissionCode,
COALESCE(sv.permission_version,0) AS permissionVersion,
COALESCE(tv.status_version,0) AS tenantVersion,
m.overseas_warehouse_id AS overseasWarehouseId,
m.domestic_warehouse_id AS domesticWarehouseId
FROM iam_user u
JOIN iam_membership m ON m.user_id=u.id
LEFT JOIN iam_tenant t ON t.id=m.tenant_id AND t.deleted=0
LEFT JOIN iam_membership_role mr ON mr.membership_id=m.id AND mr.tenant_id=m.tenant_id
LEFT JOIN iam_role r ON r.id=mr.role_id AND (r.tenant_id=0 OR r.tenant_id=m.tenant_id)
AND r.app_code=#{appCode} AND r.status='ACTIVE' AND r.deleted=0
LEFT JOIN iam_role_permission rp ON rp.role_id=r.id
LEFT JOIN iam_permission p ON p.id=rp.permission_id AND (p.tenant_id=0 OR p.tenant_id=m.tenant_id)
AND p.app_code=#{appCode} AND p.status='ACTIVE' AND p.deleted=0
LEFT JOIN iam_security_subject_version sv ON sv.auth_user_id=u.auth_user_id
LEFT JOIN iam_security_tenant_version tv ON tv.tenant_id=m.tenant_id
WHERE u.auth_user_id=#{userId} AND u.status='ACTIVE' AND u.deleted=0
AND m.id=#{membershipId} AND m.tenant_id=#{tenantId} AND m.status='ACTIVE' AND m.deleted=0
AND (m.tenant_id=0 OR t.status IN ('ACTIVE','PROFILE_REQUIRED','PENDING','REJECTED'))
""")
int countActiveIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
@Select("""
SELECT DISTINCT r.role_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
WHERE u.auth_user_id = #{userId}
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode}
AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.role_code
""")
List<String> findRoles(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT p.permission_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
JOIN iam_role_permission rp ON rp.role_id = r.id
JOIN iam_permission p ON p.id = rp.permission_id
WHERE u.auth_user_id = #{userId}
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId})
AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code
""")
List<String> findPermissions(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT r.role_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.role_code
""")
List<String> findRolesByIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT p.permission_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
JOIN iam_role_permission rp ON rp.role_id = r.id
JOIN iam_permission p ON p.id = rp.permission_id
WHERE u.auth_user_id = #{userId} AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.tenant_id = #{tenantId} AND m.status = 'ACTIVE' AND m.deleted = 0
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId})
AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code
""")
List<String> findPermissionsByIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("appCode") String appCode);
@Select("SELECT COALESCE(MAX(permission_version),0) FROM iam_security_subject_version WHERE auth_user_id=#{userId}")
long findPermissionVersion(@Param("userId") long userId);
@Select("SELECT overseas_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0")
Long findOverseasWarehouseId(@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
@Select("SELECT domestic_warehouse_id FROM iam_membership WHERE id=#{membershipId} AND tenant_id=#{tenantId} AND status='ACTIVE' AND deleted=0")
Long findDomesticWarehouseId(@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
List<com.baiyue.iam.dto.AuthorizationSnapshotRow> findSnapshot(
@Param("userId") long userId, @Param("tenantId") long tenantId,
@Param("membershipId") long membershipId, @Param("appCode") String appCode);
}
@@ -19,7 +19,8 @@ public interface IamMembershipRoleMapper extends BaseMapper<IamMembershipRole> {
FROM iam_membership_role mr
JOIN iam_membership m ON m.id = mr.membership_id AND m.tenant_id = mr.tenant_id
JOIN iam_user u ON u.id = m.user_id
WHERE mr.tenant_id = #{tenantId}
JOIN iam_role r ON r.id = mr.role_id
WHERE (#{tenantId} = 0 OR r.tenant_id = #{tenantId})
AND mr.role_id = #{roleId}
AND m.deleted = 0
AND u.deleted = 0
@@ -69,7 +69,7 @@ public class RbacRepository {
public Set<Long> findRoleIdsByPermission(long tenantId, Long permissionId) {
List<IamRolePermission> rows = rolePermissionMapper.selectList(
new LambdaQueryWrapper<IamRolePermission>()
.eq(IamRolePermission::getTenantId, tenantId)
.eq(tenantId != 0, IamRolePermission::getTenantId, tenantId)
.eq(IamRolePermission::getPermissionId, permissionId));
Set<Long> result = new LinkedHashSet<>();
rows.forEach(row -> result.add(row.getRoleId()));
@@ -24,7 +24,7 @@ public class AuthorizationSnapshotService {
private AuthorizationQueryMapper mapper;
@Transactional(readOnly = true)
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public SnapshotResponse load(SnapshotRequest request) {
requireRequest(request);
String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT);
@@ -37,25 +37,24 @@ public class AuthorizationSnapshotService {
if ("PLATFORM".equals(appCode) && request.getTenantId() != 0) {
throw new BizException(ResultCode.FORBIDDEN, "平台账号必须属于平台租户");
}
if (mapper.countActiveIdentity(request.getUserId(), request.getTenantId(), request.getMembershipId()) == 0) {
var rows = mapper.findSnapshot(request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode);
if (rows.isEmpty()) {
throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效");
}
Set<String> roles = normalizedSet(mapper.findRoles(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
Set<String> permissions = normalizedSet(mapper.findPermissions(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
Set<String> roles = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getRoleCode).toList());
Set<String> permissions = normalizedSet(rows.stream().map(com.baiyue.iam.dto.AuthorizationSnapshotRow::getPermissionCode).toList());
var identity = rows.get(0);
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions),
mapper.findPermissionVersion(request.getUserId()),
mapper.findOverseasWarehouseId(request.getTenantId(), request.getMembershipId()),
mapper.findDomesticWarehouseId(request.getTenantId(), request.getMembershipId()));
identity.getPermissionVersion(), identity.getOverseasWarehouseId(),
identity.getDomesticWarehouseId(), identity.getTenantVersion());
}
/**
* 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。
*/
@Transactional(readOnly = true)
@Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED)
public Set<String> currentPermissions(AuthenticatedUser user, String appCode) {
if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整");
@@ -41,7 +41,7 @@ public class MemberService {
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
private SecurityVersionService securityVersions;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -106,8 +106,7 @@ public class MemberService {
membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo());
membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership);
repository.replaceRoles(tenantId, membershipId, request.roleIds());
securityEvents.userStatus(authId, tenantId, "MERCHANT", "ACTIVE");
securityEvents.permissionChanged(authId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION");
securityVersions.subjectChanged(authId);
return response(tenantId, membership, user);
} catch (RuntimeException exception) {
try {
@@ -146,6 +145,7 @@ public class MemberService {
if (!new HashSet<>(currentRoleIds).equals(new HashSet<>(request.roleIds()))) {
call(() -> authClient.revokeSessions(internalToken,
new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId()))));
securityVersions.subjectChanged(user.getAuthUserId());
}
return response(tenantId, membership);
}
@@ -169,9 +169,7 @@ public class MemberService {
membership.setStatus(request.status());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
user.setStatus(request.status()); repository.saveUser(user); repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), tenantId, "MERCHANT", request.status());
securityEvents.permissionChanged(user.getAuthUserId(), tenantId, "MERCHANT",
"MEMBER_STATUS_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
} catch (RuntimeException localFailure) {
try {
authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus));
@@ -43,7 +43,7 @@ public class MerchantOnboardingService {
@Resource private IamTenantReviewMapper reviewMapper;
@Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource private SecurityEventOutboxService securityEvents;
@Resource private SecurityVersionService securityVersions;
@Resource private CustomerCodeRepository customerCodes;
@Transactional
@@ -82,7 +82,7 @@ public class MerchantOnboardingService {
tenant.setDeleted(false);
tenant.setVersion(0);
tenantMapper.insert(tenant);
securityEvents.tenantStatus(tenantId, tenant.getStatus());
securityVersions.tenantChanged(tenantId);
IamUser user = new IamUser();
user.setId(userId);
@@ -114,8 +114,7 @@ public class MerchantOnboardingService {
membershipRole.setTenantId(tenantId);
membershipRole.setCreatedAt(LocalDateTime.now());
membershipRoleMapper.insert(membershipRole);
securityEvents.userStatus(request.authUserId(), tenantId, "MERCHANT", "ACTIVE");
securityEvents.permissionChanged(request.authUserId(), tenantId, "MERCHANT", "INITIAL_AUTHORIZATION");
securityVersions.subjectChanged(request.authUserId());
if (StringUtils.hasText(request.address())) {
IamTenantAddress address = new IamTenantAddress();
@@ -39,7 +39,7 @@ public class PlatformInternalUserService {
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
private SecurityVersionService securityVersions;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -77,7 +77,7 @@ public class PlatformInternalUserService {
repository.insertUser(user);
repository.insert(membership);
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
publishInitialSecurityProjection(user);
initializeSecurityVersion(user);
return response(membership, user, request.username(), roleIds);
} catch (RuntimeException localFailure) {
compensateCreate(authUserId, localFailure);
@@ -116,6 +116,7 @@ public class PlatformInternalUserService {
repository.save(membership);
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
if (rolesChanged && !displayNameChanged) revokeSessions(user.getAuthUserId());
if (rolesChanged) securityVersions.subjectChanged(user.getAuthUserId());
} catch (RuntimeException localFailure) {
if (displayNameChanged) compensateProfile(user.getAuthUserId(), oldAccount.displayName(), localFailure);
throw localFailure;
@@ -146,9 +147,7 @@ public class PlatformInternalUserService {
user.setStatus(request.status());
repository.saveUser(user);
repository.save(membership);
securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", request.status());
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
"PLATFORM_INTERNAL_USER_STATUS_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
return response(membership, user, authAccount.username(), roleIds);
} catch (RuntimeException localFailure) {
compensateStatus(user.getAuthUserId(), authAccount.status(), localFailure);
@@ -178,8 +177,7 @@ public class PlatformInternalUserService {
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
if (!new java.util.HashSet<>(currentRoleIds).equals(new java.util.HashSet<>(roleIds))) {
revokeSessions(user.getAuthUserId());
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
"MEMBER_ROLES_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
}
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
return response(membership, user, authAccount.username(), roleIds);
@@ -204,14 +202,12 @@ public class PlatformInternalUserService {
IamMembership membership = newMembership(membershipId, user.getId(), memberNo);
repository.insert(membership);
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
publishInitialSecurityProjection(user);
initializeSecurityVersion(user);
return response(membership, user, authAccount.username(), roleIds);
}
private void publishInitialSecurityProjection(IamUser user) {
securityEvents.userStatus(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM", user.getStatus());
securityEvents.permissionChanged(user.getAuthUserId(), PLATFORM_TENANT_ID, "PLATFORM",
"INITIAL_AUTHORIZATION");
private void initializeSecurityVersion(IamUser user) {
securityVersions.subjectChanged(user.getAuthUserId());
}
private InternalUserResponse response(IamMembership membership) {
@@ -44,7 +44,7 @@ public class PlatformUserManagementService {
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
private SecurityVersionService securityVersions;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -107,8 +107,7 @@ public class PlatformUserManagementService {
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
members.insert(membership);
members.replaceRoles(tenantId, membershipId, roleIds);
securityEvents.userStatus(authUserId, tenantId, appCode, request.status());
securityEvents.permissionChanged(authUserId, tenantId, appCode, "INITIAL_AUTHORIZATION");
securityVersions.subjectChanged(authUserId);
return response(membership);
} catch (RuntimeException localFailure) {
compensateCreate(authUserId, localFailure);
@@ -170,8 +169,7 @@ public class PlatformUserManagementService {
new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(user.getAuthUserId()))));
}
if (rolesChanged || warehouseChanged || scopeChanged) {
securityEvents.permissionChanged(user.getAuthUserId(), newTenantId,
appCode(newTenantId), "PLATFORM_USER_AUTHORIZATION_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
}
} catch (RuntimeException localFailure) {
compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount,
@@ -204,10 +202,7 @@ public class PlatformUserManagementService {
user.setStatus(request.status());
members.saveUser(user);
members.save(membership);
securityEvents.userStatus(user.getAuthUserId(), membership.getTenantId(),
appCode(membership.getTenantId()), request.status());
securityEvents.permissionChanged(user.getAuthUserId(), membership.getTenantId(),
appCode(membership.getTenantId()), "PLATFORM_USER_STATUS_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
} catch (RuntimeException localFailure) {
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(oldAccount.status())));
@@ -33,7 +33,7 @@ public class RbacService {
@Resource private RbacRepository repository;
@Resource private MemberRepository memberRepository;
@Resource private AuthAccountClient authClient;
@Resource private SecurityEventOutboxService securityEvents;
@Resource private SecurityVersionService securityVersions;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -186,6 +186,7 @@ public class RbacService {
affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId));
}
revokeSessions(List.copyOf(affectedAccounts));
affectedAccounts.forEach(id -> securityVersions.subjectChanged(id));
return permission;
}
@@ -240,7 +241,7 @@ public class RbacService {
var user = memberRepository.findUser(membership.getUserId());
if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable();
revokeSessions(List.of(user.getAuthUserId()));
securityEvents.permissionChanged(user.getAuthUserId(), tenantId, appCode, "MEMBER_ROLES_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
}
}
@@ -260,12 +261,13 @@ public class RbacService {
repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds());
List<Long> affected = repository.findAssignedAuthUserIds(tenantId, request.roleId());
revokeSessions(affected);
affected.forEach(userId -> securityEvents.permissionChanged(userId, tenantId, appCode,
"ROLE_PERMISSIONS_CHANGED"));
affected.forEach(userId -> securityVersions.subjectChanged(userId));
}
private void revokeAssignedSessions(long tenantId, Long roleId) {
revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId));
List<Long> affected = repository.findAssignedAuthUserIds(tenantId, roleId);
revokeSessions(affected);
affected.forEach(id -> securityVersions.subjectChanged(id));
}
private void revokeSessions(List<Long> accountIds) {
@@ -1,91 +0,0 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.common.event.RegionCode;
import com.baiyue.iam.client.SecurityEventBridgeClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
/** IAM 安全事件至少一次投递器,支持过期租约回收、退避重试和死信。 */
@Component
@Slf4j
public class SecurityEventOutboxRelay {
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
@Resource private SecurityEventBridgeClient client;
@Value("${baiyue.security.internal-service-token}") private String internalToken;
@Value("${baiyue.security-event.max-attempts:12}") private int maxAttempts;
private final String workerId = UUID.randomUUID().toString();
@Scheduled(fixedDelayString = "${baiyue.security-event.fixed-delay-ms:5000}")
public void relay() {
recoverExpiredLeases();
for (String eventId : candidates()) deliver(eventId);
}
@Transactional
public void deliver(String eventId) {
LocalDateTime now = LocalDateTime.now();
int claimed = jdbc.update("UPDATE iam_security_event_outbox SET status='SENDING',lease_owner=?,"
+ "lease_until=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?",
workerId, now.plusMinutes(2), now, eventId, now);
if (claimed != 1) return;
String json = jdbc.queryForObject("SELECT payload_json FROM iam_security_event_outbox "
+ "WHERE event_id=? AND status='SENDING' AND lease_owner=?", String.class, eventId, workerId);
try {
EventEnvelope envelope = objectMapper.readValue(json, EventEnvelope.class);
EventEnvelopeValidator.requireValid(envelope);
if (envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) {
throw new IllegalStateException("IAM 安全事件必须从 CN 投影到 OVERSEAS");
}
var response = client.receive(internalToken, envelope);
if (response == null || !response.successful() || response.data() == null
|| !eventId.equals(response.data().eventId())) {
throw new IllegalStateException("事件桥未确认 IAM 安全事件");
}
jdbc.update("UPDATE iam_security_event_outbox SET status='SENT',sent_at=?,lease_owner=NULL,"
+ "lease_until=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND lease_owner=?",
LocalDateTime.now(), LocalDateTime.now(), eventId, workerId);
} catch (Exception exception) {
fail(eventId, exception.getMessage());
log.warn("IAM 安全事件投递失败,eventId={}, reason={}", eventId, exception.getMessage());
}
}
private List<String> candidates() {
return jdbc.queryForList("SELECT event_id FROM iam_security_event_outbox WHERE status='PENDING' "
+ "AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class);
}
private void recoverExpiredLeases() {
jdbc.update("UPDATE iam_security_event_outbox SET status='PENDING',lease_owner=NULL,lease_until=NULL,"
+ "next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND lease_until<NOW(3)");
}
private void fail(String eventId, String message) {
Integer attempts = jdbc.queryForObject("SELECT attempt_count FROM iam_security_event_outbox WHERE event_id=?",
Integer.class, eventId);
int next = (attempts == null ? 0 : attempts) + 1;
String status = next >= maxAttempts ? "DEAD" : "PENDING";
long delay = Math.min(1800, 5L << Math.min(next, 8));
jdbc.update("UPDATE iam_security_event_outbox SET status=?,attempt_count=?,next_retry_at=?,"
+ "lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=? WHERE event_id=? AND lease_owner=?",
status, next, LocalDateTime.now().plusSeconds(delay), truncate(message), LocalDateTime.now(), eventId, workerId);
}
private String truncate(String value) {
String text = value == null ? "unknown" : value.replaceAll("[\\r\\n\\t]+", " ");
return text.length() > 1000 ? text.substring(0, 1000) : text;
}
}
@@ -1,83 +0,0 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/** 在 IAM 状态事务内递增安全版本并写入可靠事件发送箱。 */
@Service
public class SecurityEventOutboxService {
private static final List<String> OVERSEAS_AUTHORITY_PREFIXES = List.of(
"platform:overseas-warehouse:", "platform:overseas-business:",
"platform:overseas-master-data:", "merchant:warehouse:", "merchant:overseas-warehouse:",
"merchant:cross-border:", "platform:finance:", "lastleg:");
@Resource private JdbcTemplate jdbc;
@Resource private ObjectMapper objectMapper;
@Resource private AuthorizationQueryMapper authorizationQueries;
public void userStatus(long authUserId, long tenantId, String appCode, String status) {
long version = bump(authUserId);
write(EventTypes.IAM_USER_STATUS_CHANGED, "USER", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.UserStatusChanged(authUserId, tenantId, appCode, status, version, Instant.now()));
}
public void tenantStatus(long tenantId, String status) {
jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId);
long version = jdbc.queryForObject(
"SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=?", Long.class, tenantId);
String securityStatus = "ACTIVE".equals(status) ? "ACTIVE" : "DISABLED";
write(EventTypes.IAM_TENANT_STATUS_CHANGED, "TENANT", String.valueOf(tenantId), tenantId, version,
new IamSecurityEventsV1.TenantStatusChanged(tenantId, securityStatus, Instant.now()));
}
public void permissionChanged(long authUserId, long tenantId, String appCode, String reason) {
long version = bump(authUserId);
Set<String> roles = Set.copyOf(authorizationQueries.findRolesByIdentity(authUserId, tenantId, appCode));
Set<String> authorities = authorizationQueries.findPermissionsByIdentity(authUserId, tenantId, appCode)
.stream().filter(permission -> permission != null && OVERSEAS_AUTHORITY_PREFIXES.stream()
.anyMatch(permission::startsWith)).collect(Collectors.toUnmodifiableSet());
write(EventTypes.IAM_PERMISSION_VERSION_CHANGED, "AUTHORIZATION", String.valueOf(authUserId), tenantId, version,
new IamSecurityEventsV1.PermissionVersionChanged(authUserId, tenantId, appCode, version, reason,
Instant.now(), roles, authorities));
}
private long bump(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
return jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=?",
Long.class, authUserId);
}
private void write(String type, String aggregateType, String aggregateId, long tenantId,
long aggregateVersion, Object value) {
JsonNode payload = objectMapper.valueToTree(value);
String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload));
String flowId = EventIdentifiers.newUuid();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), type, IamSecurityEventsV1.VERSION,
Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, String.valueOf(tenantId), aggregateType,
aggregateId, aggregateVersion, null, flowId, flowId, null, payload, hash).requireValid();
try {
jdbc.update("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))", envelope.eventId(), type, aggregateType, aggregateId,
objectMapper.writeValueAsString(envelope));
} catch (Exception exception) {
throw new IllegalStateException("IAM 安全事件写入失败", exception);
}
}
}
@@ -0,0 +1,24 @@
package com.baiyue.iam.service;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** Durable security epochs commit or roll back with their IAM mutation, with no projection relay. */
@Service
@Transactional(propagation = Propagation.MANDATORY)
public class SecurityVersionService {
@Resource private JdbcTemplate jdbc;
public void subjectChanged(long authUserId) {
jdbc.update("INSERT INTO iam_security_subject_version(auth_user_id,permission_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE permission_version=permission_version+1", authUserId);
}
public void tenantChanged(long tenantId) {
jdbc.update("INSERT INTO iam_security_tenant_version(tenant_id,status_version) VALUES (?,1) "
+ "ON DUPLICATE KEY UPDATE status_version=status_version+1", tenantId);
}
}
@@ -44,7 +44,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
@Resource
private AuthAccountClient authClient;
@Resource
private SecurityEventOutboxService securityEvents;
private SecurityVersionService securityVersions;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
private SecureRandom random = new SecureRandom();
@@ -115,7 +115,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
membership.setUpdatedBy(operatorId);
members.insert(membership);
members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId()));
publishInitialSecurityProjection(authUserId, tenant.getId());
initializeSecurityVersion(authUserId, tenant.getId());
return result;
} catch (RuntimeException localFailure) {
compensate(context, result);
@@ -156,7 +156,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
String password = temporaryPassword();
String username = createAuthAccount(user.getAuthUserId(), password, tenant, owner.getId(),
user.getPhone(), user.getDisplayName());
publishInitialSecurityProjection(user.getAuthUserId(), tenant.getId());
initializeSecurityVersion(user.getAuthUserId(), tenant.getId());
return ProvisioningResult.created(user.getAuthUserId(), username, password);
}
if (!"ACTIVE".equals(account.status())
@@ -171,8 +171,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
List<Long> repairedRoleIds = new java.util.ArrayList<>(roleIds);
repairedRoleIds.add(ownerRole.getId());
members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList());
securityEvents.permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT",
"MEMBER_ROLES_CHANGED");
securityVersions.subjectChanged(user.getAuthUserId());
}
return ProvisioningResult.existing();
}
@@ -232,9 +231,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
}
}
private void publishInitialSecurityProjection(long authUserId, long tenantId) {
securityEvents.userStatus(authUserId, tenantId, "MERCHANT", "ACTIVE");
securityEvents.permissionChanged(authUserId, tenantId, "MERCHANT", "INITIAL_AUTHORIZATION");
private void initializeSecurityVersion(long authUserId, long tenantId) {
securityVersions.subjectChanged(authUserId);
}
private void compensate(long authUserId) {
@@ -76,7 +76,7 @@ public class TenantService {
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Resource
private SecurityEventOutboxService securityEvents;
private SecurityVersionService securityVersions;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
@@ -194,7 +194,7 @@ public class TenantService {
tenant.setCreatedBy(operatorId);
tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant);
securityEvents.tenantStatus(tenant.getId(), tenant.getStatus());
securityVersions.tenantChanged(tenant.getId());
dimensionRuleInitialization.initialize(tenant.getId(), operatorId);
overseasStorageFeeInitialization.initialize(tenant.getId(), operatorId, tenant.getTenantName());
var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId);
@@ -302,7 +302,7 @@ public class TenantService {
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
tenant.setApprovedBy(approve ? reviewerId : null);
repository.saveTenant(tenant);
securityEvents.tenantStatus(tenant.getId(), tenant.getStatus());
securityVersions.tenantChanged(tenant.getId());
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
if ("PENDING".equals(qualification.getStatus())) {
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
@@ -424,7 +424,7 @@ public class TenantService {
tenant.setStatus(request.status());
repository.saveTenant(tenant);
revokeMerchantSessions(tenantId);
securityEvents.tenantStatus(tenantId, request.status());
securityVersions.tenantChanged(tenantId);
return response(tenant);
}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
@@ -1,15 +0,0 @@
spring:
cloud:
nacos:
config:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
encode: UTF-8
discovery:
namespace: ${NACOS_NAMESPACE:test}
group: ${NACOS_GROUP:BAIYUE}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${IAM_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${IAM_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${IAM_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
+9 -11
View File
@@ -2,29 +2,28 @@ server:
port: ${IAM_PORT:9201}
spring:
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
active: ${SPRING_PROFILES_ACTIVE:test}
application:
name: iam-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
- nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
- nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
server-addr: ${NACOS_SERVER_ADDR}
username: ${NACOS_USERNAME}
password: ${NACOS_PASSWORD}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
namespace: ${NACOS_NAMESPACE}
config:
namespace: ${NACOS_NAMESPACE:public}
namespace: ${NACOS_NAMESPACE}
encode: UTF-8
baiyue:
security-event:
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
@@ -36,6 +35,5 @@ baiyue:
auth:
url: ${AUTH_SERVICE_URL:}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
internal-service-token: ${INTERNAL_SERVICE_TOKEN}
management.endpoints.web.exposure.include: health,info,prometheus
@@ -1,35 +0,0 @@
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态事件版本';
CREATE TABLE IF NOT EXISTS iam_security_event_outbox (
event_id VARCHAR(64) NOT NULL COMMENT '全局事件ID,消费者幂等键',
event_type VARCHAR(64) NOT NULL COMMENT '安全事件类型',
aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合标识',
payload_json JSON NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '发送尝试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间',
lease_owner VARCHAR(128) NULL COMMENT '租约持有者',
lease_until DATETIME(3) NULL COMMENT '租约到期时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次错误',
sent_at DATETIME(3) NULL COMMENT '发送成功时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (event_id),
KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at),
KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at)
) ENGINE=InnoDB COMMENT='IAM可靠安全事件本地发送箱';
@@ -0,0 +1,16 @@
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_security_subject_version (
auth_user_id BIGINT NOT NULL COMMENT '认证账号ID',
permission_version BIGINT NOT NULL DEFAULT 0 COMMENT '权限版本,身份或授权变化时单调递增',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (auth_user_id)
) ENGINE=InnoDB COMMENT='安全主体权限版本';
CREATE TABLE IF NOT EXISTS iam_security_tenant_version (
tenant_id BIGINT NOT NULL COMMENT '客户ID',
status_version BIGINT NOT NULL DEFAULT 0 COMMENT '客户状态单调版本',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (tenant_id)
) ENGINE=InnoDB COMMENT='客户状态持久化版本';
@@ -869,6 +869,37 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/iam/authorization-snapshot:
post:
tags: [IAM Resources / IAM 资源]
summary: Read a consistent authoritative authorization snapshot
description: Internal service credential required; no public Gateway route. Identity, permissions, warehouse bindings and monotonic subject/customer versions are read by one MVCC SELECT statement, without cross-statement snapshot mixing. Auth fingerprints this state for login, refresh and every Access validation.
security: []
parameters:
- {$ref: '#/components/parameters/InternalServiceToken'}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [userId, tenantId, membershipId, appCode]
properties:
userId: {type: integer, format: int64}
tenantId: {type: integer, format: int64}
membershipId: {type: integer, format: int64}
appCode: {type: string, enum: [PLATFORM, MERCHANT]}
responses:
'200':
description: Consistent snapshot or business error
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/AuthorizationSnapshot'}
/internal/iam/merchant-registrations/{authUserId}:
get:
tags: [IAM Resources / IAM 资源]
@@ -1747,3 +1778,18 @@ components:
properties:
roleId: {type: integer, format: int64}
permissionIds: {type: array, items: {type: integer, format: int64}}
AuthorizationSnapshot:
type: object
required: [userId, tenantId, membershipId, appCode, clientType, roles, permissions, permissionVersion, tenantVersion]
properties:
userId: {type: integer, format: int64}
tenantId: {type: integer, format: int64}
membershipId: {type: integer, format: int64}
appCode: {type: string, enum: [PLATFORM, MERCHANT]}
clientType: {type: string}
roles: {type: array, items: {type: string}}
permissions: {type: array, items: {type: string}}
permissionVersion: {type: integer, format: int64, description: Persistent subject security epoch}
tenantVersion: {type: integer, format: int64, description: Persistent customer status epoch}
overseasWarehouseId: {type: integer, format: int64, nullable: true}
domesticWarehouseId: {type: integer, format: int64, nullable: true}
@@ -1,35 +0,0 @@
package com.baiyue.iam.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurityEventBridgeClientContractTest {
private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize();
@Test
void feignUsesTheExplicitOverseasBridgePropertyInsteadOfSameNameDiscovery() {
FeignClient annotation = SecurityEventBridgeClient.class.getAnnotation(FeignClient.class);
assertThat(annotation.name()).isEqualTo("event-bridge-service");
assertThat(annotation.url()).isEqualTo("${baiyue.security-event.event-bridge-url:}");
}
@Test
void cnIamConfigurationTargetsTheOverseasBridgeNetworkAlias() throws Exception {
String application = Files.readString(REPOSITORY_ROOT.resolve("iam/src/main/resources/application.yml"));
String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/iam-service.yml"));
String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml"));
assertThat(application).contains(
"event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}");
assertThat(nacos).contains(
"event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}");
assertThat(compose).contains(
"SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312}");
}
}
@@ -12,26 +12,32 @@ class AuthorizationIdentitySqlTest {
void disabledArchivedDeletedOrMissingCustomerCannotGetNewAuthorizationSnapshot() throws Exception {
var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("CREATE TABLE iam_user(id BIGINT, auth_user_id BIGINT, status VARCHAR(30), deleted INT)");
jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT)");
jdbc.execute("CREATE TABLE iam_membership(id BIGINT, user_id BIGINT, tenant_id BIGINT, status VARCHAR(30), deleted INT, overseas_warehouse_id BIGINT, domestic_warehouse_id BIGINT)");
jdbc.execute("CREATE TABLE iam_tenant(id BIGINT, status VARCHAR(30), deleted INT)");
jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)");
jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)");
jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT,permission_version BIGINT)");
jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT,status_version BIGINT)");
jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,NULL,NULL)");
jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)");
String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("countActiveIdentity", long.class, long.class, long.class)
.getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7");
String sql = String.join("\n", AuthorizationQueryMapper.class.getMethod("findSnapshot", long.class, long.class, long.class, String.class)
.getAnnotation(Select.class).value()).replace("#{userId}", "42").replace("#{membershipId}", "9").replace("#{tenantId}", "7").replace("#{appCode}", "'MERCHANT'");
for (String status : new String[]{"ACTIVE", "PROFILE_REQUIRED", "PENDING", "REJECTED"}) {
jdbc.update("UPDATE iam_tenant SET status=?", status);
assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isEqualTo(1);
assertThat(jdbc.queryForList(sql).size()).as(status).isEqualTo(1);
}
for (String status : new String[]{"DISABLED", "ARCHIVED", "UNKNOWN"}) {
jdbc.update("UPDATE iam_tenant SET status=?", status);
assertThat(jdbc.queryForObject(sql, Integer.class)).as(status).isZero();
assertThat(jdbc.queryForList(sql).size()).as(status).isZero();
}
jdbc.update("UPDATE iam_tenant SET status='ACTIVE', deleted=1");
assertThat(jdbc.queryForObject(sql, Integer.class)).isZero();
assertThat(jdbc.queryForList(sql).size()).isZero();
jdbc.update("DELETE FROM iam_tenant");
assertThat(jdbc.queryForObject(sql, Integer.class)).isZero();
assertThat(jdbc.queryForList(sql).size()).isZero();
jdbc.update("UPDATE iam_membership SET tenant_id=0");
assertThat(jdbc.queryForObject(sql.replace("m.tenant_id = 7", "m.tenant_id = 0"), Integer.class)).isEqualTo(1);
assertThat(jdbc.queryForList(sql.replace("m.tenant_id=7", "m.tenant_id=0")).size()).isEqualTo(1);
}
}
@@ -1,42 +0,0 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurityEventSqlContractTest {
@Test
void securityOutboxHasVersionsAndReliableDeliveryColumns() throws Exception {
String sql = new ClassPathResource("db/security-event-outbox.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("iam_security_subject_version")
.contains("permission_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_tenant_version")
.contains("status_version BIGINT NOT NULL DEFAULT 0")
.contains("iam_security_event_outbox")
.contains("payload_json JSON NOT NULL")
.contains("idx_security_outbox_publish")
.contains("PENDING", "SENDING", "SENT", "DEAD");
}
@Test
void cleanInitializationIncludesSecurityEventSchema() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path migration = Files.exists(direct)
? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql").normalize();
String sql = Files.readString(migration, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE iam_security_subject_version")
.contains("CREATE TABLE iam_security_tenant_version")
.contains("CREATE TABLE iam_security_event_outbox")
.contains("idx_security_outbox_publish", "idx_security_outbox_aggregate")
.contains("SELECT 'PASS' AS initialization_status")
.doesNotContain("assert_iam_security_event_schema", "CREATE TABLE IF NOT EXISTS iam_security_event");
}
}
@@ -0,0 +1,26 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class SecurityVersionSqlContractTest {
@Test void versionsRemainDurableWithoutProjectionOutbox() throws Exception {
String sql = new ClassPathResource("db/security-version.sql").getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("iam_security_subject_version", "permission_version BIGINT NOT NULL DEFAULT 0",
"iam_security_tenant_version", "status_version BIGINT NOT NULL DEFAULT 0")
.doesNotContain("iam_security_event_outbox");
}
@Test void cleanInitializationIncludesVersionsWithoutRetiredProjectionTables() throws Exception {
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path baseline = Files.exists(direct) ? direct : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
assertThat(Files.readString(baseline, StandardCharsets.UTF_8))
.contains("CREATE TABLE iam_security_subject_version", "CREATE TABLE iam_security_tenant_version",
"SELECT 'PASS' AS initialization_status")
.doesNotContain("CREATE TABLE iam_security_event_outbox", "CREATE TABLE ll_security_event_inbox", "CREATE TABLE ll_security_projection");
}
}
@@ -24,12 +24,7 @@ class AuthorizationSnapshotServiceTest {
@Resource private AuthorizationSnapshotService service;
@Test
void loadsRolesAndPermissionsForActiveMerchantIdentity() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("ADMIN", "OPERATOR"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("order:read", "order:create"));
when(mapper.findOverseasWarehouseId(7L, 9L)).thenReturn(900L);
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("ADMIN", "OPERATOR"), List.of("order:read", "order:create")));
var result = service
.load(new SnapshotRequest(42L, 7L, 9L, "merchant"));
@@ -51,36 +46,29 @@ class AuthorizationSnapshotServiceTest {
@Test
void rejectsInactiveMembership() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0);
assertThatThrownBy(() -> service
.load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT")))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
verify(mapper).countActiveIdentity(42L, 7L, 9L);
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of());
assertThatThrownBy(() -> service.load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT")))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
verify(mapper).findSnapshot(42L, 7L, 9L, "MERCHANT");
}
@Test
void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() {
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR"));
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM"))
.thenReturn(List.of("platform:order:list"));
when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(List.of("ORDER_OPERATOR"), List.of("platform:order:list")));
var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(result).containsExactly("platform:order:list");
verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM");
verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM");
}
@Test
void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of(
"merchant:warehouse:delivery:list",
"merchant:warehouse:delivery:create"));
"merchant:warehouse:delivery:create")));
var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
@@ -92,14 +80,12 @@ class AuthorizationSnapshotServiceTest {
@Test
void exposesCustomerOverseasBusinessPermissionsFromCurrentRoleAssignments() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of(
"merchant:cross-border:procurement:list",
"merchant:cross-border:procurement:create",
"merchant:cross-border:domestic-trucking:list",
"merchant:cross-border:pallet-shipping:list",
"merchant:warehouse:inbound:list"));
"merchant:warehouse:inbound:list")));
var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
@@ -114,11 +100,9 @@ class AuthorizationSnapshotServiceTest {
@Test
void exposesWalletPermissionsFromCurrentRoleAssignments() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of("MERCHANT_OWNER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT")).thenReturn(List.of(
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of(
"merchant:finance:account:view",
"merchant:finance:recharge:create"));
"merchant:finance:recharge:create")));
var merchantPermissions = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L,
"owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT");
@@ -127,11 +111,9 @@ class AuthorizationSnapshotServiceTest {
"merchant:finance:account:view",
"merchant:finance:recharge:create");
when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1);
when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN"));
when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of(
when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of(
"platform:finance:recharge:review",
"platform:finance:customer-account:adjust"));
"platform:finance:customer-account:adjust")));
var platformPermissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L,
"platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
@@ -143,11 +125,9 @@ class AuthorizationSnapshotServiceTest {
@Test
void exposesInboundHoldIndependentlyFromReadPermissions() {
when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1);
when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN"));
when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of(
when(mapper.findSnapshot(51L, 0L, 19L, "PLATFORM")).thenReturn(rows(List.of("PLATFORM_ADMIN"), List.of(
"platform:firstleg:inbound:list",
"platform:firstleg:inbound:hold"));
"platform:firstleg:inbound:hold")));
var permissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L,
"platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
@@ -168,9 +148,7 @@ class AuthorizationSnapshotServiceTest {
@Test
void shouldHandleCase01() {
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
when(mapper.findSnapshot(42L, 0L, 9L, "PLATFORM")).thenReturn(rows(null, null));
var result = service.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
@@ -180,12 +158,10 @@ class AuthorizationSnapshotServiceTest {
@Test
void newLoginPermissionsDisappearWhileRoleDisabledAndReturnAfterReenable() {
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("ORDER_VIEWER"), List.of(), List.of("ORDER_VIEWER"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("merchant:order:list", "merchant:order:view"), List.of(),
List.of("merchant:order:list", "merchant:order:view"));
when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT"))
.thenReturn(rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view")),
rows(List.of(), List.of()),
rows(List.of("ORDER_VIEWER"), List.of("merchant:order:list", "merchant:order:view")));
SnapshotRequest request = new SnapshotRequest(42L, 7L, 9L, "MERCHANT");
assertThat(service.load(request).getPermissions())
@@ -195,4 +171,16 @@ class AuthorizationSnapshotServiceTest {
.containsExactlyInAnyOrder("merchant:order:list", "merchant:order:view");
}
private List<com.baiyue.iam.dto.AuthorizationSnapshotRow> rows(List<String> roles, List<String> permissions) {
var result = new java.util.ArrayList<com.baiyue.iam.dto.AuthorizationSnapshotRow>();
List<String> roleValues = roles == null || roles.isEmpty() ? java.util.Collections.singletonList(null) : roles;
List<String> permissionValues = permissions == null || permissions.isEmpty() ? java.util.Collections.singletonList(null) : permissions;
for (String role : roleValues) for (String permission : permissionValues) {
var row = new com.baiyue.iam.dto.AuthorizationSnapshotRow();
row.setRoleCode(role); row.setPermissionCode(permission);
row.setPermissionVersion(1L); row.setTenantVersion(1L); row.setOverseasWarehouseId(900L);
result.add(row);
}
return result;
}
}
@@ -0,0 +1,90 @@
package com.baiyue.iam.service;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.lang.reflect.Proxy;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.assertj.core.api.Assertions.*;
/** Production SQL and transaction annotation, with a commit between the atomic SQL read and response assembly. */
class AuthorizationSnapshotTransactionTest {
@Test void singleStatementSnapshotCannotMixIdentityPermissionsAndVersion() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE iam_user(id BIGINT,auth_user_id BIGINT,status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_membership(id BIGINT,user_id BIGINT,tenant_id BIGINT,status VARCHAR(20),deleted INT,overseas_warehouse_id BIGINT,domestic_warehouse_id BIGINT)");
jdbc.execute("CREATE TABLE iam_tenant(id BIGINT,status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_role(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),role_code VARCHAR(20),status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_membership_role(membership_id BIGINT,tenant_id BIGINT,role_id BIGINT)");
jdbc.execute("CREATE TABLE iam_permission(id BIGINT,tenant_id BIGINT,app_code VARCHAR(20),permission_code VARCHAR(20),status VARCHAR(20),deleted INT)");
jdbc.execute("CREATE TABLE iam_role_permission(role_id BIGINT,permission_id BIGINT)");
jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)");
jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)");
jdbc.update("INSERT INTO iam_user VALUES(1,42,'ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership VALUES(9,1,7,'ACTIVE',0,100,NULL)");
jdbc.update("INSERT INTO iam_tenant VALUES(7,'ACTIVE',0)");
jdbc.update("INSERT INTO iam_role VALUES(2,7,'MERCHANT','OPERATOR','ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,2)");
jdbc.update("INSERT INTO iam_permission VALUES(3,7,'MERCHANT','write','ACTIVE',0)");
jdbc.update("INSERT INTO iam_role_permission VALUES(2,3)");
jdbc.update("INSERT INTO iam_security_subject_version VALUES(42,1)");
jdbc.update("INSERT INTO iam_security_tenant_version VALUES(7,1)");
var factory = new SqlSessionFactoryBean(); factory.setDataSource(ds);
var configuration = new org.apache.ibatis.session.Configuration(); configuration.addMapper(AuthorizationQueryMapper.class);
configuration.addMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class);
factory.setConfiguration(configuration);
var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(AuthorizationQueryMapper.class);
var read = new CountDownLatch(1); var committed = new CountDownLatch(1); var pause = new AtomicBoolean(true);
AuthorizationQueryMapper controlled = (AuthorizationQueryMapper) Proxy.newProxyInstance(getClass().getClassLoader(),
new Class<?>[]{AuthorizationQueryMapper.class}, (proxy, method, args) -> {
Object result = method.invoke(mapper, args);
if (method.getName().equals("findSnapshot") && pause.compareAndSet(true, false)) {
read.countDown(); if (!committed.await(5, TimeUnit.SECONDS)) throw new AssertionError("Commit barrier timed out");
}
return result;
});
var target = new AuthorizationSnapshotService(); ReflectionTestUtils.setField(target, "mapper", controlled);
var tx = new DataSourceTransactionManager(ds); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(tx, new AnnotationTransactionAttributeSource()));
var service = (AuthorizationSnapshotService) proxy.getProxy();
var executor = Executors.newSingleThreadExecutor();
try {
var previous = executor.submit(() -> service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT")));
assertThat(read.await(5, TimeUnit.SECONDS)).isTrue();
new TransactionTemplate(tx).executeWithoutResult(status -> {
jdbc.update("UPDATE iam_permission SET permission_code='read' WHERE id=3");
jdbc.update("UPDATE iam_security_subject_version SET permission_version=2 WHERE auth_user_id=42");
jdbc.update("UPDATE iam_membership SET overseas_warehouse_id=200 WHERE id=9");
});
committed.countDown();
var old = previous.get(5, TimeUnit.SECONDS);
assertThat(old.getPermissions()).containsExactly("write");
assertThat(old.getPermissionVersion()).isEqualTo(1); assertThat(old.getOverseasWarehouseId()).isEqualTo(100);
var fresh = service.load(new SnapshotRequest(42L,7L,9L,"MERCHANT"));
assertThat(fresh.getPermissions()).containsExactly("read");
assertThat(fresh.getPermissionVersion()).isEqualTo(2); assertThat(fresh.getOverseasWarehouseId()).isEqualTo(200);
// Shared platform role: enumerate both customers, not just membership tenant 0.
jdbc.update("INSERT INTO iam_user VALUES(2,43,'ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership VALUES(10,2,8,'ACTIVE',0,NULL,NULL)");
jdbc.update("INSERT INTO iam_role VALUES(5,0,'MERCHANT','SHARED','ACTIVE',0)");
jdbc.update("INSERT INTO iam_membership_role VALUES(9,7,5),(10,8,5)");
var assignments = new SqlSessionTemplate(factory.getObject())
.getMapper(com.baiyue.iam.mapper.IamMembershipRoleMapper.class);
assertThat(assignments.findAssignedAuthUserIds(0, 5L)).containsExactly(42L, 43L);
assertThat(assignments.findAssignedAuthUserIds(7, 5L)).isEmpty();
} finally { committed.countDown(); executor.shutdownNow(); }
}
}
@@ -35,7 +35,7 @@ class MemberServiceTest {
@MockBean private TenantRepository tenantRepository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private MemberService service;
private IamMembership member;
private IamUser user;
@@ -106,8 +106,7 @@ class MemberServiceTest {
assertEquals("DISABLED", result.status());
verify(repository).save(member);
verify(repository).saveUser(user);
verify(securityEvents).userStatus(40L, 100L, "MERCHANT", "DISABLED");
verify(securityEvents).permissionChanged(40L, 100L, "MERCHANT", "MEMBER_STATUS_CHANGED");
verify(securityVersions).subjectChanged(40L);
}
@Test void shouldHandleCase05() {
@@ -156,8 +155,7 @@ class MemberServiceTest {
verify(auth).create(eq("internal-token"), account.capture());
long authUserId = account.getValue().accountId();
verify(repository).replaceRoles(eq(100L), anyLong(), eq(List.of(50L)));
verify(securityEvents).userStatus(authUserId, 100L, "MERCHANT", "ACTIVE");
verify(securityEvents).permissionChanged(authUserId, 100L, "MERCHANT", "INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(authUserId);
}
@Test void shouldHandleCase08() {
@@ -44,7 +44,7 @@ class MerchantOnboardingServiceTest {
@MockBean private IamTenantReviewMapper reviews;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@MockBean private CustomerCodeRepository customerCodes;
@Resource private MerchantOnboardingService service;
@@ -78,10 +78,8 @@ class MerchantOnboardingServiceTest {
verify(membershipRoles).insert(roleGrant.capture());
verify(dimensionRuleInitialization).initialize(result.tenantId(), 9001L);
verify(overseasStorageFeeInitialization).initialize(result.tenantId(), 9001L, "测试企业");
verify(securityEvents).tenantStatus(result.tenantId(), "PROFILE_REQUIRED");
verify(securityEvents).userStatus(9001L, result.tenantId(), "MERCHANT", "ACTIVE");
verify(securityEvents).permissionChanged(9001L, result.tenantId(), "MERCHANT",
"INITIAL_AUTHORIZATION");
verify(securityVersions).tenantChanged(result.tenantId());
verify(securityVersions).subjectChanged(9001L);
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals("PENDING", result.dimensionInitializationStatus());
@@ -30,7 +30,7 @@ class PlatformInternalUserServiceTest {
@MockBean private MemberRepository repository;
@MockBean private IamRoleMapper roleMapper;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private PlatformInternalUserService service;
private IamMembership membership;
private IamUser user;
@@ -86,8 +86,7 @@ class PlatformInternalUserServiceTest {
verify(repository).insert(any(IamMembership.class));
verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L)));
long authUserId = captor.getValue().accountId();
verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "ACTIVE");
verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(authUserId);
}
@Test
@@ -103,8 +102,7 @@ class PlatformInternalUserServiceTest {
service.create(request);
verify(repository).replaceRoles(eq(0L), anyLong(), eq(List.of(40L)));
verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE");
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(10L);
verify(auth, never()).create(anyString(), any());
}
@@ -116,7 +114,7 @@ class PlatformInternalUserServiceTest {
service.create(request);
verifyNoInteractions(securityEvents);
verifyNoInteractions(securityVersions);
verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
}
@@ -150,9 +148,7 @@ class PlatformInternalUserServiceTest {
new ChangeInternalUserStatusRequest("DISABLED"), 99L);
assertEquals("DISABLED", result.status());
verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "DISABLED");
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM",
"PLATFORM_INTERNAL_USER_STATUS_CHANGED");
verify(securityVersions).subjectChanged(10L);
}
@Test
@@ -33,7 +33,7 @@ class PlatformUserManagementServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private PlatformUserManagementService service;
private IamRole platformRole;
private IamMembership membership;
@@ -107,8 +107,7 @@ class PlatformUserManagementServiceTest {
verify(auth).create(eq("internal-token"), account.capture());
long authUserId = account.getValue().accountId();
verify(members).replaceRoles(eq(0L), anyLong(), eq(List.of(40001L)));
verify(securityEvents).userStatus(authUserId, 0L, "PLATFORM", "DISABLED");
verify(securityEvents).permissionChanged(authUserId, 0L, "PLATFORM", "INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(authUserId);
}
@Test
@@ -159,8 +158,7 @@ class PlatformUserManagementServiceTest {
assertEquals(900L, membership.getOverseasWarehouseId());
assertEquals(900L, response.overseasWarehouseId());
verify(auth).revokeSessions(eq("internal-token"), any());
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM",
"PLATFORM_USER_AUTHORIZATION_CHANGED");
verify(securityVersions).subjectChanged(10L);
}
@Test
@@ -197,8 +195,7 @@ class PlatformUserManagementServiceTest {
assertEquals(901L, response.overseasWarehouseId());
verify(auth).revokeSessions(eq("internal-token"),
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L))));
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM",
"PLATFORM_USER_AUTHORIZATION_CHANGED");
verify(securityVersions).subjectChanged(10L);
}
@Test
@@ -225,8 +222,7 @@ class PlatformUserManagementServiceTest {
assertNull(response.overseasWarehouseId());
verify(auth).revokeSessions(eq("internal-token"),
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(10L))));
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM",
"PLATFORM_USER_AUTHORIZATION_CHANGED");
verify(securityVersions).subjectChanged(10L);
}
@Test
@@ -246,8 +242,7 @@ class PlatformUserManagementServiceTest {
UserResponse result = service.changeStatus(30L, new ChangeStatusRequest("ACTIVE"), 99L);
assertEquals("ACTIVE", result.status());
verify(securityEvents).userStatus(10L, 0L, "PLATFORM", "ACTIVE");
verify(securityEvents).permissionChanged(10L, 0L, "PLATFORM", "PLATFORM_USER_STATUS_CHANGED");
verify(securityVersions).subjectChanged(10L);
}
@Test
@@ -40,7 +40,7 @@ class RbacServiceTest {
@MockBean private RbacRepository repository;
@MockBean private MemberRepository memberRepository;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private RbacService service;
@Test
@@ -338,6 +338,8 @@ class RbacServiceTest {
verify(authClient, times(2)).revokeSessions(eq("test-internal-token"), captor.capture());
assertEquals(Set.of(501L, 502L), captor.getAllValues().get(0).accountIds());
assertEquals(Set.of(501L, 502L), captor.getAllValues().get(1).accountIds());
verify(securityVersions, times(2)).subjectChanged(501L);
verify(securityVersions, times(2)).subjectChanged(502L);
}
@Test
@@ -368,6 +370,7 @@ class RbacServiceTest {
verify(authClient).revokeSessions(eq("test-internal-token"),
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L, 502L, 503L))));
for (long id : new long[]{501, 502, 503}) verify(securityVersions).subjectChanged(id);
}
@Test
@@ -1,93 +0,0 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.iam.client.SecurityEventBridgeClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SecurityEventOutboxRelayTest {
@Mock private JdbcTemplate jdbc;
@Mock private SecurityEventBridgeClient client;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private SecurityEventOutboxRelay relay;
@BeforeEach
void setUp() {
relay = new SecurityEventOutboxRelay();
ReflectionTestUtils.setField(relay, "jdbc", jdbc);
ReflectionTestUtils.setField(relay, "client", client);
ReflectionTestUtils.setField(relay, "objectMapper", objectMapper);
ReflectionTestUtils.setField(relay, "internalToken", "internal-token");
ReflectionTestUtils.setField(relay, "maxAttempts", 12);
}
@Test
void cnToOverseasEventIsSentOnlyAfterMatchingBridgeAcknowledgement() throws Exception {
EventEnvelope envelope = envelope(RegionCode.OVERSEAS);
when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(anyString(), eq(String.class), any(), any()))
.thenReturn(objectMapper.writeValueAsString(envelope));
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.success(
new SecurityEventBridgeClient.Acknowledgement(envelope.eventId(), "ACCEPTED", "2026-08-25T07:00:00Z")));
relay.deliver(envelope.eventId());
ArgumentCaptor<EventEnvelope> sent = ArgumentCaptor.forClass(EventEnvelope.class);
verify(client).receive(eq("internal-token"), sent.capture());
assertThat(sent.getValue().sourceRegion()).isEqualTo(RegionCode.CN);
assertThat(sent.getValue().targetRegion()).isEqualTo(RegionCode.OVERSEAS);
verify(jdbc).update(anyString(), any(), any(), eq(envelope.eventId()), anyString());
}
@Test
void nonOverseasTargetNeverReachesTheBridge() throws Exception {
EventEnvelope envelope = envelope(RegionCode.CN);
when(jdbc.update(anyString(), any(Object[].class))).thenReturn(1);
when(jdbc.queryForObject(anyString(), eq(String.class), any(), any()))
.thenReturn(objectMapper.writeValueAsString(envelope));
when(jdbc.queryForObject(anyString(), eq(Integer.class), eq(envelope.eventId()))).thenReturn(0);
relay.deliver(envelope.eventId());
verify(client, never()).receive(anyString(), any(EventEnvelope.class));
verify(jdbc).update(anyString(), eq("PENDING"), eq(1), any(), anyString(), any(),
eq(envelope.eventId()), anyString());
}
private EventEnvelope envelope(RegionCode target) {
var value = new IamSecurityEventsV1.PermissionVersionChanged(
101L, 0L, "PLATFORM", 3L, "contract", Instant.parse("2026-08-25T07:00:00Z"),
java.util.Set.of("PLATFORM_ADMIN"),
java.util.Set.of("platform:overseas-warehouse:last-mile-order:list"));
var payload = objectMapper.valueToTree(value);
return new EventEnvelope("00000000-0000-4000-8000-000000000081",
EventTypes.IAM_PERMISSION_VERSION_CHANGED, IamSecurityEventsV1.VERSION,
Instant.parse("2026-08-25T07:00:00Z"), RegionCode.CN, target, "0",
"AUTHORIZATION", "101", 3L, null, "trace-12345678", "correlation-12345678",
null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
}
}
@@ -1,103 +0,0 @@
package com.baiyue.iam.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.IamSecurityEventsV1;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SecurityEventOutboxServiceTest {
@Mock JdbcTemplate jdbc;
@Mock AuthorizationQueryMapper authorizationQueries;
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
private SecurityEventOutboxService service;
@BeforeEach
void setUp() {
service = new SecurityEventOutboxService();
ReflectionTestUtils.setField(service, "jdbc", jdbc);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "authorizationQueries", authorizationQueries);
}
@Test
void userStatusWritesAValidEnvelopeWithCorrelationId() throws Exception {
when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(1L);
service.userStatus(101L, 0L, "PLATFORM", "DISABLED");
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture());
EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class);
assertThat(envelope.requireValid().correlationId()).isNotBlank().isEqualTo(envelope.traceId());
}
@ParameterizedTest
@CsvSource({
"ACTIVE,ACTIVE",
"PROFILE_REQUIRED,DISABLED",
"PENDING,DISABLED",
"REJECTED,DISABLED",
"DISABLED,DISABLED",
"ARCHIVED,DISABLED"
})
void tenantWorkflowStatusIsNormalizedToTheSecurityContract(String businessStatus,
String expectedSecurityStatus) throws Exception {
when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(3L);
service.tenantStatus(10001L, businessStatus);
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture());
EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class);
IamSecurityEventsV1.TenantStatusChanged payload = objectMapper.treeToValue(
envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class);
assertThat(payload.status()).isEqualTo(expectedSecurityStatus);
assertThat(envelope.aggregateVersion()).isEqualTo(3L);
}
@Test
void permissionEventCarriesOnlyTheOverseasAuthorizationSnapshot() throws Exception {
when(jdbc.queryForObject(anyString(), eq(Long.class), any())).thenReturn(7L);
when(authorizationQueries.findRolesByIdentity(101L, 0L, "ADMIN"))
.thenReturn(List.of("PLATFORM_ADMIN"));
when(authorizationQueries.findPermissionsByIdentity(101L, 0L, "ADMIN"))
.thenReturn(List.of("platform:overseas-warehouse:orders:view", "platform:iam:users:view",
"merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view"));
service.permissionChanged(101L, 0L, "ADMIN", "ROLE_CHANGED");
ArgumentCaptor<Object[]> parameters = ArgumentCaptor.forClass(Object[].class);
verify(jdbc).update(eq("INSERT INTO iam_security_event_outbox(event_id,event_type,aggregate_type,aggregate_id,payload_json) "
+ "VALUES (?,?,?,?,CAST(? AS JSON))"), parameters.capture());
EventEnvelope envelope = objectMapper.readValue(String.valueOf(parameters.getValue()[4]), EventEnvelope.class);
IamSecurityEventsV1.PermissionVersionChanged payload = objectMapper.treeToValue(
envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class);
assertThat(payload.roles()).containsExactly("PLATFORM_ADMIN");
assertThat(payload.authorities()).containsExactlyInAnyOrder(
"platform:overseas-warehouse:orders:view",
"merchant:overseas-warehouse:return-inventory:list", "lastleg:orders:view");
assertThat(payload.authorities()).doesNotContain("platform:iam:users:view");
}
}
@@ -0,0 +1,46 @@
package com.baiyue.iam.service;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.ArrayList;
import java.util.UUID;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
class SecurityVersionTransactionTest {
@Test void concurrentVersionsAreDurableAndRollbackWithMutation() throws Exception {
var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000");
var jdbc = new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE iam_security_subject_version(auth_user_id BIGINT PRIMARY KEY,permission_version BIGINT)");
jdbc.execute("CREATE TABLE iam_security_tenant_version(tenant_id BIGINT PRIMARY KEY,status_version BIGINT)");
var manager = new DataSourceTransactionManager(ds);
var target = new SecurityVersionService(); ReflectionTestUtils.setField(target, "jdbc", jdbc);
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
var service = (SecurityVersionService) proxy.getProxy();
assertThatThrownBy(() -> service.subjectChanged(42)).isInstanceOf(org.springframework.transaction.IllegalTransactionStateException.class);
new TransactionTemplate(manager).executeWithoutResult(tx -> { service.subjectChanged(42); service.tenantChanged(7); });
var start = new CountDownLatch(1); var executor = Executors.newFixedThreadPool(4);
try {
var futures = new ArrayList<Future<?>>();
for (int i=0; i<8; i++) futures.add(executor.submit(() -> {
try { if (!start.await(5, TimeUnit.SECONDS)) throw new AssertionError("Start barrier timed out"); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); }
new TransactionTemplate(manager).executeWithoutResult(tx -> service.subjectChanged(42));
}));
start.countDown(); for (var future : futures) future.get(10, TimeUnit.SECONDS);
} finally { executor.shutdownNow(); }
new TransactionTemplate(manager).executeWithoutResult(tx -> {
service.subjectChanged(42); service.tenantChanged(7); tx.setRollbackOnly();
});
assertThat(jdbc.queryForObject("SELECT permission_version FROM iam_security_subject_version WHERE auth_user_id=42", Long.class)).isEqualTo(9);
assertThat(jdbc.queryForObject("SELECT status_version FROM iam_security_tenant_version WHERE tenant_id=7", Long.class)).isEqualTo(1);
}
}
@@ -44,7 +44,7 @@ class TenantAdministratorProvisioningServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient auth;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private TenantAdministratorProvisioningService service;
private IamTenant tenant;
@@ -104,9 +104,7 @@ class TenantAdministratorProvisioningServiceTest {
verify(members).insertUser(any(IamUser.class));
verify(members).insert(any(IamMembership.class));
verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L)));
verify(securityEvents).userStatus(account.getValue().accountId(), tenant.getId(), "MERCHANT", "ACTIVE");
verify(securityEvents).permissionChanged(account.getValue().accountId(), tenant.getId(), "MERCHANT",
"INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(account.getValue().accountId());
}
@Test
@@ -141,7 +139,7 @@ class TenantAdministratorProvisioningServiceTest {
assertEquals(null, result.temporaryPassword());
verify(auth, never()).create(anyString(), any());
verify(members, never()).replaceRoles(anyLong(), anyLong(), any());
verifyNoInteractions(securityEvents);
verifyNoInteractions(securityVersions);
}
@Test
@@ -160,8 +158,7 @@ class TenantAdministratorProvisioningServiceTest {
assertFalse(result.created());
verify(auth, never()).create(anyString(), any());
verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L));
verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT",
"MEMBER_ROLES_CHANGED");
verify(securityVersions).subjectChanged(user.getAuthUserId());
}
@Test
@@ -199,9 +196,7 @@ class TenantAdministratorProvisioningServiceTest {
verify(members, never()).insertUser(any());
verify(members, never()).insert(any());
verify(members, never()).replaceRoles(anyLong(), anyLong(), any());
verify(securityEvents).userStatus(user.getAuthUserId(), tenant.getId(), "MERCHANT", "ACTIVE");
verify(securityEvents).permissionChanged(user.getAuthUserId(), tenant.getId(), "MERCHANT",
"INITIAL_AUTHORIZATION");
verify(securityVersions).subjectChanged(user.getAuthUserId());
}
@Test
@@ -30,7 +30,7 @@ class TenantCustomerExportTest {
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@Resource private TenantService service;
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -57,7 +57,7 @@ class TenantServiceTest {
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@MockBean private AuthAccountClient authClient;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private SecurityVersionService securityVersions;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@SpyBean private TenantAdministratorProvisioningService provisioning;
@@ -196,13 +196,13 @@ class TenantServiceTest {
when(authClient.revokeSessions("internal-token", request)).thenReturn(null);
assertThrows(BizException.class,
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")));
verify(securityEvents, never()).tenantStatus(org.mockito.ArgumentMatchers.anyLong(), any());
verify(securityVersions, never()).tenantChanged(org.mockito.ArgumentMatchers.anyLong());
when(authClient.revokeSessions("internal-token", request))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(null));
assertEquals("DISABLED", service.changeMerchantStatus(10001L,
new ChangeTenantStatusRequest("DISABLED")).status());
verify(securityEvents).tenantStatus(10001L, "DISABLED");
verify(securityVersions).tenantChanged(10001L);
}
@Test
@@ -357,7 +357,7 @@ class TenantServiceTest {
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L);
verify(securityEvents).tenantStatus(created.tenant().id(), "PROFILE_REQUIRED");
verify(securityVersions).tenantChanged(created.tenant().id());
verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
verify(repository, never()).insertReview(any());
}
@@ -474,7 +474,7 @@ class TenantServiceTest {
assertEquals("ACTIVE", tenant.getStatus());
verify(repository).saveReview(review);
verify(repository).saveTenant(tenant);
verify(securityEvents).tenantStatus(10001L, "ACTIVE");
verify(securityVersions).tenantChanged(10001L);
}
@Test