diff --git a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java index 5bc0aded..ea80a8c1 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java +++ b/mateclaw-server/src/main/java/vip/mate/team/controller/TeamController.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import vip.mate.agent.model.AgentEntity; import vip.mate.agent.repository.AgentMapper; import vip.mate.common.result.R; @@ -21,6 +23,7 @@ import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamTaskService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.security.Principal; import java.util.List; @@ -54,35 +57,32 @@ public class TeamController { @Operation(summary = "团队列表") @GetMapping + @RequireWorkspaceRole("viewer") public R> list() { - return R.ok(teamService.listTeams().stream().map(this::toVO).toList()); + return R.ok(teamService.listTeams(currentWorkspaceId()).stream().map(this::toVO).toList()); } @Operation(summary = "团队详情(含成员)") @GetMapping("/{id}") + @RequireWorkspaceRole("viewer") public R get(@PathVariable Long id) { - AgentTeamEntity team = teamService.getTeam(id); + AgentTeamEntity team = teamService.getTeam(id, currentWorkspaceId()); if (team == null) { return R.fail("team not found"); } List members = teamService.listMembers(id).stream() - .map(m -> { - AgentEntity agent = agentMapper.selectById(m.getAgentId()); - return new MemberVO(m.getAgentId(), - agent != null && agent.getName() != null ? agent.getName() - : String.valueOf(m.getAgentId()), - m.getRole(), - agent != null ? agent.getIcon() : null); - }) + .map(m -> toMemberVO(team, m)) + .filter(java.util.Objects::nonNull) .toList(); return R.ok(new TeamDetailVO(toVO(team), members)); } @Operation(summary = "创建团队") @PostMapping + @RequireWorkspaceRole("admin") public R create(@RequestBody CreateTeamRequest req, Principal principal) { return guarded(() -> { - AgentTeamEntity team = teamService.createTeam(req.getName(), req.getDescription(), + AgentTeamEntity team = teamService.createTeam(currentWorkspaceId(), req.getName(), req.getDescription(), req.getLeadAgentId(), req.getMemberAgentIds(), principal != null ? principal.getName() : "admin"); return R.ok(toVO(team)); @@ -91,16 +91,18 @@ public class TeamController { @Operation(summary = "更新团队") @PutMapping("/{id}") + @RequireWorkspaceRole("admin") public R update(@PathVariable Long id, @RequestBody UpdateTeamRequest req) { - return guarded(() -> R.ok(toVO(teamService.updateTeam(id, req.getName(), + return guarded(() -> R.ok(toVO(teamService.updateTeam(id, currentWorkspaceId(), req.getName(), req.getDescription(), req.getSettings())))); } @Operation(summary = "删除团队") @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") public R delete(@PathVariable Long id) { return guarded(() -> { - teamService.deleteTeam(id); + teamService.deleteTeam(id, currentWorkspaceId()); return R.ok(null); }); } @@ -109,18 +111,20 @@ public class TeamController { @Operation(summary = "添加成员") @PostMapping("/{id}/members") + @RequireWorkspaceRole("admin") public R addMember(@PathVariable Long id, @RequestBody MemberRequest req) { return guarded(() -> { - teamService.addMember(id, req.getAgentId(), req.getRole()); + teamService.addMember(id, currentWorkspaceId(), req.getAgentId(), req.getRole()); return R.ok(null); }); } @Operation(summary = "移除成员") @DeleteMapping("/{id}/members/{agentId}") + @RequireWorkspaceRole("admin") public R removeMember(@PathVariable Long id, @PathVariable Long agentId) { return guarded(() -> { - teamService.removeMember(id, agentId); + teamService.removeMember(id, currentWorkspaceId(), agentId); return R.ok(null); }); } @@ -129,18 +133,24 @@ public class TeamController { @Operation(summary = "任务板列表") @GetMapping("/{id}/tasks") + @RequireWorkspaceRole("viewer") public R> listTasks(@PathVariable Long id, @RequestParam(required = false) List status, @RequestParam(required = false) Integer limit, @RequestParam(required = false) Integer offset) { - return R.ok(taskService.listTasks(id, status, limit, offset).stream() - .map(this::toTaskVO).toList()); + return guarded(() -> { + requireTeam(id); + return R.ok(taskService.listTasks(id, status, limit, offset).stream() + .map(this::toTaskVO).toList()); + }); } @Operation(summary = "任务详情(含评论)") @GetMapping("/{id}/tasks/{taskId}") + @RequireWorkspaceRole("viewer") public R getTask(@PathVariable Long id, @PathVariable Long taskId) { return guarded(() -> { + requireTeam(id); TeamTaskEntity task = requireTask(id, taskId); return R.ok(new TaskDetailVO(toTaskVO(task), taskService.listComments(taskId))); }); @@ -148,9 +158,11 @@ public class TeamController { @Operation(summary = "手动创建任务") @PostMapping("/{id}/tasks") + @RequireWorkspaceRole("admin") public R createTask(@PathVariable Long id, @RequestBody CreateTaskRequest req, Principal principal) { return guarded(() -> { + requireTeam(id); TeamTaskEntity task = taskService.createTask(TeamTaskCreateCommand.builder() .teamId(id) .subject(req.getSubject()) @@ -172,9 +184,11 @@ public class TeamController { @Operation(summary = "批准 in_review 任务") @PostMapping("/{id}/tasks/{taskId}/approve") + @RequireWorkspaceRole("admin") public R approve(@PathVariable Long id, @PathVariable Long taskId, Principal principal) { return guarded(() -> { + requireTeam(id); requireTask(id, taskId); List released = taskService.approveTask(taskId); recordUserEvent(id, taskId, TeamTaskEventEntity.APPROVED, principal, null); @@ -188,10 +202,12 @@ public class TeamController { @Operation(summary = "驳回 in_review 任务") @PostMapping("/{id}/tasks/{taskId}/reject") + @RequireWorkspaceRole("admin") public R reject(@PathVariable Long id, @PathVariable Long taskId, @RequestBody(required = false) ReasonRequest req, Principal principal) { return guarded(() -> { + requireTeam(id); requireTask(id, taskId); taskService.rejectTask(taskId, req == null ? null : req.getReason()); recordUserEvent(id, taskId, TeamTaskEventEntity.REJECTED, principal, @@ -207,9 +223,11 @@ public class TeamController { @Operation(summary = "重试 failed/stale 任务") @PostMapping("/{id}/tasks/{taskId}/retry") + @RequireWorkspaceRole("admin") public R retry(@PathVariable Long id, @PathVariable Long taskId, Principal principal) { return guarded(() -> { + requireTeam(id); requireTask(id, taskId); if (!taskService.retryTask(taskId)) { return R.fail("only failed or stale tasks can be retried"); @@ -223,10 +241,12 @@ public class TeamController { @Operation(summary = "取消任务") @PostMapping("/{id}/tasks/{taskId}/cancel") + @RequireWorkspaceRole("admin") public R cancel(@PathVariable Long id, @PathVariable Long taskId, @RequestBody(required = false) ReasonRequest req, Principal principal) { return guarded(() -> { + requireTeam(id); TeamTaskEntity task = requireTask(id, taskId); List released = taskService.cancelTask(taskId, req == null ? null : req.getReason()); recordUserEvent(id, taskId, TeamTaskEventEntity.CANCELLED, principal, @@ -243,8 +263,10 @@ public class TeamController { @Operation(summary = "任务时间线") @GetMapping("/{id}/tasks/{taskId}/events") + @RequireWorkspaceRole("viewer") public R> taskEvents(@PathVariable Long id, @PathVariable Long taskId) { return guarded(() -> { + requireTeam(id); requireTask(id, taskId); return R.ok(taskService.listEvents(taskId)); }); @@ -252,8 +274,10 @@ public class TeamController { @Operation(summary = "团队事件流(SSE)") @GetMapping("/{id}/events") + @RequireWorkspaceRole("viewer") public SseEmitter events(@PathVariable Long id, @RequestHeader(value = "Last-Event-ID", required = false) Long lastEventId) { + requireTeam(id); SseEmitter emitter = new SseEmitter(0L); // A fresh subscription is an activity ticker, not a transcript: skip // the ring-buffer replay (stale events would render as breaking news) @@ -275,9 +299,11 @@ public class TeamController { @Operation(summary = "添加评论") @PostMapping("/{id}/tasks/{taskId}/comments") + @RequireWorkspaceRole("admin") public R comment(@PathVariable Long id, @PathVariable Long taskId, @RequestBody CommentRequest req, Principal principal) { return guarded(() -> { + requireTeam(id); requireTask(id, taskId); taskService.addComment(taskId, TeamTaskService.AUTHOR_USER, principal != null ? principal.getName() : "admin", @@ -288,8 +314,12 @@ public class TeamController { @Operation(summary = "任务状态统计(看板列头)") @GetMapping("/{id}/tasks/stats") + @RequireWorkspaceRole("viewer") public R> taskStats(@PathVariable Long id) { - return R.ok(taskService.countByStatus(id)); + return guarded(() -> { + requireTeam(id); + return R.ok(taskService.countByStatus(id)); + }); } // ==================== helpers / DTOs ==================== @@ -316,8 +346,32 @@ public class TeamController { return task; } + private AgentTeamEntity requireTeam(Long teamId) { + AgentTeamEntity team = teamService.getTeam(teamId, currentWorkspaceId()); + if (team == null) { + throw new IllegalArgumentException("team not found: " + teamId); + } + return team; + } + + private long currentWorkspaceId() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) { + String header = attrs.getRequest().getHeader("X-Workspace-Id"); + if (header != null && !header.isBlank()) { + try { + return Long.parseLong(header.trim()); + } catch (NumberFormatException ignored) { + // Keep the same defaulting semantics as WorkspaceAccessInterceptor. + } + } + } + return 1L; + } + private TeamVO toVO(AgentTeamEntity team) { - long memberCount = teamService.listMembers(team.getId()).size(); + long memberCount = teamService.listMembers(team.getId()).stream() + .filter(member -> memberBelongsToWorkspace(team, member)) + .count(); AgentEntity lead = agentMapper.selectById(team.getLeadAgentId()); return new TeamVO(team, lead != null && lead.getName() != null ? lead.getName() @@ -326,6 +380,21 @@ public class TeamController { memberCount); } + private MemberVO toMemberVO(AgentTeamEntity team, AgentTeamMemberEntity member) { + AgentEntity agent = agentMapper.selectById(member.getAgentId()); + if (agent == null || !team.getWorkspaceId().equals(agent.getWorkspaceId())) { + return null; + } + return new MemberVO(member.getAgentId(), + agent.getName() != null ? agent.getName() : String.valueOf(member.getAgentId()), + member.getRole(), agent.getIcon()); + } + + private boolean memberBelongsToWorkspace(AgentTeamEntity team, AgentTeamMemberEntity member) { + AgentEntity agent = agentMapper.selectById(member.getAgentId()); + return agent != null && team.getWorkspaceId().equals(agent.getWorkspaceId()); + } + private TaskVO toTaskVO(TeamTaskEntity task) { return new TaskVO(task, agentName(task.getAssigneeAgentId()), diff --git a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java index 5ab849b1..daf3a510 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/team/model/AgentTeamEntity.java @@ -23,6 +23,9 @@ public class AgentTeamEntity { private String description; + /** Owning workspace. Team data is never shared across workspaces. */ + private Long workspaceId; + /** Agent that orchestrates this team; exactly one per team. */ private Long leadAgentId; diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java index 984a82a2..b7f03035 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamDispatchService.java @@ -108,7 +108,7 @@ public class TeamDispatchService { @Scheduled(fixedDelay = 30_000, initialDelay = 30_000) public void scheduledSweep() { taskService.recoverStaleTasks(); - for (AgentTeamEntity team : teamService.listTeams()) { + for (AgentTeamEntity team : teamService.listAllTeams()) { if (TeamService.STATUS_ACTIVE.equals(team.getStatus())) { try { sweep(team.getId()); diff --git a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java index 539938a4..b3816677 100644 --- a/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java +++ b/mateclaw-server/src/main/java/vip/mate/team/service/TeamService.java @@ -43,16 +43,16 @@ public class TeamService { private final ApplicationEventPublisher eventPublisher; @Transactional - public AgentTeamEntity createTeam(String name, String description, Long leadAgentId, + public AgentTeamEntity createTeam(Long workspaceId, String name, String description, Long leadAgentId, List memberAgentIds, String createdBy) { - requireAgentExists(leadAgentId, "lead"); + requireAgentInWorkspace(leadAgentId, workspaceId, "lead"); requireNotInAnyTeam(leadAgentId); if (memberAgentIds != null) { for (Long memberId : memberAgentIds) { if (memberId.equals(leadAgentId)) { throw new IllegalArgumentException("lead agent cannot also be listed as a member"); } - requireAgentExists(memberId, "member"); + requireAgentInWorkspace(memberId, workspaceId, "member"); requireNotInAnyTeam(memberId); } } @@ -60,6 +60,7 @@ public class TeamService { AgentTeamEntity team = new AgentTeamEntity(); team.setName(name); team.setDescription(description); + team.setWorkspaceId(workspaceId); team.setLeadAgentId(leadAgentId); team.setStatus(STATUS_ACTIVE); team.setTaskSeq(0); @@ -77,23 +78,23 @@ public class TeamService { } @Transactional - public void addMember(Long teamId, Long agentId, String role) { - AgentTeamEntity team = requireTeam(teamId); + public void addMember(Long teamId, Long workspaceId, Long agentId, String role) { + AgentTeamEntity team = requireTeam(teamId, workspaceId); if (TeamRole.LEAD.equals(role)) { throw new IllegalArgumentException("a team has exactly one lead; role must be member or reviewer"); } if (agentId.equals(team.getLeadAgentId())) { throw new IllegalArgumentException("agent is already the team lead"); } - requireAgentExists(agentId, "member"); + requireAgentInWorkspace(agentId, workspaceId, "member"); requireNotInAnyTeam(agentId); insertMember(teamId, agentId, role == null ? TeamRole.MEMBER : role); notifyTeamChanged(teamId); } @Transactional - public void removeMember(Long teamId, Long agentId) { - AgentTeamEntity team = requireTeam(teamId); + public void removeMember(Long teamId, Long workspaceId, Long agentId) { + AgentTeamEntity team = requireTeam(teamId, workspaceId); if (agentId.equals(team.getLeadAgentId())) { throw new IllegalArgumentException("cannot remove the team lead; delete the team instead"); } @@ -106,8 +107,8 @@ public class TeamService { } @Transactional - public void deleteTeam(Long teamId) { - requireTeam(teamId); + public void deleteTeam(Long teamId, Long workspaceId) { + requireTeam(teamId, workspaceId); // Capture membership before it is wiped so every agent gets evicted. List agentIds = listMembers(teamId).stream() .map(AgentTeamMemberEntity::getAgentId).toList(); @@ -118,8 +119,8 @@ public class TeamService { } @Transactional - public AgentTeamEntity updateTeam(Long teamId, String name, String description, String settings) { - AgentTeamEntity team = requireTeam(teamId); + public AgentTeamEntity updateTeam(Long teamId, Long workspaceId, String name, String description, String settings) { + AgentTeamEntity team = requireTeam(teamId, workspaceId); if (name != null) { team.setName(name); } @@ -134,7 +135,14 @@ public class TeamService { return team; } - public List listTeams() { + public List listTeams(Long workspaceId) { + return teamMapper.selectList(Wrappers.lambdaQuery() + .eq(AgentTeamEntity::getWorkspaceId, workspaceId) + .orderByDesc(AgentTeamEntity::getCreateTime)); + } + + /** Internal scheduler view across all workspaces. Never expose through an HTTP endpoint. */ + public List listAllTeams() { return teamMapper.selectList(Wrappers.lambdaQuery() .orderByDesc(AgentTeamEntity::getCreateTime)); } @@ -143,6 +151,12 @@ public class TeamService { return teamMapper.selectById(teamId); } + public AgentTeamEntity getTeam(Long teamId, Long workspaceId) { + return teamMapper.selectOne(Wrappers.lambdaQuery() + .eq(AgentTeamEntity::getId, teamId) + .eq(AgentTeamEntity::getWorkspaceId, workspaceId)); + } + public List listMembers(Long teamId) { return memberMapper.selectList(Wrappers.lambdaQuery() .eq(AgentTeamMemberEntity::getTeamId, teamId) @@ -154,6 +168,10 @@ public class TeamService { * builder to inject team context and by the task tool to scope board access. */ public Optional getTeamForAgent(Long agentId) { + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null || agent.getWorkspaceId() == null) { + return Optional.empty(); + } AgentTeamMemberEntity member = memberMapper.selectOne(Wrappers.lambdaQuery() .eq(AgentTeamMemberEntity::getAgentId, agentId) .last("LIMIT 1")); @@ -161,13 +179,20 @@ public class TeamService { return Optional.empty(); } AgentTeamEntity team = teamMapper.selectById(member.getTeamId()); - if (team == null || !STATUS_ACTIVE.equals(team.getStatus())) { + if (team == null || !STATUS_ACTIVE.equals(team.getStatus()) + || !agent.getWorkspaceId().equals(team.getWorkspaceId())) { return Optional.empty(); } return Optional.of(team); } public boolean isMember(Long teamId, Long agentId) { + AgentTeamEntity team = teamMapper.selectById(teamId); + AgentEntity agent = agentMapper.selectById(agentId); + if (team == null || agent == null || team.getWorkspaceId() == null + || !team.getWorkspaceId().equals(agent.getWorkspaceId())) { + return false; + } return memberMapper.selectCount(Wrappers.lambdaQuery() .eq(AgentTeamMemberEntity::getTeamId, teamId) .eq(AgentTeamMemberEntity::getAgentId, agentId)) > 0; @@ -209,19 +234,22 @@ public class TeamService { memberMapper.insert(member); } - private AgentTeamEntity requireTeam(Long teamId) { - AgentTeamEntity team = teamMapper.selectById(teamId); + private AgentTeamEntity requireTeam(Long teamId, Long workspaceId) { + AgentTeamEntity team = getTeam(teamId, workspaceId); if (team == null) { throw new IllegalArgumentException("team not found: " + teamId); } return team; } - private void requireAgentExists(Long agentId, String roleLabel) { + private void requireAgentInWorkspace(Long agentId, Long workspaceId, String roleLabel) { AgentEntity agent = agentMapper.selectById(agentId); if (agent == null) { throw new IllegalArgumentException(roleLabel + " agent not found: " + agentId); } + if (!workspaceId.equals(agent.getWorkspaceId())) { + throw new IllegalArgumentException(roleLabel + " agent does not belong to the current workspace: " + agentId); + } } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V180__team_workspace_isolation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V180__team_workspace_isolation.sql new file mode 100644 index 00000000..20c5137f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V180__team_workspace_isolation.sql @@ -0,0 +1,13 @@ +-- Bind every team to one workspace. Existing teams inherit the lead agent's +-- workspace; rows with a missing/legacy lead remain in the default workspace. +ALTER TABLE mate_agent_team + ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; + +UPDATE mate_agent_team t +SET workspace_id = COALESCE( + (SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id), + 1 +); + +CREATE INDEX IF NOT EXISTS idx_agent_team_workspace + ON mate_agent_team (workspace_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V180__team_workspace_isolation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V180__team_workspace_isolation.sql new file mode 100644 index 00000000..20c5137f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V180__team_workspace_isolation.sql @@ -0,0 +1,13 @@ +-- Bind every team to one workspace. Existing teams inherit the lead agent's +-- workspace; rows with a missing/legacy lead remain in the default workspace. +ALTER TABLE mate_agent_team + ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; + +UPDATE mate_agent_team t +SET workspace_id = COALESCE( + (SELECT a.workspace_id FROM mate_agent a WHERE a.id = t.lead_agent_id), + 1 +); + +CREATE INDEX IF NOT EXISTS idx_agent_team_workspace + ON mate_agent_team (workspace_id, create_time); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V180__team_workspace_isolation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V180__team_workspace_isolation.sql new file mode 100644 index 00000000..452907fc --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V180__team_workspace_isolation.sql @@ -0,0 +1,24 @@ +-- Bind every team to one workspace. Existing teams inherit the lead agent's +-- workspace; rows with a missing/legacy lead remain in the default workspace. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent_team' + AND COLUMN_NAME = 'workspace_id'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_agent_team ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1 AFTER description', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE mate_agent_team t +JOIN mate_agent a ON a.id = t.lead_agent_id +SET t.workspace_id = a.workspace_id +WHERE a.workspace_id IS NOT NULL; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent_team' + AND INDEX_NAME = 'idx_agent_team_workspace'); +SET @s := IF(@c = 0, + 'CREATE INDEX idx_agent_team_workspace ON mate_agent_team (workspace_id, create_time)', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java index 75a7e5ff..f4b6ec4e 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/controller/TeamControllerTest.java @@ -1,19 +1,26 @@ package vip.mate.team.controller; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import vip.mate.agent.repository.AgentMapper; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; import vip.mate.common.result.R; +import vip.mate.config.WorkspaceAccessInterceptor; import vip.mate.team.model.TeamTaskCreateCommand; +import vip.mate.team.model.AgentTeamEntity; import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.service.TeamAnnounceService; import vip.mate.team.service.TeamDispatchService; import vip.mate.team.service.TeamEventChannel; import vip.mate.team.service.TeamService; import vip.mate.team.service.TeamTaskService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.service.WorkspaceService; import java.util.List; @@ -27,6 +34,8 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * Verifies that service-layer validation verdicts (IllegalArgumentException / @@ -45,6 +54,8 @@ class TeamControllerTest { @Mock private TeamAnnounceService announceService; @Mock private TeamEventChannel eventChannel; @Mock private AgentMapper agentMapper; + @Mock private WorkspaceService workspaceService; + @Mock private AuthService authService; private TeamController controller; @@ -52,6 +63,15 @@ class TeamControllerTest { void setUp() { controller = new TeamController(teamService, taskService, dispatchService, announceService, eventChannel, agentMapper); + AgentTeamEntity team = new AgentTeamEntity(); + team.setId(TEAM_ID); + team.setWorkspaceId(1L); + org.mockito.Mockito.lenient().when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(team); + } + + @AfterEach + void clearSecurityContext() { + org.springframework.security.core.context.SecurityContextHolder.clearContext(); } private TeamTaskEntity task(Long teamId, String status) { @@ -68,7 +88,7 @@ class TeamControllerTest { @Test void createTeamSurfacesMembershipConflictAsReadableFailure() { - when(teamService.createTeam(any(), any(), any(), any(), any())) + when(teamService.createTeam(anyLong(), any(), any(), any(), any(), any())) .thenThrow(new IllegalStateException( "agent 5 already belongs to team 2; an agent can join only one team")); TeamController.CreateTeamRequest req = new TeamController.CreateTeamRequest(); @@ -83,7 +103,7 @@ class TeamControllerTest { @Test void updateTeamSurfacesUnknownTeamAsReadableFailure() { - when(teamService.updateTeam(eq(TEAM_ID), any(), any(), any())) + when(teamService.updateTeam(eq(TEAM_ID), eq(1L), any(), any(), any())) .thenThrow(new IllegalArgumentException("team not found: " + TEAM_ID)); R r = controller.update(TEAM_ID, new TeamController.UpdateTeamRequest()); @@ -95,7 +115,7 @@ class TeamControllerTest { @Test void addMemberSurfacesValidationAsReadableFailure() { doThrow(new IllegalArgumentException("agent is already the team lead")) - .when(teamService).addMember(TEAM_ID, 5L, "member"); + .when(teamService).addMember(TEAM_ID, 1L, 5L, "member"); TeamController.MemberRequest req = new TeamController.MemberRequest(); req.setAgentId(5L); req.setRole("member"); @@ -109,7 +129,7 @@ class TeamControllerTest { @Test void removeMemberSurfacesLeadProtectionAsReadableFailure() { doThrow(new IllegalArgumentException("cannot remove the team lead; delete the team instead")) - .when(teamService).removeMember(TEAM_ID, 5L); + .when(teamService).removeMember(TEAM_ID, 1L, 5L); R r = controller.removeMember(TEAM_ID, 5L); @@ -240,4 +260,73 @@ class TeamControllerTest { assertEquals(500, r.getCode()); assertEquals("task not found on this team's board", r.getMsg()); } + + @Test + void crossWorkspaceTeamIsRejectedBeforeTaskBoardRead() { + when(teamService.getTeam(TEAM_ID, 1L)).thenReturn(null); + + R> r = controller.listTasks(TEAM_ID, null, null, null); + + assertEquals(500, r.getCode()); + assertEquals("team not found: 1", r.getMsg()); + verify(taskService, never()).listTasks(anyLong(), any(), any(), any()); + } + + @Test + void everyEndpointDeclaresWorkspaceRole() { + assertRole("list", "viewer"); + assertRole("get", "viewer", Long.class); + assertRole("create", "admin", TeamController.CreateTeamRequest.class, java.security.Principal.class); + assertRole("update", "admin", Long.class, TeamController.UpdateTeamRequest.class); + assertRole("delete", "admin", Long.class); + assertRole("addMember", "admin", Long.class, TeamController.MemberRequest.class); + assertRole("removeMember", "admin", Long.class, Long.class); + assertRole("listTasks", "viewer", Long.class, List.class, Integer.class, Integer.class); + assertRole("getTask", "viewer", Long.class, Long.class); + assertRole("createTask", "admin", Long.class, TeamController.CreateTaskRequest.class, java.security.Principal.class); + assertRole("approve", "admin", Long.class, Long.class, java.security.Principal.class); + assertRole("reject", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class); + assertRole("retry", "admin", Long.class, Long.class, java.security.Principal.class); + assertRole("cancel", "admin", Long.class, Long.class, TeamController.ReasonRequest.class, java.security.Principal.class); + assertRole("taskEvents", "viewer", Long.class, Long.class); + assertRole("events", "viewer", Long.class, Long.class); + assertRole("comment", "admin", Long.class, Long.class, TeamController.CommentRequest.class, java.security.Principal.class); + assertRole("taskStats", "viewer", Long.class); + } + + @Test + void viewerCannotDeleteTeam() throws Exception { + UserEntity viewer = new UserEntity(); + viewer.setId(42L); + viewer.setUsername("viewer"); + viewer.setRole("user"); + when(authService.findByUsername("viewer")).thenReturn(viewer); + when(workspaceService.hasPermissionCached(1L, 42L, "admin")).thenReturn(false); + var authentication = new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + "viewer", "", List.of()); + org.springframework.security.core.context.SecurityContextHolder.getContext() + .setAuthentication(authentication); + var mockMvc = org.springframework.test.web.servlet.setup.MockMvcBuilders + .standaloneSetup(controller) + .addInterceptors(new WorkspaceAccessInterceptor(workspaceService, authService, agentMapper)) + .build(); + + mockMvc.perform(delete("/api/v1/teams/{id}", TEAM_ID) + .header("X-Workspace-Id", "1")) + .andExpect(status().isForbidden()); + + verify(teamService, never()).deleteTeam(anyLong(), anyLong()); + } + + private void assertRole(String method, String role, Class... parameterTypes) { + try { + RequireWorkspaceRole annotation = TeamController.class + .getDeclaredMethod(method, parameterTypes) + .getAnnotation(RequireWorkspaceRole.class); + assertNotNull(annotation, method + " must require a workspace role"); + assertEquals(role, annotation.value(), method + " role"); + } catch (NoSuchMethodException e) { + throw new AssertionError(e); + } + } } diff --git a/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java index 567d3fe5..bc5d6343 100644 --- a/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/team/service/TeamServiceTest.java @@ -16,6 +16,7 @@ import vip.mate.team.repository.AgentTeamMapper; import vip.mate.team.repository.AgentTeamMemberMapper; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -58,6 +59,7 @@ class TeamServiceTest { a.setId(id); a.setName("agent-" + id); a.setAgentType(agentType); + a.setWorkspaceId(1L); return a; } @@ -69,7 +71,80 @@ class TeamServiceTest { when(memberMapper.selectCount(any())).thenReturn(0L); assertDoesNotThrow(() -> - service.createTeam("组", null, LEAD_ID, List.of(MEMBER_ID), "admin")); - verify(teamMapper).insert(any(AgentTeamEntity.class)); + service.createTeam(1L, "组", null, LEAD_ID, List.of(MEMBER_ID), "admin")); + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(AgentTeamEntity.class); + verify(teamMapper).insert(captor.capture()); + assertEquals(1L, captor.getValue().getWorkspaceId()); + } + + @Test + void rejectsMemberFromAnotherWorkspace() { + AgentEntity lead = agent(LEAD_ID, "react"); + AgentEntity member = agent(MEMBER_ID, "react"); + member.setWorkspaceId(2L); + when(agentMapper.selectById(LEAD_ID)).thenReturn(lead); + when(agentMapper.selectById(MEMBER_ID)).thenReturn(member); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> service.createTeam(1L, "组", null, LEAD_ID, List.of(MEMBER_ID), "admin")); + + assertEquals("member agent does not belong to the current workspace: 2", error.getMessage()); + verify(teamMapper, never()).insert(any(AgentTeamEntity.class)); + } + + @Test + void listTeamsAlwaysScopesByWorkspace() { + when(teamMapper.selectList(any())).thenReturn(List.of()); + + service.listTeams(7L); + + @SuppressWarnings("rawtypes") + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class); + verify(teamMapper).selectList(captor.capture()); + com.baomidou.mybatisplus.core.conditions.Wrapper wrapper = captor.getValue(); + assertTrue(wrapper.getSqlSegment().toLowerCase().contains("workspace"), wrapper.getSqlSegment()); + @SuppressWarnings("unchecked") + com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper lambda = + (com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper) wrapper; + assertTrue(lambda.getParamNameValuePairs().containsValue(7L)); + } + + @Test + void agentCannotResolveTeamOwnedByAnotherWorkspace() { + AgentEntity agent = agent(LEAD_ID, "react"); + AgentTeamMemberEntity membership = new AgentTeamMemberEntity(); + membership.setTeamId(10L); + AgentTeamEntity foreignTeam = new AgentTeamEntity(); + foreignTeam.setId(10L); + foreignTeam.setWorkspaceId(2L); + foreignTeam.setStatus(TeamService.STATUS_ACTIVE); + when(agentMapper.selectById(LEAD_ID)).thenReturn(agent); + when(memberMapper.selectOne(any())).thenReturn(membership); + when(teamMapper.selectById(10L)).thenReturn(foreignTeam); + + Optional result = service.getTeamForAgent(LEAD_ID); + + assertTrue(result.isEmpty()); + } + + @Test + void addMemberRejectsAgentFromAnotherWorkspace() { + AgentTeamEntity team = new AgentTeamEntity(); + team.setId(10L); + team.setLeadAgentId(LEAD_ID); + team.setWorkspaceId(1L); + AgentEntity foreignMember = agent(MEMBER_ID, "react"); + foreignMember.setWorkspaceId(2L); + when(teamMapper.selectOne(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))) + .thenReturn(team); + when(agentMapper.selectById(MEMBER_ID)).thenReturn(foreignMember); + + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> service.addMember(10L, 1L, MEMBER_ID, "member")); + + assertEquals("member agent does not belong to the current workspace: 2", error.getMessage()); + verify(memberMapper, never()).insert(any(AgentTeamMemberEntity.class)); } } diff --git a/mateclaw-ui/src/composables/useTeamEvents.ts b/mateclaw-ui/src/composables/useTeamEvents.ts index 8121a0ab..7c4b8c42 100644 --- a/mateclaw-ui/src/composables/useTeamEvents.ts +++ b/mateclaw-ui/src/composables/useTeamEvents.ts @@ -20,7 +20,9 @@ export function subscribeTeamEvents( const run = async () => { const headers: Record = { Accept: 'text/event-stream' } const token = localStorage.getItem('token') + const workspaceId = localStorage.getItem('mc-workspace-id') if (token) headers.Authorization = `Bearer ${token}` + if (workspaceId) headers['X-Workspace-Id'] = workspaceId const res = await fetch(`/api/v1/teams/${teamId}/events`, { headers,