feat(workspace): return memberRole on list and add access endpoint

This commit is contained in:
matevip 2026-05-15 10:17:49 +08:00
parent 65dd4c1f49
commit f4964bc6d8
6 changed files with 295 additions and 5 deletions

View File

@ -9,8 +9,10 @@ import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.model.WorkspaceAccessVO;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
import vip.mate.workspace.core.model.WorkspaceWithRoleVO;
import vip.mate.workspace.core.service.WorkspaceService;
import java.util.List;
@ -32,11 +34,12 @@ public class WorkspaceController {
// ==================== 工作区 CRUD ====================
@Operation(summary = "获取当前用户的工作区列表")
@Operation(summary = "获取当前用户的工作区列表(含 memberRole 与 effectiveRole")
@GetMapping
public R<List<WorkspaceEntity>> list(Authentication auth) {
Long userId = resolveUserId(auth);
return R.ok(workspaceService.listByUserId(userId));
public R<List<WorkspaceWithRoleVO>> list(Authentication auth) {
UserEntity user = resolveUser(auth);
boolean isGlobalAdmin = isGlobalAdmin(user);
return R.ok(workspaceService.listWithRoleByUserId(user.getId(), isGlobalAdmin));
}
@Operation(summary = "获取工作区详情")
@ -45,6 +48,14 @@ public class WorkspaceController {
return R.ok(workspaceService.getById(id));
}
@Operation(summary = "获取当前用户在指定工作区的访问能力(路由守卫消费)")
@GetMapping("/{id}/access")
public R<WorkspaceAccessVO> getAccess(@PathVariable Long id, Authentication auth) {
UserEntity user = resolveUser(auth);
boolean isGlobalAdmin = isGlobalAdmin(user);
return R.ok(workspaceService.getAccess(id, user.getId(), isGlobalAdmin));
}
@Operation(summary = "创建工作区")
@PostMapping
public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) {
@ -150,11 +161,19 @@ public class WorkspaceController {
// ==================== 工具方法 ====================
private Long resolveUserId(Authentication auth) {
return resolveUser(auth).getId();
}
private UserEntity resolveUser(Authentication auth) {
String username = auth.getName();
UserEntity user = authService.findByUsername(username);
if (user == null) {
throw new MateClawException("用户不存在: " + username);
}
return user.getId();
return user;
}
private boolean isGlobalAdmin(UserEntity user) {
return user != null && "admin".equalsIgnoreCase(user.getRole());
}
}

View File

@ -0,0 +1,23 @@
package vip.mate.workspace.core.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.Set;
/**
* Response for {@code GET /api/v1/workspaces/&#123;id&#125;/access}.
* <p>
* The frontend calls this after switching workspace or when a 403 suggests
* its cached capability set is stale.
*/
@Data
@AllArgsConstructor
public class WorkspaceAccessVO {
private Long workspaceId;
private String memberRole;
private Boolean isGlobalAdmin;
private String effectiveRole;
private Set<String> capabilities;
}

View File

@ -0,0 +1,63 @@
package vip.mate.workspace.core.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Workspace list item enriched with the current user's membership info.
* <p>
* Returned by {@code GET /api/v1/workspaces} so the frontend can render the
* workspace switcher and derive route visibility without an extra round-trip.
* <ul>
* <li>{@code memberRole}: real membership role; {@code null} for non-members</li>
* <li>{@code roleLevel}: numeric (owner=4..viewer=1, 0 for non-member)</li>
* <li>{@code isGlobalAdmin}: {@code mate_user.role='admin'} bypasses workspace gates</li>
* <li>{@code effectiveRole}: {@code owner} for global admin, otherwise = {@code memberRole}</li>
* </ul>
* The display name in the UI uses {@code memberRole} so a global admin is shown as
* "global admin (non-member)" rather than impersonated as workspace owner. Access
* decisions use {@code effectiveRole} or the {@code /access} capability set.
*/
@Data
public class WorkspaceWithRoleVO {
private Long id;
private String name;
private String slug;
private String description;
private String basePath;
private Long ownerId;
private String settingsJson;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private String memberRole;
private Integer roleLevel;
private Boolean isGlobalAdmin;
private String effectiveRole;
public static WorkspaceWithRoleVO from(WorkspaceEntity entity,
String memberRole,
boolean isGlobalAdmin) {
WorkspaceWithRoleVO vo = new WorkspaceWithRoleVO();
vo.setId(entity.getId());
vo.setName(entity.getName());
vo.setSlug(entity.getSlug());
vo.setDescription(entity.getDescription());
vo.setBasePath(entity.getBasePath());
vo.setOwnerId(entity.getOwnerId());
vo.setSettingsJson(entity.getSettingsJson());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
vo.setMemberRole(memberRole);
vo.setIsGlobalAdmin(isGlobalAdmin);
String effective = isGlobalAdmin ? "owner" : memberRole;
vo.setEffectiveRole(effective);
vo.setRoleLevel(memberRole == null && !isGlobalAdmin
? 0
: vip.mate.workspace.core.security.RoleCapabilities.roleLevel(effective));
return vo;
}
}

View File

@ -0,0 +1,50 @@
package vip.mate.workspace.core.security;
/**
* Module-level capability constants for workspace RBAC.
* <p>
* Capability is the abstraction that sits between roles (viewer/member/admin/owner)
* and concrete REST methods. Frontend routes declare {@code meta.requiredCapability}
* and the backend {@code /api/v1/workspaces/&#123;id&#125;/access} endpoint returns
* the resolved capability set for the current user.
* <p>
* Granularity is intentionally coarse: one capability per UI module, not one per
* HTTP action.
*/
public final class Capability {
private Capability() {}
/** Conversation runtime: send/stream/execute + own conversation read/write. */
public static final String CHAT = "chat";
/** Wiki knowledge base / page / relation read-only. */
public static final String VIEW_WIKI = "view:wiki";
/** Memory / Fact / Dream operator surfaces (separate from chat runtime). */
public static final String VIEW_MEMORY = "view:memory";
/** Dashboard, token usage, cron run history. */
public static final String VIEW_DASHBOARD = "view:dashboard";
/** Wiki write: KB CRUD, transformations, research, hot cache. */
public static final String MANAGE_WIKI = "manage:wiki";
/** Agent CRUD, bindings (skill/tool/provider), cron jobs, templates. */
public static final String MANAGE_AGENTS = "manage:agents";
/** Skill catalog management, install, templates, secrets. */
public static final String MANAGE_SKILLS = "manage:skills";
/** Channel CRUD, health, preflight. */
public static final String MANAGE_CHANNELS = "manage:channels";
/** LLM provider, model config, OAuth credentials, datasource. */
public static final String MANAGE_MODELS = "manage:models";
/** Tool guard, file guard, audit, activity feed. */
public static final String MANAGE_SECURITY = "manage:security";
/** System settings, feature flags, MCP, plugins, ACP, workflow, trigger, members. */
public static final String MANAGE_SETTINGS = "manage:settings";
}

View File

@ -0,0 +1,81 @@
package vip.mate.workspace.core.security;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
/**
* Authoritative role &rarr; capability mapping.
* <p>
* The frontend treats the {@code /access} response as the single source of truth
* and does NOT keep a local copy of this table. Updating role permissions only
* requires editing this file.
* <p>
* Role hierarchy is additive: each level inherits everything from the level below.
*/
public final class RoleCapabilities {
public static final String ROLE_VIEWER = "viewer";
public static final String ROLE_MEMBER = "member";
public static final String ROLE_ADMIN = "admin";
public static final String ROLE_OWNER = "owner";
private static final Map<String, Set<String>> ROLE_TO_CAPS;
static {
Set<String> viewer = new LinkedHashSet<>();
viewer.add(Capability.CHAT);
viewer.add(Capability.VIEW_WIKI);
Set<String> member = new LinkedHashSet<>(viewer);
member.add(Capability.VIEW_MEMORY);
member.add(Capability.VIEW_DASHBOARD);
member.add(Capability.MANAGE_WIKI);
member.add(Capability.MANAGE_AGENTS);
Set<String> admin = new LinkedHashSet<>(member);
admin.add(Capability.MANAGE_SKILLS);
admin.add(Capability.MANAGE_CHANNELS);
admin.add(Capability.MANAGE_MODELS);
admin.add(Capability.MANAGE_SECURITY);
admin.add(Capability.MANAGE_SETTINGS);
// Owner currently has the same capability set as admin. Owner-only actions
// (workspace deletion, transferring owner role) are enforced at the
// service layer via role-level comparison, not capabilities.
Set<String> owner = new LinkedHashSet<>(admin);
ROLE_TO_CAPS = Map.of(
ROLE_VIEWER, Collections.unmodifiableSet(viewer),
ROLE_MEMBER, Collections.unmodifiableSet(member),
ROLE_ADMIN, Collections.unmodifiableSet(admin),
ROLE_OWNER, Collections.unmodifiableSet(owner)
);
}
private RoleCapabilities() {}
/**
* Returns the capability set for a given role. Unknown roles get an empty set
* (default deny).
*/
public static Set<String> forRole(String role) {
if (role == null) {
return Set.of();
}
Set<String> caps = ROLE_TO_CAPS.get(role.toLowerCase());
return caps != null ? caps : Set.of();
}
public static int roleLevel(String role) {
if (role == null) return 0;
return switch (role.toLowerCase()) {
case ROLE_OWNER -> 4;
case ROLE_ADMIN -> 3;
case ROLE_MEMBER -> 2;
case ROLE_VIEWER -> 1;
default -> 0;
};
}
}

View File

@ -11,13 +11,20 @@ import vip.mate.exception.MateClawException;
import vip.mate.i18n.I18nService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import vip.mate.workspace.core.model.WorkspaceAccessVO;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
import vip.mate.workspace.core.model.WorkspaceWithRoleVO;
import vip.mate.workspace.core.repository.WorkspaceMapper;
import vip.mate.workspace.core.repository.WorkspaceMemberMapper;
import vip.mate.workspace.core.security.RoleCapabilities;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 工作区业务服务
@ -66,6 +73,53 @@ public class WorkspaceService {
return workspaceMapper.selectBatchIds(wsIds);
}
/**
* List workspaces visible to a user, each annotated with the user's membership
* role. Global admins see every workspace (memberRole reflects their real
* membership, or null when they are not actually a member).
*/
public List<WorkspaceWithRoleVO> listWithRoleByUserId(Long userId, boolean isGlobalAdmin) {
List<WorkspaceMemberEntity> memberships = memberMapper.selectList(
new LambdaQueryWrapper<WorkspaceMemberEntity>()
.eq(WorkspaceMemberEntity::getUserId, userId));
Map<Long, String> roleByWorkspaceId = new HashMap<>();
for (WorkspaceMemberEntity m : memberships) {
roleByWorkspaceId.put(m.getWorkspaceId(), m.getRole());
}
List<WorkspaceEntity> entities;
if (isGlobalAdmin) {
entities = listAll();
} else if (memberships.isEmpty()) {
WorkspaceEntity defaultWs = getBySlug(DEFAULT_SLUG);
entities = defaultWs != null ? List.of(defaultWs) : List.of();
} else {
entities = workspaceMapper.selectBatchIds(roleByWorkspaceId.keySet());
}
List<WorkspaceWithRoleVO> result = new ArrayList<>(entities.size());
for (WorkspaceEntity ws : entities) {
String role = roleByWorkspaceId.get(ws.getId());
result.add(WorkspaceWithRoleVO.from(ws, role, isGlobalAdmin));
}
return result;
}
/**
* Resolve the user's access summary for a workspace. Used by
* {@code GET /api/v1/workspaces/&#123;id&#125;/access} so the frontend can
* refresh its capability set after a role change without reloading the page.
*/
public WorkspaceAccessVO getAccess(Long workspaceId, Long userId, boolean isGlobalAdmin) {
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
String memberRole = member != null ? member.getRole() : null;
String effective = isGlobalAdmin ? "owner" : memberRole;
Set<String> capabilities = effective != null
? RoleCapabilities.forRole(effective)
: Set.of();
return new WorkspaceAccessVO(workspaceId, memberRole, isGlobalAdmin, effective, capabilities);
}
public WorkspaceEntity getById(Long id) {
WorkspaceEntity entity = workspaceMapper.selectById(id);
if (entity == null) {