feat: 增加工作流和知识库三级权限

- 抽取统一资源访问骨架与部门可见范围判断

- 接入工作流和知识库的 READ/MANAGE 权限校验

- 增加可见范围配置与只读态前端交互
This commit is contained in:
2026-03-29 17:25:55 +08:00
parent f49d94e2fe
commit 22ceabff96
58 changed files with 3053 additions and 85 deletions

View File

@@ -0,0 +1,7 @@
package tech.easyflow.system.enums;
public enum ResourceAction {
READ,
USE,
MANAGE
}

View File

@@ -0,0 +1,12 @@
package tech.easyflow.system.enums;
public enum ResourceLookup {
WORKFLOW_ID,
EXEC_KEY,
KNOWLEDGE_ID,
KNOWLEDGE_ID_OR_SLUG,
DOCUMENT_ID,
DOCUMENT_CHUNK_ID,
FAQ_ITEM_ID,
FAQ_CATEGORY_ID
}

View File

@@ -0,0 +1,29 @@
package tech.easyflow.system.enums;
import java.util.Arrays;
import java.util.Locale;
public enum VisibilityScope {
PRIVATE,
DEPT,
PUBLIC;
public static VisibilityScope from(String code) {
if (code == null || code.isBlank()) {
throw new IllegalArgumentException("visibilityScope不能为空");
}
String normalized = code.trim().toUpperCase(Locale.ROOT);
return Arrays.stream(values())
.filter(item -> item.name().equals(normalized))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("不支持的visibilityScope: " + code));
}
public static VisibilityScope fromOrDefault(String code, VisibilityScope defaultValue) {
if (code == null || code.isBlank()) {
return defaultValue;
}
return from(code);
}
}

View File

@@ -0,0 +1,25 @@
package tech.easyflow.system.permission.resource;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.ResourceLookup;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireResourceAccess {
CategoryResourceType resource();
ResourceAction action();
ResourceLookup lookup();
String idExpr();
String denyMessage() default "无权限访问该资源";
}

View File

@@ -0,0 +1,86 @@
package tech.easyflow.system.permission.resource;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceLookup;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.lang.reflect.Method;
import java.util.List;
@Aspect
@Component
public class RequireResourceAccessAspect {
private final List<ResourceAccessResolver> resolvers;
private final ResourceAccessService resourceAccessService;
private final CategoryPermissionService categoryPermissionService;
private final ExpressionParser expressionParser = new SpelExpressionParser();
private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
public RequireResourceAccessAspect(List<ResourceAccessResolver> resolvers,
ResourceAccessService resourceAccessService,
CategoryPermissionService categoryPermissionService) {
this.resolvers = resolvers;
this.resourceAccessService = resourceAccessService;
this.categoryPermissionService = categoryPermissionService;
}
@Around("@annotation(requireResourceAccess)")
public Object doAround(ProceedingJoinPoint joinPoint, RequireResourceAccess requireResourceAccess) throws Throwable {
Object identifier = resolveIdentifier(joinPoint, requireResourceAccess);
ResourceAccessResolver resolver = findResolver(requireResourceAccess.resource(), requireResourceAccess.lookup());
ResolvedResourceAccess resolved = resolver.resolve(identifier);
assertExecutionOwner(resolved);
resourceAccessService.assertAccess(
requireResourceAccess.resource(),
resolved.getResource(),
requireResourceAccess.action(),
requireResourceAccess.denyMessage()
);
return joinPoint.proceed();
}
private Object resolveIdentifier(ProceedingJoinPoint joinPoint, RequireResourceAccess requireResourceAccess) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
MethodBasedEvaluationContext context = new MethodBasedEvaluationContext(
joinPoint.getTarget(),
method,
joinPoint.getArgs(),
parameterNameDiscoverer
);
return expressionParser.parseExpression(requireResourceAccess.idExpr()).getValue(context);
}
private ResourceAccessResolver findResolver(CategoryResourceType resourceType, ResourceLookup lookup) {
return resolvers.stream()
.filter(item -> item.supports(resourceType, lookup))
.findFirst()
.orElseThrow(() -> new IllegalStateException("未找到资源访问解析器: " + resourceType + "/" + lookup));
}
private void assertExecutionOwner(ResolvedResourceAccess resolved) {
String executionOwnerKey = resolved.getExecutionOwnerKey();
if (executionOwnerKey == null || executionOwnerKey.isBlank() || categoryPermissionService.isCurrentSuperAdmin()) {
return;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
String accountId = loginAccount == null || loginAccount.getId() == null ? null : loginAccount.getId().toString();
if (!executionOwnerKey.equals(accountId)) {
throw new BusinessException("无权限访问该执行记录");
}
}
}

View File

@@ -0,0 +1,20 @@
package tech.easyflow.system.permission.resource;
public class ResolvedResourceAccess {
private final VisibilityResource resource;
private final String executionOwnerKey;
public ResolvedResourceAccess(VisibilityResource resource, String executionOwnerKey) {
this.resource = resource;
this.executionOwnerKey = executionOwnerKey;
}
public VisibilityResource getResource() {
return resource;
}
public String getExecutionOwnerKey() {
return executionOwnerKey;
}
}

View File

@@ -0,0 +1,11 @@
package tech.easyflow.system.permission.resource;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceLookup;
public interface ResourceAccessResolver {
boolean supports(CategoryResourceType resourceType, ResourceLookup lookup);
ResolvedResourceAccess resolve(Object identifier);
}

View File

@@ -0,0 +1,14 @@
package tech.easyflow.system.permission.resource;
import java.math.BigInteger;
public interface VisibilityResource {
BigInteger getCreatedBy();
BigInteger getDeptId();
BigInteger getCategoryId();
String getVisibilityScope();
}

View File

@@ -0,0 +1,12 @@
package tech.easyflow.system.service;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.permission.resource.VisibilityResource;
public interface ResourceAccessService {
boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action);
void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message);
}

View File

@@ -3,6 +3,9 @@ package tech.easyflow.system.service;
import tech.easyflow.system.entity.SysDept;
import com.mybatisflex.core.service.IService;
import java.math.BigInteger;
import java.util.Set;
/**
* 部门表 服务层。
*
@@ -11,4 +14,7 @@ import com.mybatisflex.core.service.IService;
*/
public interface SysDeptService extends IService<SysDept> {
Set<BigInteger> getSelfAndAncestorDeptIds(BigInteger currentDeptId);
boolean canUserAccessDeptScopedResource(BigInteger currentDeptId, BigInteger resourceDeptId);
}

View File

@@ -0,0 +1,65 @@
package tech.easyflow.system.service.impl;
import org.springframework.stereotype.Service;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.enums.CategoryResourceType;
import tech.easyflow.system.enums.ResourceAction;
import tech.easyflow.system.enums.VisibilityScope;
import tech.easyflow.system.permission.resource.VisibilityResource;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import tech.easyflow.system.service.SysDeptService;
import javax.annotation.Resource;
import java.math.BigInteger;
@Service
public class ResourceAccessServiceImpl implements ResourceAccessService {
@Resource
private CategoryPermissionService categoryPermissionService;
@Resource
private SysDeptService sysDeptService;
@Override
public boolean canAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action) {
if (resource == null) {
return false;
}
LoginAccount loginAccount = SaTokenUtil.getLoginAccount();
if (loginAccount == null || loginAccount.getId() == null) {
return false;
}
BigInteger accountId = loginAccount.getId();
if (categoryPermissionService.isCurrentSuperAdmin()) {
return true;
}
if (accountId.equals(resource.getCreatedBy())) {
return true;
}
if (ResourceAction.MANAGE == action) {
return false;
}
if (!categoryPermissionService.canAccessCategory(resourceType.getCode(), resource.getCreatedBy(), resource.getCategoryId())) {
return false;
}
VisibilityScope scope = VisibilityScope.fromOrDefault(resource.getVisibilityScope(), VisibilityScope.PRIVATE);
if (VisibilityScope.PUBLIC == scope) {
return true;
}
if (VisibilityScope.DEPT == scope) {
return sysDeptService.canUserAccessDeptScopedResource(loginAccount.getDeptId(), resource.getDeptId());
}
return false;
}
@Override
public void assertAccess(CategoryResourceType resourceType, VisibilityResource resource, ResourceAction action, String message) {
if (!canAccess(resourceType, resource, action)) {
throw new BusinessException(message == null ? "无权限访问该资源" : message);
}
}
}

View File

@@ -6,6 +6,11 @@ import tech.easyflow.system.service.SysDeptService;
import com.mybatisflex.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import java.math.BigInteger;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 部门表 服务层实现。
*
@@ -15,4 +20,39 @@ import org.springframework.stereotype.Service;
@Service
public class SysDeptServiceImpl extends ServiceImpl<SysDeptMapper, SysDept> implements SysDeptService {
@Override
public Set<BigInteger> getSelfAndAncestorDeptIds(BigInteger currentDeptId) {
if (currentDeptId == null) {
return Collections.emptySet();
}
SysDept currentDept = getById(currentDeptId);
if (currentDept == null) {
return Collections.emptySet();
}
Set<BigInteger> deptIds = new LinkedHashSet<>();
String ancestors = currentDept.getAncestors();
if (ancestors != null && !ancestors.isBlank()) {
String[] items = ancestors.split(",");
for (String item : items) {
if (item == null || item.isBlank()) {
continue;
}
BigInteger deptId = new BigInteger(item.trim());
if (BigInteger.ZERO.equals(deptId)) {
continue;
}
deptIds.add(deptId);
}
}
deptIds.add(currentDeptId);
return deptIds;
}
@Override
public boolean canUserAccessDeptScopedResource(BigInteger currentDeptId, BigInteger resourceDeptId) {
if (currentDeptId == null || resourceDeptId == null) {
return false;
}
return getSelfAndAncestorDeptIds(currentDeptId).contains(resourceDeptId);
}
}