From d44ba0cdd85d99ef8f315ba3902b74d18152ec78 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 9 Apr 2026 14:19:51 +0800 Subject: [PATCH] fix(security): close workspace isolation gaps and audit context loss --- .../agent/controller/AgentController.java | 43 +++++++++++++----- .../mate/audit/service/AuditEventService.java | 22 +++++++--- .../channel/controller/ChannelController.java | 44 ++++++++++++------- .../controller/DashboardController.java | 10 +++-- .../dashboard/service/CronJobRunService.java | 38 ++++++++++++++++ .../mate/wiki/controller/WikiController.java | 11 ++++- .../service/WikiKnowledgeBaseService.java | 11 +++++ 7 files changed, 140 insertions(+), 39 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index c1f1c99f..4934d37d 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -11,6 +11,7 @@ import vip.mate.agent.AgentState; import vip.mate.agent.model.AgentEntity; import vip.mate.audit.service.AuditEventService; import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.io.IOException; @@ -39,17 +40,19 @@ public class AgentController { @RequireWorkspaceRole("viewer") public R> list( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { - if (workspaceId != null) { - return R.ok(agentService.listAgentsByWorkspace(workspaceId)); - } - return R.ok(agentService.listAgents()); + // 无 header 时强制使用默认 workspace,不返回全局数据 + long wsId = workspaceId != null ? workspaceId : 1L; + return R.ok(agentService.listAgentsByWorkspace(wsId)); } @Operation(summary = "获取Agent详情") @GetMapping("/{id}") @RequireWorkspaceRole("viewer") - public R get(@PathVariable Long id) { - return R.ok(agentService.getAgent(id)); + public R get(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + AgentEntity agent = agentService.getAgent(id); + verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId); + return R.ok(agent); } @Operation(summary = "创建Agent") @@ -58,9 +61,8 @@ public class AgentController { public R create( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, @RequestBody AgentEntity agent) { - if (workspaceId != null) { - agent.setWorkspaceId(workspaceId); - } + // 始终注入 workspace_id,无 header 时使用默认 + agent.setWorkspaceId(workspaceId != null ? workspaceId : 1L); AgentEntity created = agentService.createAgent(agent); auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null); return R.ok(created); @@ -69,8 +71,12 @@ public class AgentController { @Operation(summary = "更新Agent") @PutMapping("/{id}") @RequireWorkspaceRole("member") - public R update(@PathVariable Long id, @RequestBody AgentEntity agent) { + public R update(@PathVariable Long id, @RequestBody AgentEntity agent, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + AgentEntity existing = agentService.getAgent(id); + verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId); agent.setId(id); + agent.setWorkspaceId(existing.getWorkspaceId()); // 不允许跨 workspace 迁移 AgentEntity updated = agentService.updateAgent(agent); auditEventService.record("UPDATE", "AGENT", String.valueOf(id), updated.getName(), null); return R.ok(updated); @@ -79,10 +85,12 @@ public class AgentController { @Operation(summary = "删除Agent") @DeleteMapping("/{id}") @RequireWorkspaceRole("admin") - public R delete(@PathVariable Long id) { + public R delete(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { AgentEntity agent = agentService.getAgent(id); + verifyResourceWorkspace(agent.getWorkspaceId(), workspaceId); agentService.deleteAgent(id); - auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent != null ? agent.getName() : null, null); + auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent.getName(), null); return R.ok(); } @@ -148,4 +156,15 @@ public class AgentController { private String message; private String conversationId = "default"; } + + /** + * 校验目标资源实际归属的 workspace 与请求 header 一致。 + * 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。 + */ + private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { + long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { + throw new MateClawException("资源不属于当前工作区"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java index d0727828..933dd804 100644 --- a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java +++ b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java @@ -35,18 +35,26 @@ public class AuditEventService { private final AuthService authService; /** - * 异步记录审计事件 + * 异步记录审计事件。 + *

+ * 在调用线程(请求线程)中捕获完整上下文,然后交给异步线程写库。 + * 这样避免了 SecurityContext/RequestContext 在异步线程中丢失的问题。 */ - @Async public void record(String action, String resourceType, String resourceId, String resourceName, String detailJson) { + // 在请求线程中构建事件(可以访问 SecurityContext 和 RequestContext) + AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson); + if (event != null) { + insertAsync(event); + } + } + + @Async + void insertAsync(AuditEventEntity event) { try { - AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson); - if (event != null) { - auditEventMapper.insert(event); - } + auditEventMapper.insert(event); } catch (Exception e) { - log.warn("Failed to record audit event: {}/{}/{}", action, resourceType, resourceId, e); + log.warn("Failed to insert audit event: {}/{}", event.getAction(), event.getResourceType(), e); } } diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java index c99ec140..0c3f3620 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java @@ -9,6 +9,7 @@ import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.service.ChannelService; import vip.mate.audit.service.AuditEventService; import vip.mate.common.result.R; +import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; @@ -37,10 +38,8 @@ public class ChannelController { @GetMapping public R> list( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { - if (workspaceId != null) { - return R.ok(channelService.listChannelsByWorkspace(workspaceId)); - } - return R.ok(channelService.listChannels()); + long wsId = workspaceId != null ? workspaceId : 1L; + return R.ok(channelService.listChannelsByWorkspace(wsId)); } @RequireWorkspaceRole("viewer") @@ -53,8 +52,11 @@ public class ChannelController { @RequireWorkspaceRole("viewer") @Operation(summary = "获取渠道详情") @GetMapping("/{id}") - public R get(@PathVariable Long id) { - return R.ok(channelService.getChannel(id)); + public R get(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + ChannelEntity channel = channelService.getChannel(id); + verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId); + return R.ok(channel); } @RequireWorkspaceRole("admin") @@ -63,9 +65,7 @@ public class ChannelController { public R create( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, @RequestBody ChannelEntity channel) { - if (workspaceId != null) { - channel.setWorkspaceId(workspaceId); - } + channel.setWorkspaceId(workspaceId != null ? workspaceId : 1L); ChannelEntity created = channelService.createChannel(channel); auditEventService.record("CREATE", "CHANNEL", String.valueOf(created.getId()), created.getName(), null); return R.ok(created); @@ -74,10 +74,13 @@ public class ChannelController { @RequireWorkspaceRole("admin") @Operation(summary = "更新渠道") @PutMapping("/{id}") - public R update(@PathVariable Long id, @RequestBody ChannelEntity channel) { + public R update(@PathVariable Long id, @RequestBody ChannelEntity channel, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + ChannelEntity existing = channelService.getChannel(id); + verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId); channel.setId(id); + channel.setWorkspaceId(existing.getWorkspaceId()); ChannelEntity updated = channelService.updateChannel(channel); - // 配置变更后热替换渠道(新 Adapter 就绪后才替换旧的,失败则保留旧的) channelManager.restartChannel(id); auditEventService.record("UPDATE", "CHANNEL", String.valueOf(id), updated.getName(), null); return R.ok(updated); @@ -86,19 +89,23 @@ public class ChannelController { @RequireWorkspaceRole("admin") @Operation(summary = "删除渠道") @DeleteMapping("/{id}") - public R delete(@PathVariable Long id) { - // 先停止渠道再删除 + public R delete(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { ChannelEntity channel = channelService.getChannel(id); + verifyResourceWorkspace(channel.getWorkspaceId(), workspaceId); channelManager.stopChannel(id); channelService.deleteChannel(id); - auditEventService.record("DELETE", "CHANNEL", String.valueOf(id), channel != null ? channel.getName() : null, null); + auditEventService.record("DELETE", "CHANNEL", String.valueOf(id), channel.getName(), null); return R.ok(); } @RequireWorkspaceRole("admin") @Operation(summary = "启用/禁用渠道") @PutMapping("/{id}/toggle") - public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { + public R toggle(@PathVariable Long id, @RequestParam boolean enabled, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + ChannelEntity existing = channelService.getChannel(id); + verifyResourceWorkspace(existing.getWorkspaceId(), workspaceId); ChannelEntity channel = channelService.toggleChannel(id, enabled); // 联动 ChannelManager:启用时启动,禁用时停止 if (enabled) { @@ -116,4 +123,11 @@ public class ChannelController { public R> status() { return R.ok(channelManager.getStatus()); } + + private void verifyResourceWorkspace(Long resourceWorkspaceId, Long headerWorkspaceId) { + long requestedWs = headerWorkspaceId != null ? headerWorkspaceId : 1L; + if (resourceWorkspaceId != null && !resourceWorkspaceId.equals(requestedWs)) { + throw new MateClawException("资源不属于当前工作区"); + } + } } diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java b/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java index 63abb670..64f03f26 100644 --- a/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java @@ -50,13 +50,17 @@ public class DashboardController { public R> cronJobRuns( @PathVariable Long cronJobId, @RequestParam(defaultValue = "20") int limit) { + // TODO: 校验 cronJobId 对应的 agent 属于当前 workspace return R.ok(cronJobRunService.listByJobId(cronJobId, Math.min(limit, 100))); } - @Operation(summary = "获取最近执行记录") + @Operation(summary = "获取最近执行记录(当前 workspace 关联的 CronJob)") @GetMapping("/cron-runs") @RequireWorkspaceRole("viewer") - public R> recentRuns(@RequestParam(defaultValue = "20") int limit) { - return R.ok(cronJobRunService.listRecent(Math.min(limit, 100))); + public R> recentRuns( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestParam(defaultValue = "20") int limit) { + long wsId = workspaceId != null ? workspaceId : 1L; + return R.ok(cronJobRunService.listRecentByWorkspace(wsId, Math.min(limit, 100))); } } diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java index c0ac9ab1..ed77fd80 100644 --- a/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java @@ -3,11 +3,18 @@ package vip.mate.dashboard.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.cron.repository.CronJobMapper; import vip.mate.dashboard.model.CronJobRunEntity; import vip.mate.dashboard.repository.CronJobRunMapper; import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; /** * CronJob 执行历史服务 @@ -19,6 +26,8 @@ import java.util.List; public class CronJobRunService { private final CronJobRunMapper runMapper; + private final CronJobMapper cronJobMapper; + private final AgentMapper agentMapper; /** * 记录一次执行开始 @@ -80,4 +89,33 @@ public class CronJobRunService { .orderByDesc(CronJobRunEntity::getStartedAt) .last("LIMIT " + limit)); } + + /** + * 查询指定 workspace 关联的最近执行记录 + * 路径:workspace → agents → cronJobs → cronJobRuns + */ + public List listRecentByWorkspace(Long workspaceId, int limit) { + // 1. workspace 下的 agent IDs + List agents = agentMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentEntity::getWorkspaceId, workspaceId) + .select(AgentEntity::getId)); + if (agents.isEmpty()) return Collections.emptyList(); + Set agentIds = agents.stream().map(AgentEntity::getId).collect(Collectors.toSet()); + + // 2. 这些 agent 关联的 cronJob IDs + List jobs = cronJobMapper.selectList( + new LambdaQueryWrapper() + .in(CronJobEntity::getAgentId, agentIds) + .select(CronJobEntity::getId)); + if (jobs.isEmpty()) return Collections.emptyList(); + Set jobIds = jobs.stream().map(CronJobEntity::getId).collect(Collectors.toSet()); + + // 3. 这些 cronJob 的执行记录 + return runMapper.selectList( + new LambdaQueryWrapper() + .in(CronJobRunEntity::getCronJobId, jobIds) + .orderByDesc(CronJobRunEntity::getStartedAt) + .last("LIMIT " + limit)); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 086bbb3d..adda9dc7 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -79,11 +79,18 @@ public class WikiController { @RequireWorkspaceRole("member") @Operation(summary = "创建知识库") @PostMapping("/knowledge-bases") - public R createKB(@RequestBody Map body) { + public R createKB(@RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { String name = (String) body.get("name"); String description = (String) body.get("description"); Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; - return R.ok(kbService.create(name, description, agentId)); + WikiKnowledgeBaseEntity kb = kbService.create(name, description, agentId); + // 注入 workspace_id(create 方法内部不感知 workspace,需要在 controller 层补充) + if (kb.getWorkspaceId() == null || kb.getWorkspaceId() == 0) { + kb.setWorkspaceId(workspaceId != null ? workspaceId : 1L); + kbService.updateWorkspaceId(kb.getId(), kb.getWorkspaceId()); + } + return R.ok(kb); } @RequireWorkspaceRole("member") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index a837e624..54c120fa 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -165,6 +165,17 @@ public class WikiKnowledgeBaseService { kbMapper.updateById(entity); } + /** + * 更新知识库的 workspace 归属 + */ + public void updateWorkspaceId(Long kbId, Long workspaceId) { + WikiKnowledgeBaseEntity entity = kbMapper.selectById(kbId); + if (entity != null) { + entity.setWorkspaceId(workspaceId); + kbMapper.updateById(entity); + } + } + @Transactional public void delete(Long id) { kbMapper.deleteById(id);