fix(cron): isolate cron jobs by workspace (issue #37)

This commit is contained in:
matevip 2026-04-29 15:49:04 +08:00
parent bd36f87cc9
commit 0a5b1989f3
8 changed files with 153 additions and 48 deletions

View File

@ -24,55 +24,75 @@ public class CronJobController {
private final CronJobService cronJobService; private final CronJobService cronJobService;
/**
* RFC-083: every endpoint reads {@code X-Workspace-Id} (the frontend
* axios interceptor already injects it). Service-layer filtering is
* required because {@link vip.mate.config.WorkspaceAccessInterceptor}
* skips its membership check entirely for global {@code admin} users
* relying on the interceptor alone leaks cron jobs across workspaces.
*/
private static final long DEFAULT_WORKSPACE_ID = 1L;
@Operation(summary = "获取定时任务列表") @Operation(summary = "获取定时任务列表")
@GetMapping @GetMapping
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("viewer")
public R<List<CronJobDTO>> list() { public R<List<CronJobDTO>> list(
return R.ok(cronJobService.list()); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(cronJobService.list(resolve(workspaceId)));
} }
@Operation(summary = "获取定时任务详情") @Operation(summary = "获取定时任务详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@RequireWorkspaceRole("viewer") @RequireWorkspaceRole("viewer")
public R<CronJobDTO> get(@PathVariable Long id) { public R<CronJobDTO> get(@PathVariable Long id,
return R.ok(cronJobService.getById(id)); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(cronJobService.getById(id, resolve(workspaceId)));
} }
@Operation(summary = "创建定时任务") @Operation(summary = "创建定时任务")
@PostMapping @PostMapping
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<CronJobDTO> create(@RequestBody CronJobDTO dto) { public R<CronJobDTO> create(@RequestBody CronJobDTO dto,
return R.ok(cronJobService.create(dto)); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(cronJobService.create(dto, resolve(workspaceId)));
} }
@Operation(summary = "更新定时任务") @Operation(summary = "更新定时任务")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto) { public R<CronJobDTO> update(@PathVariable Long id, @RequestBody CronJobDTO dto,
return R.ok(cronJobService.update(id, dto)); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
return R.ok(cronJobService.update(id, dto, resolve(workspaceId)));
} }
@Operation(summary = "删除定时任务") @Operation(summary = "删除定时任务")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireWorkspaceRole("admin")
public R<Void> delete(@PathVariable Long id) { public R<Void> delete(@PathVariable Long id,
cronJobService.delete(id); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
cronJobService.delete(id, resolve(workspaceId));
return R.ok(); return R.ok();
} }
@Operation(summary = "启用/禁用定时任务") @Operation(summary = "启用/禁用定时任务")
@PutMapping("/{id}/toggle") @PutMapping("/{id}/toggle")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled) { public R<Void> toggle(@PathVariable Long id, @RequestParam boolean enabled,
cronJobService.toggle(id, enabled); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
cronJobService.toggle(id, enabled, resolve(workspaceId));
return R.ok(); return R.ok();
} }
@Operation(summary = "立即执行定时任务") @Operation(summary = "立即执行定时任务")
@PostMapping("/{id}/run") @PostMapping("/{id}/run")
@RequireWorkspaceRole("member") @RequireWorkspaceRole("member")
public R<Void> runNow(@PathVariable Long id) { public R<Void> runNow(@PathVariable Long id,
cronJobService.runNow(id); @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
cronJobService.runNow(id, resolve(workspaceId));
return R.ok(); return R.ok();
} }
private static long resolve(Long headerWorkspaceId) {
return headerWorkspaceId != null ? headerWorkspaceId : DEFAULT_WORKSPACE_ID;
}
} }

View File

@ -13,6 +13,8 @@ import java.time.LocalDateTime;
public class CronJobDTO { public class CronJobDTO {
private Long id; private Long id;
/** Out-only: workspace ID stamped by the server from X-Workspace-Id (RFC-083). */
private Long workspaceId;
private String name; private String name;
private String cronExpression; private String cronExpression;
private String timezone; private String timezone;
@ -54,6 +56,7 @@ public class CronJobDTO {
public static CronJobDTO from(CronJobEntity entity) { public static CronJobDTO from(CronJobEntity entity) {
CronJobDTO dto = new CronJobDTO(); CronJobDTO dto = new CronJobDTO();
dto.setId(entity.getId()); dto.setId(entity.getId());
dto.setWorkspaceId(entity.getWorkspaceId());
dto.setName(entity.getName()); dto.setName(entity.getName());
dto.setCronExpression(entity.getCronExpression()); dto.setCronExpression(entity.getCronExpression());
dto.setTimezone(entity.getTimezone()); dto.setTimezone(entity.getTimezone());

View File

@ -18,6 +18,9 @@ public class CronJobEntity {
@TableId(type = IdType.ASSIGN_ID) @TableId(type = IdType.ASSIGN_ID)
private Long id; private Long id;
/** Workspace ID this cron job belongs to (RFC-083 / V62; existing rows default to 1). */
private Long workspaceId;
/** 任务名称 */ /** 任务名称 */
private String name; private String name;

View File

@ -17,13 +17,16 @@ import java.util.List;
public interface CronJobMapper extends BaseMapper<CronJobEntity> { public interface CronJobMapper extends BaseMapper<CronJobEntity> {
/** /**
* RFC-063r §2.14: list cron jobs together with their most-recent * RFC-063r §2.14 + RFC-083: list cron jobs in the given workspace together
* delivery status / error (subquery against {@code mate_cron_job_run}). * with their most-recent delivery status / error (subquery against
* {@code mate_cron_job_run}).
* *
* <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated * <p>Both H2 and MySQL accept {@code LIMIT 1} inside a correlated
* subquery, so the same SQL is portable across the two profiles. Index * subquery, so the same SQL is portable across the two profiles. Index
* coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created * coverage: {@code mate_cron_job_run(cron_job_id, started_at)} (created
* by V1 baseline migration) makes the subquery cheap. * by V1 baseline migration) makes the subquery cheap; V62 adds
* {@code idx_cron_job_workspace(workspace_id, deleted)} for the outer
* filter.
* *
* <p>Filters out logically-deleted rows and orders by create_time DESC * <p>Filters out logically-deleted rows and orders by create_time DESC
* to mirror the existing {@code list()} ordering. * to mirror the existing {@code list()} ordering.
@ -37,14 +40,16 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
WHERE r.cron_job_id = j.id WHERE r.cron_job_id = j.id
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
FROM mate_cron_job j FROM mate_cron_job j
WHERE j.deleted = 0 WHERE j.deleted = 0 AND j.workspace_id = #{workspaceId}
ORDER BY j.create_time DESC ORDER BY j.create_time DESC
""") """)
List<CronJobEntity> selectListWithDeliveryStatus(); List<CronJobEntity> selectListWithDeliveryStatus(@Param("workspaceId") Long workspaceId);
/** /**
* RFC-063r §2.14: per-job variant for the detail page. Same subquery * RFC-063r §2.14 + RFC-083: per-job variant for the detail page. Same
* pattern, restricted to a single id. * subquery pattern, restricted to a single id within the given workspace
* (cross-workspace access returns null caller throws not_found, matching
* the "deleted" shape so workspace existence isn't enumerable).
*/ */
@Select(""" @Select("""
SELECT j.*, SELECT j.*,
@ -55,7 +60,17 @@ public interface CronJobMapper extends BaseMapper<CronJobEntity> {
WHERE r.cron_job_id = j.id WHERE r.cron_job_id = j.id
ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error ORDER BY r.started_at DESC LIMIT 1) AS last_delivery_error
FROM mate_cron_job j FROM mate_cron_job j
WHERE j.id = #{id} AND j.deleted = 0 WHERE j.id = #{id} AND j.deleted = 0 AND j.workspace_id = #{workspaceId}
""") """)
CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id); CronJobEntity selectByIdWithDeliveryStatus(@Param("id") Long id,
@Param("workspaceId") Long workspaceId);
/**
* RFC-083: workspace-scoped lookup for write paths (update / delete /
* toggle / runNow). Skips the delivery-status subquery those paths
* don't need it and pay for the correlated lookup otherwise.
*/
@Select("SELECT * FROM mate_cron_job WHERE id = #{id} AND deleted = 0 AND workspace_id = #{workspaceId}")
CronJobEntity selectByIdAndWorkspace(@Param("id") Long id,
@Param("workspaceId") Long workspaceId);
} }

View File

@ -93,6 +93,10 @@ public class CronJobService implements ApplicationRunner {
scheduler.setThreadNamePrefix("cron-job-"); scheduler.setThreadNamePrefix("cron-job-");
scheduler.initialize(); scheduler.initialize();
// RFC-083: scheduler is process-global by design load every enabled
// job across all workspaces. Do NOT filter by workspace_id here,
// otherwise jobs in workspace B stop firing whenever the active UI
// workspace is A. Workspace isolation lives in the CRUD paths only.
List<CronJobEntity> enabledJobs = cronJobMapper.selectList( List<CronJobEntity> enabledJobs = cronJobMapper.selectList(
new LambdaQueryWrapper<CronJobEntity>() new LambdaQueryWrapper<CronJobEntity>()
.eq(CronJobEntity::getEnabled, true)); .eq(CronJobEntity::getEnabled, true));
@ -114,11 +118,12 @@ public class CronJobService implements ApplicationRunner {
// ==================== CRUD ==================== // ==================== CRUD ====================
public List<CronJobDTO> list() { public List<CronJobDTO> list(Long workspaceId) {
// RFC-063r §2.14: use the variant that aggregates the most-recent // RFC-063r §2.14: use the variant that aggregates the most-recent
// delivery_status from mate_cron_job_run so the list page can // delivery_status from mate_cron_job_run so the list page can
// render the "最近投递" badge without a per-row N+1 query. // render the "最近投递" badge without a per-row N+1 query.
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus(); // RFC-083: scoped to the caller's workspace.
List<CronJobEntity> entities = cronJobMapper.selectListWithDeliveryStatus(workspaceId);
// 批量加载 Agent 名称 // 批量加载 Agent 名称
List<Long> agentIds = entities.stream() List<Long> agentIds = entities.stream()
@ -152,10 +157,13 @@ public class CronJobService implements ApplicationRunner {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
public CronJobDTO getById(Long id) { public CronJobDTO getById(Long id, Long workspaceId) {
// RFC-063r §2.14: detail page shows lastDeliveryStatus too same // RFC-063r §2.14: detail page shows lastDeliveryStatus too same
// subquery shape, restricted to one id. // subquery shape, restricted to one id.
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id); // RFC-083: scoped to the caller's workspace; cross-workspace ID access
// surfaces as not_found (same shape as deleted) so workspace existence
// is not enumerable.
CronJobEntity entity = cronJobMapper.selectByIdWithDeliveryStatus(id, workspaceId);
if (entity == null) { if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
} }
@ -168,12 +176,15 @@ public class CronJobService implements ApplicationRunner {
return dto; return dto;
} }
public CronJobDTO create(CronJobDTO dto) { public CronJobDTO create(CronJobDTO dto, Long workspaceId) {
validateDto(dto); validateDto(dto);
// toSpringCron 校验表达式合法性结果复用于后续 calcNextRunTime register // toSpringCron 校验表达式合法性结果复用于后续 calcNextRunTime register
String springCron = toSpringCron(dto.getCronExpression()); String springCron = toSpringCron(dto.getCronExpression());
CronJobEntity entity = dto.toEntity(); CronJobEntity entity = dto.toEntity();
// RFC-083: workspace stamped server-side from X-Workspace-Id; never
// trust a client-supplied value (DTO.toEntity intentionally drops it).
entity.setWorkspaceId(workspaceId);
if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai"); if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai");
if (entity.getTaskType() == null) entity.setTaskType("text"); if (entity.getTaskType() == null) entity.setTaskType("text");
if (entity.getEnabled() == null) entity.setEnabled(true); if (entity.getEnabled() == null) entity.setEnabled(true);
@ -186,11 +197,13 @@ public class CronJobService implements ApplicationRunner {
register(entity); register(entity);
} }
return getById(entity.getId()); return getById(entity.getId(), workspaceId);
} }
public CronJobDTO update(Long id, CronJobDTO dto) { public CronJobDTO update(Long id, CronJobDTO dto, Long workspaceId) {
CronJobEntity existing = cronJobMapper.selectById(id); // RFC-083: scoped lookup cross-workspace updates 404 the same as
// deleted rows.
CronJobEntity existing = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (existing == null) { if (existing == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
} }
@ -222,11 +235,11 @@ public class CronJobService implements ApplicationRunner {
schedulerLock.unlock(); schedulerLock.unlock();
} }
return getById(id); return getById(id, workspaceId);
} }
public void delete(Long id) { public void delete(Long id, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectById(id); CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) { if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
} }
@ -239,8 +252,8 @@ public class CronJobService implements ApplicationRunner {
cronJobMapper.deleteById(id); cronJobMapper.deleteById(id);
} }
public void toggle(Long id, Boolean enabled) { public void toggle(Long id, Boolean enabled, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectById(id); CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) { if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
} }
@ -267,8 +280,8 @@ public class CronJobService implements ApplicationRunner {
} }
} }
public void runNow(Long id) { public void runNow(Long id, Long workspaceId) {
CronJobEntity entity = cronJobMapper.selectById(id); CronJobEntity entity = cronJobMapper.selectByIdAndWorkspace(id, workspaceId);
if (entity == null) { if (entity == null) {
throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id); throw new MateClawException("err.cron.not_found", "定时任务不存在: " + id);
} }
@ -309,7 +322,8 @@ public class CronJobService implements ApplicationRunner {
} }
}), trigger); }), trigger);
scheduledTasks.put(job.getId(), future); scheduledTasks.put(job.getId(), future);
log.info("[CronJob] Registered job {} ({}), cron={}, tz={}", job.getId(), job.getName(), log.info("[CronJob] Registered job {} ({}) ws={}, cron={}, tz={}",
job.getId(), job.getName(), job.getWorkspaceId(),
job.getCronExpression(), job.getTimezone()); job.getCronExpression(), job.getTimezone());
} finally { } finally {
schedulerLock.unlock(); schedulerLock.unlock();

View File

@ -81,7 +81,11 @@ public class CronJobTool {
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity. // reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
propagateChannelBinding(dto, origin); propagateChannelBinding(dto, origin);
CronJobDTO created = cronJobService.create(dto); // RFC-083: stamp workspace from the originating ChatOrigin so the
// cron job is created in the agent's current workspace; fall back
// to the default workspace when origin is unscoped (legacy paths).
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
CronJobDTO created = cronJobService.create(dto, workspaceId);
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
result.set("success", true); result.set("success", true);
@ -101,9 +105,12 @@ public class CronJobTool {
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. " @Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
+ "Returns task name, cron expression, next run time, enabled status, and last run time.") + "Returns task name, cron expression, next run time, enabled status, and last run time.")
public String list_cron_jobs() { public String list_cron_jobs(@Nullable ToolContext ctx) {
try { try {
List<CronJobDTO> jobs = cronJobService.list(); // RFC-083: scope to the originating workspace so an agent only
// sees the cron jobs of the workspace it's running in.
Long workspaceId = workspaceFromContext(ctx);
List<CronJobDTO> jobs = cronJobService.list(workspaceId);
JSONArray arr = new JSONArray(); JSONArray arr = new JSONArray();
for (CronJobDTO job : jobs) { for (CronJobDTO job : jobs) {
JSONObject obj = new JSONObject(); JSONObject obj = new JSONObject();
@ -132,10 +139,13 @@ public class CronJobTool {
+ "Use list_cron_jobs first to find the job ID.") + "Use list_cron_jobs first to find the job ID.")
public String toggle_cron_job( public String toggle_cron_job(
@ToolParam(description = "Job ID (number)") Long jobId, @ToolParam(description = "Job ID (number)") Long jobId,
@ToolParam(description = "true to enable, false to disable") Boolean enabled) { @ToolParam(description = "true to enable, false to disable") Boolean enabled,
@Nullable ToolContext ctx) {
try { try {
cronJobService.toggle(jobId, enabled); // RFC-083: scope toggle to the originating workspace.
CronJobDTO updated = cronJobService.getById(jobId); Long workspaceId = workspaceFromContext(ctx);
cronJobService.toggle(jobId, enabled, workspaceId);
CronJobDTO updated = cronJobService.getById(jobId, workspaceId);
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
result.set("success", true); result.set("success", true);
result.set("jobId", jobId); result.set("jobId", jobId);
@ -153,11 +163,14 @@ public class CronJobTool {
@Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. " @Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. "
+ "Use list_cron_jobs first to find the job ID.") + "Use list_cron_jobs first to find the job ID.")
public String delete_cron_job( public String delete_cron_job(
@ToolParam(description = "Job ID (number) to delete") Long jobId) { @ToolParam(description = "Job ID (number) to delete") Long jobId,
@Nullable ToolContext ctx) {
try { try {
CronJobDTO job = cronJobService.getById(jobId); // RFC-083: scope delete to the originating workspace.
Long workspaceId = workspaceFromContext(ctx);
CronJobDTO job = cronJobService.getById(jobId, workspaceId);
String jobName = job.getName(); String jobName = job.getName();
cronJobService.delete(jobId); cronJobService.delete(jobId, workspaceId);
JSONObject result = new JSONObject(); JSONObject result = new JSONObject();
result.set("success", true); result.set("success", true);
result.set("deleted", jobName); result.set("deleted", jobName);
@ -175,6 +188,17 @@ public class CronJobTool {
return JSONUtil.toJsonPrettyStr(result); return JSONUtil.toJsonPrettyStr(result);
} }
/**
* RFC-083: resolve the workspace ID from the originating ChatOrigin so
* cron-tool reads/writes are scoped to the agent's current workspace.
* Falls back to the default workspace (1) when origin is unscoped same
* behaviour as the controller-layer {@code resolve()} helper.
*/
private Long workspaceFromContext(@Nullable ToolContext ctx) {
ChatOrigin origin = ChatOrigin.from(ctx);
return origin != null && origin.workspaceId() != null ? origin.workspaceId() : 1L;
}
/** /**
* RFC-063r §2.4: propagate the originating channel binding into the cron * RFC-063r §2.4: propagate the originating channel binding into the cron
* job DTO so PR-3's delivery dispatcher can route results back to the * job DTO so PR-3's delivery dispatcher can route results back to the

View File

@ -0,0 +1,6 @@
-- RFC-083: workspace-isolate cron jobs
-- (issue: https://github.com/matevip/mateclaw/issues/37).
ALTER TABLE mate_cron_job ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1;
CREATE INDEX IF NOT EXISTS idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted);

View File

@ -0,0 +1,20 @@
-- RFC-083: workspace-isolate cron jobs
-- (issue: https://github.com/matevip/mateclaw/issues/37).
SET @col_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_cron_job'
AND COLUMN_NAME = 'workspace_id');
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_cron_job ADD COLUMN workspace_id BIGINT NOT NULL DEFAULT 1',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_cron_job'
AND INDEX_NAME = 'idx_cron_job_workspace');
SET @stmt := IF(@idx_exists = 0,
'CREATE INDEX idx_cron_job_workspace ON mate_cron_job(workspace_id, deleted)',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;