diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java index 1f1a26c8..e26d4a3d 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java @@ -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(Authentication auth) { - Long userId = resolveUserId(auth); - return R.ok(workspaceService.listByUserId(userId)); + public R> 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 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 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()); } } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceAccessVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceAccessVO.java new file mode 100644 index 00000000..117f920e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceAccessVO.java @@ -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/{id}/access}. + *

+ * 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 capabilities; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceWithRoleVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceWithRoleVO.java new file mode 100644 index 00000000..a2c1dc2e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/model/WorkspaceWithRoleVO.java @@ -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. + *

+ * Returned by {@code GET /api/v1/workspaces} so the frontend can render the + * workspace switcher and derive route visibility without an extra round-trip. + *

    + *
  • {@code memberRole}: real membership role; {@code null} for non-members
  • + *
  • {@code roleLevel}: numeric (owner=4..viewer=1, 0 for non-member)
  • + *
  • {@code isGlobalAdmin}: {@code mate_user.role='admin'} — bypasses workspace gates
  • + *
  • {@code effectiveRole}: {@code owner} for global admin, otherwise = {@code memberRole}
  • + *
+ * 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/security/Capability.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/security/Capability.java new file mode 100644 index 00000000..ed43febf --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/security/Capability.java @@ -0,0 +1,50 @@ +package vip.mate.workspace.core.security; + +/** + * Module-level capability constants for workspace RBAC. + *

+ * 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/{id}/access} endpoint returns + * the resolved capability set for the current user. + *

+ * 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"; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/security/RoleCapabilities.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/security/RoleCapabilities.java new file mode 100644 index 00000000..e56e72be --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/security/RoleCapabilities.java @@ -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 → capability mapping. + *

+ * 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. + *

+ * 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> ROLE_TO_CAPS; + + static { + Set viewer = new LinkedHashSet<>(); + viewer.add(Capability.CHAT); + viewer.add(Capability.VIEW_WIKI); + + Set 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 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 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 forRole(String role) { + if (role == null) { + return Set.of(); + } + Set 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; + }; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java index 9a9d9639..69182bae 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -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 listWithRoleByUserId(Long userId, boolean isGlobalAdmin) { + List memberships = memberMapper.selectList( + new LambdaQueryWrapper() + .eq(WorkspaceMemberEntity::getUserId, userId)); + Map roleByWorkspaceId = new HashMap<>(); + for (WorkspaceMemberEntity m : memberships) { + roleByWorkspaceId.put(m.getWorkspaceId(), m.getRole()); + } + + List 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 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/{id}/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 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) {