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}