349 lines
18 KiB
Java
349 lines
18 KiB
Java
package com.baiyue.iam.service;
|
|
|
|
import com.baiyue.common.audit.BusinessAudit;
|
|
import com.baiyue.common.core.api.ApiResponse;
|
|
import com.baiyue.common.core.api.ResultCode;
|
|
import com.baiyue.common.core.exception.BizException;
|
|
import com.baiyue.iam.client.AuthAccountClient;
|
|
import com.baiyue.iam.dto.RbacDtos.*;
|
|
import com.baiyue.iam.entity.IamMembership;
|
|
import com.baiyue.iam.entity.IamPermission;
|
|
import com.baiyue.iam.entity.IamRole;
|
|
import com.baiyue.iam.repository.MemberRepository;
|
|
import com.baiyue.iam.repository.RbacRepository;
|
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
|
import jakarta.annotation.Resource;
|
|
import feign.FeignException;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.dao.DataIntegrityViolationException;
|
|
|
|
import java.util.LinkedHashSet;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.Comparator;
|
|
|
|
/** 双端 RBAC 应用服务,严格按租户和应用域隔离。 */
|
|
@Service
|
|
public class RbacService {
|
|
private static final int SESSION_REVOCATION_BATCH_SIZE = 500;
|
|
@Resource private RbacRepository repository;
|
|
@Resource private MemberRepository memberRepository;
|
|
@Resource private AuthAccountClient authClient;
|
|
@Resource private SecurityEventOutboxService securityEvents;
|
|
@Value("${baiyue.security.internal-service-token}")
|
|
private String internalToken;
|
|
|
|
public List<RoleResponse> roles(long tenantId, String appCode) {
|
|
List<IamRole> roles = repository.findRoles(tenantId, appCode);
|
|
Map<Long, Long> memberCounts = repository.findRoleMemberCounts(
|
|
tenantId, roles.stream().map(IamRole::getId).toList());
|
|
return roles.stream()
|
|
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()),
|
|
memberCounts.getOrDefault(role.getId(), 0L)))
|
|
.toList();
|
|
}
|
|
|
|
public RoleResponse role(long tenantId, String appCode, Long roleId) {
|
|
IamRole role = requireRole(tenantId, appCode, roleId);
|
|
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
|
repository.countRoleMembers(tenantId, roleId));
|
|
}
|
|
|
|
@BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode",
|
|
description = "创建角色", captureBefore = false, captureAfter = true)
|
|
public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) {
|
|
if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) {
|
|
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
|
}
|
|
IamRole role = new IamRole();
|
|
role.setId(IdWorker.getId());
|
|
role.setTenantId(tenantId);
|
|
role.setAppCode(appCode);
|
|
role.setRoleCode(request.roleCode());
|
|
role.setRoleName(request.roleName());
|
|
role.setDescription(request.description());
|
|
role.setRoleType("CUSTOM");
|
|
role.setStatus("ACTIVE");
|
|
try {
|
|
repository.insertRole(role);
|
|
} catch (DataIntegrityViolationException exception) {
|
|
// 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。
|
|
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
|
}
|
|
return response(role, Set.of(), 0L);
|
|
}
|
|
|
|
@BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId",
|
|
description = "编辑角色", captureBefore = true, captureAfter = true)
|
|
public RoleResponse updateRole(long tenantId, String appCode, Long roleId, UpdateRoleRequest request) {
|
|
IamRole role = requireCustomRole(tenantId, appCode, roleId);
|
|
role.setRoleName(request.roleName());
|
|
role.setDescription(request.description());
|
|
repository.updateRole(role);
|
|
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
|
repository.countRoleMembers(tenantId, roleId));
|
|
}
|
|
|
|
@BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId",
|
|
description = "启停角色", captureBefore = true, captureAfter = true)
|
|
@Transactional
|
|
public RoleResponse changeStatus(long tenantId, String appCode, Long roleId,
|
|
ChangeRoleStatusRequest request) {
|
|
IamRole role = requireCustomRole(tenantId, appCode, roleId);
|
|
role.setStatus(request.status());
|
|
repository.updateRole(role);
|
|
revokeAssignedSessions(tenantId, roleId);
|
|
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
|
repository.countRoleMembers(tenantId, roleId));
|
|
}
|
|
|
|
/** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */
|
|
@BusinessAudit(event = "ROLE_DELETE", resourceType = "ROLE", resourceId = "#roleId",
|
|
description = "删除角色", captureBefore = true, captureAfter = false)
|
|
@Transactional
|
|
public void deleteRole(long tenantId, String appCode, Long roleId) {
|
|
IamRole role = requireCustomRole(tenantId, appCode, roleId);
|
|
if (repository.isRoleAssigned(tenantId, role.getId())) {
|
|
throw new BizException(ResultCode.CONFLICT, "角色已分配给成员,不能删除");
|
|
}
|
|
repository.deleteRole(tenantId, appCode, roleId);
|
|
}
|
|
|
|
public List<IamPermission> permissions(long tenantId, String appCode) {
|
|
Map<String, IamPermission> effective = new LinkedHashMap<>();
|
|
repository.findPermissions(tenantId, appCode).stream()
|
|
.sorted(Comparator
|
|
.comparingInt((IamPermission permission) ->
|
|
permission.getTenantId() != null && permission.getTenantId() == tenantId ? 0 : 1)
|
|
.thenComparing(IamPermission::getPermissionCode))
|
|
.forEach(permission -> effective.putIfAbsent(permission.getPermissionCode(), permission));
|
|
return List.copyOf(effective.values());
|
|
}
|
|
|
|
@BusinessAudit(event = "PERMISSION_CREATE", resourceType = "PERMISSION", resourceId = "#request.code",
|
|
description = "创建权限资源", captureBefore = false, captureAfter = true)
|
|
public IamPermission createPermission(long tenantId, String appCode, CreatePermissionRequest request) {
|
|
if (!Set.of("MENU", "PAGE", "BUTTON", "API").contains(request.type())) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "权限类型只能是 MENU、PAGE、BUTTON 或 API");
|
|
}
|
|
if (repository.existsPermissionCode(tenantId, appCode, request.code())) {
|
|
throw new BizException(ResultCode.CONFLICT, "权限编码已存在");
|
|
}
|
|
if (request.parentId() != null && request.parentId() > 0
|
|
&& repository.findPermission(request.parentId(), tenantId, appCode) == null) {
|
|
throw forbidden();
|
|
}
|
|
IamPermission permission = new IamPermission();
|
|
permission.setId(IdWorker.getId());
|
|
permission.setTenantId(tenantId);
|
|
permission.setAppCode(appCode);
|
|
permission.setPermissionType(request.type());
|
|
permission.setPermissionCode(request.code());
|
|
permission.setPermissionName(request.name());
|
|
permission.setParentId(request.parentId() == null ? 0L : request.parentId());
|
|
permission.setRoutePath(request.routePath());
|
|
permission.setHttpMethod(request.httpMethod());
|
|
permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
|
|
permission.setStatus("ACTIVE");
|
|
permission.setBuiltIn(false);
|
|
try {
|
|
repository.insertPermission(permission);
|
|
} catch (DataIntegrityViolationException exception) {
|
|
throw new BizException(ResultCode.CONFLICT, "权限编码已存在");
|
|
}
|
|
return permission;
|
|
}
|
|
|
|
@BusinessAudit(event = "PERMISSION_UPDATE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
|
description = "编辑权限资源", captureBefore = true, captureAfter = true)
|
|
public IamPermission updatePermission(long tenantId, String appCode, Long permissionId,
|
|
UpdatePermissionRequest request) {
|
|
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
|
validatePermissionParent(tenantId, appCode, permissionId, request.parentId());
|
|
permission.setPermissionName(request.name());
|
|
permission.setParentId(request.parentId() == null ? 0L : request.parentId());
|
|
permission.setRoutePath(request.routePath());
|
|
permission.setHttpMethod(request.httpMethod());
|
|
permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
|
|
repository.updatePermission(permission);
|
|
return permission;
|
|
}
|
|
|
|
@BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
|
description = "启停权限资源", captureBefore = true, captureAfter = true)
|
|
@Transactional
|
|
public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId,
|
|
ChangePermissionStatusRequest request) {
|
|
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
|
permission.setStatus(request.status());
|
|
repository.updatePermission(permission);
|
|
Set<Long> affectedAccounts = new LinkedHashSet<>();
|
|
for (Long roleId : repository.findRoleIdsByPermission(tenantId, permissionId)) {
|
|
affectedAccounts.addAll(repository.findAssignedAuthUserIds(tenantId, roleId));
|
|
}
|
|
revokeSessions(List.copyOf(affectedAccounts));
|
|
return permission;
|
|
}
|
|
|
|
/** 带有子资源或已授权给角色的资源不得直接删除。 */
|
|
@BusinessAudit(event = "PERMISSION_DELETE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
|
description = "删除权限资源", captureBefore = true, captureAfter = false)
|
|
@Transactional
|
|
public void deletePermission(long tenantId, String appCode, Long permissionId) {
|
|
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
|
if (repository.hasPermissionChildren(tenantId, appCode, permissionId)) {
|
|
throw new BizException(ResultCode.CONFLICT, "Permission has child resources / 权限资源存在子节点,不能删除");
|
|
}
|
|
if (repository.isPermissionGranted(tenantId, permissionId)) {
|
|
throw new BizException(ResultCode.CONFLICT, "Permission is granted to roles / 权限资源已被角色授予,不能删除");
|
|
}
|
|
repository.deletePermission(permission);
|
|
}
|
|
|
|
public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) {
|
|
requireMembership(tenantId, membershipId);
|
|
Set<Long> scopedIds = new LinkedHashSet<>();
|
|
for (Long roleId : repository.findMemberRoleIds(tenantId, membershipId)) {
|
|
if (repository.findRole(roleId, tenantId, appCode) != null) scopedIds.add(roleId);
|
|
}
|
|
return new MemberRolesResponse(membershipId, Set.copyOf(scopedIds));
|
|
}
|
|
|
|
@BusinessAudit(event = "MEMBER_ROLE_ASSIGN", resourceType = "MEMBERSHIP",
|
|
resourceId = "#request.membershipId", description = "分配成员角色",
|
|
captureBefore = true, captureAfter = true)
|
|
@Transactional
|
|
public void assignRoles(long tenantId, String appCode, AssignRolesRequest request) {
|
|
IamMembership membership = requireMembership(tenantId, request.membershipId());
|
|
if ("OWNER".equals(membership.getMemberType())) {
|
|
throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不允许通过普通授权接口修改");
|
|
}
|
|
for (Long roleId : request.roleIds()) {
|
|
IamRole role = requireRole(tenantId, appCode, roleId);
|
|
if ("MERCHANT_OWNER".equals(role.getRoleCode())) {
|
|
throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不能通过普通授权接口分配");
|
|
}
|
|
if (!"ACTIVE".equals(role.getStatus())) {
|
|
throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员");
|
|
}
|
|
}
|
|
Set<Long> currentRoleIds = new LinkedHashSet<>();
|
|
for (Long roleId : repository.findMemberRoleIds(tenantId, request.membershipId())) {
|
|
if (repository.findRole(roleId, tenantId, appCode) != null) currentRoleIds.add(roleId);
|
|
}
|
|
repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds());
|
|
if (!currentRoleIds.equals(request.roleIds())) {
|
|
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");
|
|
}
|
|
}
|
|
|
|
@BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE",
|
|
resourceId = "#request.roleId", description = "设置角色权限",
|
|
captureBefore = true, captureAfter = true)
|
|
@Transactional
|
|
public void grantPermissions(long tenantId, String appCode, GrantPermissionsRequest request) {
|
|
requireCustomRole(tenantId, appCode, request.roleId());
|
|
for (Long permissionId : request.permissionIds()) {
|
|
IamPermission permission = repository.findPermission(permissionId, tenantId, appCode);
|
|
if (permission == null) throw forbidden();
|
|
if (!"ACTIVE".equals(permission.getStatus())) {
|
|
throw new BizException(ResultCode.CONFLICT, "停用权限不能授予角色");
|
|
}
|
|
}
|
|
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"));
|
|
}
|
|
|
|
private void revokeAssignedSessions(long tenantId, Long roleId) {
|
|
revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId));
|
|
}
|
|
|
|
private void revokeSessions(List<Long> accountIds) {
|
|
for (int start = 0; start < accountIds.size(); start += SESSION_REVOCATION_BATCH_SIZE) {
|
|
Set<Long> batch = Set.copyOf(accountIds.subList(start,
|
|
Math.min(start + SESSION_REVOCATION_BATCH_SIZE, accountIds.size())));
|
|
try {
|
|
ApiResponse<Void> response = authClient.revokeSessions(internalToken,
|
|
new AuthAccountClient.RevokeAccountSessionsRequest(batch));
|
|
if (response == null || !response.successful()) throw sessionRevocationUnavailable();
|
|
} catch (FeignException exception) {
|
|
throw sessionRevocationUnavailable();
|
|
}
|
|
}
|
|
}
|
|
|
|
private IamRole requireRole(long tenantId, String appCode, Long roleId) {
|
|
IamRole role = repository.findRole(roleId, tenantId, appCode);
|
|
if (role == null) throw forbidden();
|
|
return role;
|
|
}
|
|
|
|
private IamPermission requireMutablePermission(long tenantId, String appCode, Long permissionId) {
|
|
IamPermission permission = repository.findOwnedPermission(permissionId, tenantId, appCode);
|
|
if (permission == null) throw forbidden();
|
|
if (Boolean.TRUE.equals(permission.getBuiltIn())) {
|
|
throw new BizException(ResultCode.CONFLICT,
|
|
"Built-in permission cannot be changed / 系统预置权限不允许编辑、启停或删除");
|
|
}
|
|
return permission;
|
|
}
|
|
|
|
private void validatePermissionParent(long tenantId, String appCode, Long permissionId, Long parentId) {
|
|
if (parentId == null || parentId == 0) return;
|
|
if (parentId.equals(permissionId)) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "Permission cannot be its own parent / 权限不能设置自身为父节点");
|
|
}
|
|
IamPermission parent = repository.findPermission(parentId, tenantId, appCode);
|
|
if (parent == null) throw forbidden();
|
|
Long cursor = parent.getParentId();
|
|
while (cursor != null && cursor > 0) {
|
|
if (cursor.equals(permissionId)) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "Permission parent cycle is not allowed / 权限节点不能形成环");
|
|
}
|
|
IamPermission ancestor = repository.findPermission(cursor, tenantId, appCode);
|
|
if (ancestor == null || cursor.equals(ancestor.getParentId())) break;
|
|
cursor = ancestor.getParentId();
|
|
}
|
|
}
|
|
|
|
private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) {
|
|
IamRole role = requireRole(tenantId, appCode, roleId);
|
|
if ("SYSTEM".equals(role.getRoleType())) {
|
|
throw new BizException(ResultCode.CONFLICT, "系统角色不允许修改、停用或删除");
|
|
}
|
|
return role;
|
|
}
|
|
|
|
private IamMembership requireMembership(long tenantId, Long membershipId) {
|
|
IamMembership membership = memberRepository.findById(membershipId);
|
|
if (membership == null || membership.getTenantId() == null
|
|
|| membership.getTenantId().longValue() != tenantId) throw forbidden();
|
|
return membership;
|
|
}
|
|
|
|
private RoleResponse response(IamRole role, Set<Long> permissionIds, long memberCount) {
|
|
return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(),
|
|
role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(),
|
|
memberCount, Set.copyOf(permissionIds));
|
|
}
|
|
|
|
private BizException forbidden() {
|
|
return new BizException(ResultCode.FORBIDDEN, "禁止跨租户或跨应用访问");
|
|
}
|
|
|
|
private BizException sessionRevocationUnavailable() {
|
|
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
|
"认证会话撤销失败,角色变更未生效");
|
|
}
|
|
}
|