fix(security): close workspace isolation gaps and audit context loss

This commit is contained in:
matevip 2026-04-09 14:19:51 +08:00
parent 3506c55bc8
commit d44ba0cdd8
7 changed files with 140 additions and 39 deletions

View File

@ -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<AgentEntity>> 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<AgentEntity> get(@PathVariable Long id) {
return R.ok(agentService.getAgent(id));
public R<AgentEntity> 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<AgentEntity> 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<AgentEntity> update(@PathVariable Long id, @RequestBody AgentEntity agent) {
public R<AgentEntity> 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<Void> delete(@PathVariable Long id) {
public R<Void> 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("资源不属于当前工作区");
}
}
}

View File

@ -35,18 +35,26 @@ public class AuditEventService {
private final AuthService authService;
/**
* 异步记录审计事件
* 异步记录审计事件
* <p>
* 在调用线程请求线程中捕获完整上下文然后交给异步线程写库
* 这样避免了 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);
}
}

View File

@ -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<ChannelEntity>> 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<ChannelEntity> get(@PathVariable Long id) {
return R.ok(channelService.getChannel(id));
public R<ChannelEntity> 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<ChannelEntity> 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<ChannelEntity> update(@PathVariable Long id, @RequestBody ChannelEntity channel) {
public R<ChannelEntity> 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<Void> delete(@PathVariable Long id) {
// 先停止渠道再删除
public R<Void> 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<ChannelEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
public R<ChannelEntity> 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<Map<String, Object>> 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("资源不属于当前工作区");
}
}
}

View File

@ -50,13 +50,17 @@ public class DashboardController {
public R<List<CronJobRunEntity>> 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<List<CronJobRunEntity>> recentRuns(@RequestParam(defaultValue = "20") int limit) {
return R.ok(cronJobRunService.listRecent(Math.min(limit, 100)));
public R<List<CronJobRunEntity>> 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)));
}
}

View File

@ -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<CronJobRunEntity> listRecentByWorkspace(Long workspaceId, int limit) {
// 1. workspace 下的 agent IDs
List<AgentEntity> agents = agentMapper.selectList(
new LambdaQueryWrapper<AgentEntity>()
.eq(AgentEntity::getWorkspaceId, workspaceId)
.select(AgentEntity::getId));
if (agents.isEmpty()) return Collections.emptyList();
Set<Long> agentIds = agents.stream().map(AgentEntity::getId).collect(Collectors.toSet());
// 2. 这些 agent 关联的 cronJob IDs
List<CronJobEntity> jobs = cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>()
.in(CronJobEntity::getAgentId, agentIds)
.select(CronJobEntity::getId));
if (jobs.isEmpty()) return Collections.emptyList();
Set<Long> jobIds = jobs.stream().map(CronJobEntity::getId).collect(Collectors.toSet());
// 3. 这些 cronJob 的执行记录
return runMapper.selectList(
new LambdaQueryWrapper<CronJobRunEntity>()
.in(CronJobRunEntity::getCronJobId, jobIds)
.orderByDesc(CronJobRunEntity::getStartedAt)
.last("LIMIT " + limit));
}
}

View File

@ -79,11 +79,18 @@ public class WikiController {
@RequireWorkspaceRole("member")
@Operation(summary = "创建知识库")
@PostMapping("/knowledge-bases")
public R<WikiKnowledgeBaseEntity> createKB(@RequestBody Map<String, Object> body) {
public R<WikiKnowledgeBaseEntity> createKB(@RequestBody Map<String, Object> 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_idcreate 方法内部不感知 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")

View File

@ -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);