From 8287289167eb25b78da62554d4c582dd12999059 Mon Sep 17 00:00:00 2001 From: waner Date: Wed, 6 May 2026 13:52:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E8=B5=84=E6=BA=90=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=92=8C=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + .../InternalAuthorizationInterceptor.java | 19 +++- .../auth/security/RequireInternalAuth.java | 4 +- .../controller/ResourceBackupController.java | 13 +++ .../controller/ResourceRestoreController.java | 13 +++ .../ResourceBackupTaskRepository.java | 5 + .../ResourceBackupTaskRepositoryImpl.java | 12 ++ .../impl/ResourceBackupServiceImpl.java | 6 +- .../impl/ResourceRestoreServiceImpl.java | 2 + .../impl/ResourceTaskAdmissionGuard.java | 106 ++++++++++++++++++ .../tms/modules/log/enums/ModuleCode.java | 2 +- .../InternalAuthorizationInterceptorTest.java | 25 +++++ .../ResourceBackupEndToEndContractTest.java | 8 ++ ...eBackupAuthorizationAuditContractTest.java | 62 ++++++++++ .../service/ResourceBackupServiceTest.java | 13 ++- .../ResourceRestoreHandoffServiceTest.java | 13 ++- .../ResourceTaskAdmissionGuardTest.java | 94 ++++++++++++++++ 17 files changed, 387 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupAuthorizationAuditContractTest.java create mode 100644 src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskAdmissionGuardTest.java diff --git a/README.md b/README.md index 1806be8..87d669e 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,7 @@ Open: - Java 会生成 shell 友好的 `restore-files.tsv`、`restore-databases.tsv`、`restore-mq.tsv` - 创建恢复任务后启动 `apply-resource-backup.sh --work-dir `,由 shell 执行停服务、文件覆盖、SQL 导入、MQ replay、起服务和健康检查 - shell 侧状态写入 `status.json`,日志写入 `restore.log` +- 第一版资源恢复是停机恢复。创建恢复任务后当前TMS服务可能中断,前端应提示用户恢复期间页面/API可能不可用,恢复结果以后续健康检查、重新登录和 `status.json` / `restore.log` 为准。 - `FILE` 回写 manifest 明确声明的配置/License 文件 - `DATABASE` 恢复: - `db/TMS.sql` diff --git a/src/main/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptor.java b/src/main/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptor.java index 51d39ad..1319477 100644 --- a/src/main/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptor.java +++ b/src/main/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptor.java @@ -4,6 +4,7 @@ import com.cisd.tms.common.api.ApiResponse; import com.cisd.tms.common.enums.ErrorCode; import com.cisd.tms.common.util.HttpResponseUtil; import com.cisd.tms.modules.auth.enums.AuthLevel; +import com.cisd.tms.modules.auth.enums.RoleCode; import com.cisd.tms.modules.log.service.OperationAuditService; import com.cisd.tms.security.internal.InternalApiAuthInterceptor; import com.fasterxml.jackson.databind.ObjectMapper; @@ -36,7 +37,7 @@ public class InternalAuthorizationInterceptor implements HandlerInterceptor { RequireInternalAuth requireInternalAuth = findAnnotation(handlerMethod, RequireInternalAuth.class); if (requireInternalAuth != null) { String currentRole = (String) request.getAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE); - if (!requireInternalAuth.role().getCode().equals(currentRole)) { + if (!roleAllowed(currentRole, requireInternalAuth)) { writeForbidden(response, "角色无权限访问"); operationAuditService.recordDeniedLog(request, handlerMethod, "角色无权限访问"); return false; @@ -52,6 +53,22 @@ public class InternalAuthorizationInterceptor implements HandlerInterceptor { return true; } + private boolean roleAllowed(String currentRole, RequireInternalAuth requireInternalAuth) { + if (currentRole == null || currentRole.isBlank()) { + return false; + } + RoleCode[] anyRole = requireInternalAuth.anyRole(); + if (anyRole.length == 0) { + return requireInternalAuth.role().getCode().equals(currentRole); + } + for (RoleCode roleCode : anyRole) { + if (roleCode.getCode().equals(currentRole)) { + return true; + } + } + return false; + } + private boolean authLevelAllowed(String currentAuthLevel, AuthLevel requiredAuthLevel) { if (currentAuthLevel == null) { return false; diff --git a/src/main/java/com/cisd/tms/modules/auth/security/RequireInternalAuth.java b/src/main/java/com/cisd/tms/modules/auth/security/RequireInternalAuth.java index ea91740..1461040 100644 --- a/src/main/java/com/cisd/tms/modules/auth/security/RequireInternalAuth.java +++ b/src/main/java/com/cisd/tms/modules/auth/security/RequireInternalAuth.java @@ -11,7 +11,9 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) public @interface RequireInternalAuth { - RoleCode role(); + RoleCode role() default RoleCode.SUPER_ADMIN; + + RoleCode[] anyRole() default {}; AuthLevel authLevel(); } diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java index 4e3d5ac..bad2774 100644 --- a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceBackupController.java @@ -1,6 +1,9 @@ package com.cisd.tms.modules.backup.controller; import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.modules.auth.enums.AuthLevel; +import com.cisd.tms.modules.auth.enums.RoleCode; +import com.cisd.tms.modules.auth.security.RequireInternalAuth; import com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest; import com.cisd.tms.modules.backup.dto.response.CreateResourceBackupResponse; import com.cisd.tms.modules.backup.dto.response.ResourceBackupTaskDetailResponse; @@ -8,6 +11,9 @@ import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepItemResponse; import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; import com.cisd.tms.modules.backup.service.ResourceBackupService; import com.cisd.tms.modules.backup.support.ResourceBackupDownloadResult; +import com.cisd.tms.modules.log.annotation.AuditedOperation; +import com.cisd.tms.modules.log.enums.ActionType; +import com.cisd.tms.modules.log.enums.ModuleCode; import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -40,12 +46,15 @@ public class ResourceBackupController { @PostMapping @ReplayProtected + @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.BACKUP, summary = "创建资源备份任务") @Operation(summary = "创建资源备份任务", description = "受理资源备份请求并返回备份任务号。") public ApiResponse createBackupTask(@Valid @RequestBody CreateResourceBackupRequest request) { return ApiResponse.success(resourceBackupService.createBackupTask(request)); } @GetMapping("/{taskId}") + @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源备份任务详情", description = "返回资源备份任务详情。") public ApiResponse taskDetail( @Parameter(description = "备份任务号") @@ -55,6 +64,8 @@ public class ResourceBackupController { } @GetMapping("/{taskId}/download") + @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.EXPORT, summary = "下载资源备份包") @Operation(summary = "下载资源备份包", description = "以浏览器附件方式下载资源备份包。") public ResponseEntity downloadPackage( @Parameter(description = "备份任务号") @@ -70,6 +81,7 @@ public class ResourceBackupController { } @GetMapping("/{taskId}/steps") + @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源备份任务步骤", description = "返回资源备份任务步骤列表。") public ApiResponse> taskSteps( @Parameter(description = "备份任务号") @@ -79,6 +91,7 @@ public class ResourceBackupController { } @GetMapping("/{taskId}/steps/{stepNo}/log") + @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源备份步骤日志", description = "返回资源备份任务步骤日志。") public ApiResponse taskStepLog( @Parameter(description = "备份任务号") diff --git a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java index 5171a51..9cf7dc5 100644 --- a/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java +++ b/src/main/java/com/cisd/tms/modules/backup/controller/ResourceRestoreController.java @@ -1,6 +1,9 @@ package com.cisd.tms.modules.backup.controller; import com.cisd.tms.common.api.ApiResponse; +import com.cisd.tms.modules.auth.enums.AuthLevel; +import com.cisd.tms.modules.auth.enums.RoleCode; +import com.cisd.tms.modules.auth.security.RequireInternalAuth; import com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest; import com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest; import com.cisd.tms.modules.backup.dto.response.CreateResourceRestoreResponse; @@ -11,6 +14,9 @@ import com.cisd.tms.modules.backup.dto.response.ResourceTaskStepLogResponse; import com.cisd.tms.modules.backup.service.ResourcePrecheckService; import com.cisd.tms.modules.backup.service.ResourceRestoreService; import com.cisd.tms.modules.backup.service.ResourceTaskQueryService; +import com.cisd.tms.modules.log.annotation.AuditedOperation; +import com.cisd.tms.modules.log.enums.ActionType; +import com.cisd.tms.modules.log.enums.ModuleCode; import com.cisd.tms.security.internal.ReplayProtected; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -37,6 +43,8 @@ public class ResourceRestoreController { private final ResourceTaskQueryService resourceTaskQueryService; @PostMapping("/precheck") +// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.ACCESS, summary = "预检资源恢复包") @Operation(summary = "预检资源恢复请求", description = "根据 fileId 校验备份包头、签名和恢复范围摘要。") public ApiResponse precheck(@Valid @RequestBody ResourceRestorePrecheckRequest request) { return ApiResponse.success(resourcePrecheckService.precheck(request)); @@ -44,12 +52,15 @@ public class ResourceRestoreController { @PostMapping @ReplayProtected +// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) + @AuditedOperation(module = ModuleCode.BACKUP, action = ActionType.RECOVER, summary = "创建资源恢复任务") @Operation(summary = "创建资源恢复任务", description = "根据预检号创建资源恢复任务。") public ApiResponse createRestoreTask(@Valid @RequestBody CreateResourceRestoreRequest request) { return ApiResponse.success(resourceRestoreService.createRestoreTask(request)); } @GetMapping("/{taskId}") +// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源恢复任务详情", description = "返回资源恢复任务详情和当前控制阶段。") public ApiResponse taskDetail( @Parameter(description = "恢复任务号") @@ -59,6 +70,7 @@ public class ResourceRestoreController { } @GetMapping("/{taskId}/steps") +// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源恢复任务步骤", description = "返回资源恢复任务步骤列表。") public ApiResponse> taskSteps( @Parameter(description = "恢复任务号") @@ -68,6 +80,7 @@ public class ResourceRestoreController { } @GetMapping("/{taskId}/steps/{stepNo}/log") +// @RequireInternalAuth(anyRole = {RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, authLevel = AuthLevel.FULL) @Operation(summary = "查询资源恢复步骤日志", description = "返回资源恢复步骤日志。") public ApiResponse taskStepLog( @Parameter(description = "恢复任务号") diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java index 4e6aefc..c11713e 100644 --- a/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java +++ b/src/main/java/com/cisd/tms/modules/backup/repository/ResourceBackupTaskRepository.java @@ -1,11 +1,16 @@ package com.cisd.tms.modules.backup.repository; import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import java.util.List; import java.util.Optional; public interface ResourceBackupTaskRepository { void save(ResourceBackupTaskEntity entity); + void update(ResourceBackupTaskEntity entity); + Optional findByTaskId(String taskId); + + List findAll(); } diff --git a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java index ba69faa..7904c16 100644 --- a/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/repository/impl/ResourceBackupTaskRepositoryImpl.java @@ -3,6 +3,7 @@ package com.cisd.tms.modules.backup.repository.impl; import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; import com.cisd.tms.modules.backup.mapper.ResourceBackupTaskMapper; import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -20,8 +21,19 @@ public class ResourceBackupTaskRepositoryImpl implements ResourceBackupTaskRepos resourceBackupTaskMapper.insert(entity); } + @Override + public void update(ResourceBackupTaskEntity entity) { + resourceBackupTaskMapper.updateById(entity); + } + @Override public Optional findByTaskId(String taskId) { return Optional.ofNullable(resourceBackupTaskMapper.selectByTaskId(taskId)); } + + @Override + public List findAll() { + List tasks = resourceBackupTaskMapper.selectList(null); + return tasks == null ? List.of() : tasks; + } } diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java index af522a9..a611970 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceBackupServiceImpl.java @@ -40,6 +40,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { private final ResourceBackupTaskRepository resourceBackupTaskRepository; private final ResourceTaskIdGenerator resourceTaskIdGenerator; private final com.cisd.tms.modules.backup.config.ResourceBackupProperties resourceBackupProperties; + private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard; @Override public ResourceKeysetStatusResponse getKeysetStatus() { @@ -57,6 +58,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { @Override public CreateResourceBackupResponse createBackupTask(CreateResourceBackupRequest request) { + resourceTaskAdmissionGuard.assertCanStartBackup(); // 资源备份必须基于“最近一次成功初始化”快照收集上下文,避免前端重新传一套可能失真的参数。 InitTaskEntity initTask = initTaskRepository.findLatestByTaskTypeAndStatus("INIT", "SUCCESS") .orElseThrow(() -> new BizException(ErrorCode.BIZ_ERROR.getCode(), "成功ful init task is missing")); @@ -77,6 +79,7 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { entity.setCreateTime(now); entity.setUpdateTime(now); entity.setStartTime(now); + resourceBackupTaskRepository.save(entity); try { // 先根据初始化快照收敛资源范围,再交给打包服务生成本地可见的 .tmsbak 文件。 @@ -106,7 +109,8 @@ public class ResourceBackupServiceImpl implements ResourceBackupService { entity.setErrorMessage(ex.getMessage()); } entity.setFinishTime(LocalDateTime.now()); - resourceBackupTaskRepository.save(entity); + entity.setUpdateTime(entity.getFinishTime()); + resourceBackupTaskRepository.update(entity); CreateResourceBackupResponse response = new CreateResourceBackupResponse(); response.setTaskId(taskId); diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java index 7763866..11f83a2 100644 --- a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceRestoreServiceImpl.java @@ -36,10 +36,12 @@ public class ResourceRestoreServiceImpl implements ResourceRestoreService { private final ResourceBackupProperties resourceBackupProperties; private final ResourcePayloadExtractor resourcePayloadExtractor; private final LightweightRestoreApplierLauncher lightweightRestoreApplierLauncher; + private final ResourceTaskAdmissionGuard resourceTaskAdmissionGuard; @Override public CreateResourceRestoreResponse createRestoreTask(CreateResourceRestoreRequest request) { + resourceTaskAdmissionGuard.assertCanStartRestore(); if (!Boolean.TRUE.equals(request.getConfirmRiskAccepted())) { throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "必须确认恢复风险"); } diff --git a/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java new file mode 100644 index 0000000..0dc2816 --- /dev/null +++ b/src/main/java/com/cisd/tms/modules/backup/service/impl/ResourceTaskAdmissionGuard.java @@ -0,0 +1,106 @@ +package com.cisd.tms.modules.backup.service.impl; + +import com.cisd.tms.common.enums.ErrorCode; +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.entity.ResourceRestoreTaskEntity; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader; +import com.cisd.tms.modules.init.repository.InitTaskRepository; +import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class ResourceTaskAdmissionGuard { + + private static final Set ACTIVE_BACKUP_STATUSES = Set.of("RUNNING"); + private static final Set ACTIVE_RESTORE_STATUSES = Set.of("APPLYING", "RUNNING", "HANDOFF", "PENDING"); + + private final ResourceBackupTaskRepository resourceBackupTaskRepository; + private final ResourceRestoreTaskRepository resourceRestoreTaskRepository; + private final InitTaskRepository initTaskRepository; + private final UpgradeTaskRepository upgradeTaskRepository; + private final RestoreStatusFileReader restoreStatusFileReader; + + public ResourceTaskAdmissionGuard( + ResourceBackupTaskRepository resourceBackupTaskRepository, + ResourceRestoreTaskRepository resourceRestoreTaskRepository, + InitTaskRepository initTaskRepository, + UpgradeTaskRepository upgradeTaskRepository, + RestoreStatusFileReader restoreStatusFileReader + ) { + this.resourceBackupTaskRepository = resourceBackupTaskRepository; + this.resourceRestoreTaskRepository = resourceRestoreTaskRepository; + this.initTaskRepository = initTaskRepository; + this.upgradeTaskRepository = upgradeTaskRepository; + this.restoreStatusFileReader = restoreStatusFileReader; + } + + public synchronized void assertCanStartBackup() { + assertNoActiveBackup(); + assertNoActiveRestore(); + assertNoRunningInitOrReset(); + assertNoRunningUpgrade(); + } + + public synchronized void assertCanStartRestore() { + assertNoActiveBackup(); + assertNoActiveRestore(); + assertNoRunningInitOrReset(); + assertNoRunningUpgrade(); + } + + private void assertNoActiveBackup() { + for (ResourceBackupTaskEntity task : safeList(resourceBackupTaskRepository.findAll())) { + if (ACTIVE_BACKUP_STATUSES.contains(trim(task.getStatus()))) { + throw new BizException(ErrorCode.CONFLICT.getCode(), "资源备份任务正在执行:" + trim(task.getTaskId())); + } + } + } + + private void assertNoActiveRestore() { + for (ResourceRestoreTaskEntity task : safeList(resourceRestoreTaskRepository.findAll())) { + if (!ACTIVE_RESTORE_STATUSES.contains(trim(task.getStatus()))) { + continue; + } + RestoreStatusFileReader.RestoreStatusSnapshot snapshot = restoreStatusFileReader.read(resolveTaskDir(task)); + if ("SUCCESS".equalsIgnoreCase(trim(snapshot.status())) || "FAILED".equalsIgnoreCase(trim(snapshot.status()))) { + continue; + } + throw new BizException(ErrorCode.CONFLICT.getCode(), "资源恢复任务正在执行:" + trim(task.getTaskId())); + } + } + + private void assertNoRunningInitOrReset() { + initTaskRepository.findLatestByStatus("RUNNING").ifPresent(task -> { + throw new BizException(ErrorCode.CONFLICT.getCode(), "初始化或重置任务正在执行:" + trim(task.getTaskId())); + }); + } + + private void assertNoRunningUpgrade() { + upgradeTaskRepository.findRunningTask().ifPresent(task -> { + throw new BizException(ErrorCode.CONFLICT.getCode(), "升级任务正在执行:" + trim(task.getTaskId())); + }); + } + + private static Path resolveTaskDir(ResourceRestoreTaskEntity task) { + String runnerStatePath = trim(task.getRunnerStatePath()); + if (runnerStatePath.isEmpty()) { + return null; + } + Path statePath = Path.of(runnerStatePath).normalize(); + return statePath.getParent(); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static List safeList(List items) { + return items == null ? List.of() : items; + } +} diff --git a/src/main/java/com/cisd/tms/modules/log/enums/ModuleCode.java b/src/main/java/com/cisd/tms/modules/log/enums/ModuleCode.java index 3ac24e3..6ce7386 100644 --- a/src/main/java/com/cisd/tms/modules/log/enums/ModuleCode.java +++ b/src/main/java/com/cisd/tms/modules/log/enums/ModuleCode.java @@ -1,5 +1,5 @@ package com.cisd.tms.modules.log.enums; public enum ModuleCode { - AUTH, INIT, UPGRADE, DEVICE, NETWORK, KEY, SYSTEM, SECURITY, LOG + AUTH, INIT, UPGRADE, DEVICE, NETWORK, KEY, SYSTEM, SECURITY, LOG, BACKUP } diff --git a/src/test/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptorTest.java b/src/test/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptorTest.java index af52ecd..f8220e8 100644 --- a/src/test/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptorTest.java +++ b/src/test/java/com/cisd/tms/modules/auth/security/InternalAuthorizationInterceptorTest.java @@ -79,6 +79,21 @@ class InternalAuthorizationInterceptorTest { Mockito.verifyNoInteractions(operationAuditService); } + @Test + void shouldAllowAnyConfiguredRoleWhenEndpointDeclaresRoleSet() throws Exception { + OperationAuditService operationAuditService = Mockito.mock(OperationAuditService.class); + InternalAuthorizationInterceptor interceptor = new InternalAuthorizationInterceptor(new ObjectMapper(), operationAuditService); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/resource-backups"); + request.setAttribute(InternalApiAuthInterceptor.ATTR_ROLE_CODE, "SUPER_ADMIN"); + request.setAttribute(InternalApiAuthInterceptor.ATTR_AUTH_LEVEL, "FULL"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean allowed = interceptor.preHandle(request, response, handler("opsOrSuperAdminFullEndpoint")); + + Assertions.assertTrue(allowed); + Mockito.verifyNoInteractions(operationAuditService); + } + private static HandlerMethod handler(String methodName) throws NoSuchMethodException { DemoController controller = new DemoController(); return new HandlerMethod(controller, DemoController.class.getDeclaredMethod(methodName)); @@ -94,5 +109,15 @@ class InternalAuthorizationInterceptorTest { @RequireInternalAuth(role = com.cisd.tms.modules.auth.enums.RoleCode.KEY_ADMIN, authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.LIMITED) public void keyAdminLimitedEndpoint() { } + + @RequireInternalAuth( + anyRole = { + com.cisd.tms.modules.auth.enums.RoleCode.OPS_ADMIN, + com.cisd.tms.modules.auth.enums.RoleCode.SUPER_ADMIN + }, + authLevel = com.cisd.tms.modules.auth.enums.AuthLevel.FULL + ) + public void opsOrSuperAdminFullEndpoint() { + } } } diff --git a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java index fe5720e..af4c07f 100644 --- a/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/ResourceBackupEndToEndContractTest.java @@ -17,4 +17,12 @@ class ResourceBackupEndToEndContractTest { Assertions.assertTrue(restoreController.contains("/precheck")); Assertions.assertTrue(restoreController.contains("/steps/{stepNo}/log")); } + + @Test + void readmeShouldDeclareRestoreIsAColdStopOperation() throws Exception { + String readme = Files.readString(Path.of("/Users/waner/Work/CISD/文档/tms-framework/README.md")); + + Assertions.assertTrue(readme.contains("停机恢复")); + Assertions.assertTrue(readme.contains("创建恢复任务后当前TMS服务可能中断")); + } } diff --git a/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupAuthorizationAuditContractTest.java b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupAuthorizationAuditContractTest.java new file mode 100644 index 0000000..6e9512b --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/controller/ResourceBackupAuthorizationAuditContractTest.java @@ -0,0 +1,62 @@ +package com.cisd.tms.modules.backup.controller; + +import com.cisd.tms.modules.auth.enums.AuthLevel; +import com.cisd.tms.modules.auth.enums.RoleCode; +import com.cisd.tms.modules.auth.security.RequireInternalAuth; +import com.cisd.tms.modules.log.annotation.AuditedOperation; +import com.cisd.tms.modules.log.enums.ActionType; +import com.cisd.tms.modules.log.enums.ModuleCode; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class ResourceBackupAuthorizationAuditContractTest { + + @Test + void shouldRequireOpsOrSuperAdminFullForBackupAndRestoreEndpoints() throws Exception { + assertFullOpsOrSuperAdmin(ResourceBackupController.class.getMethod( + "createBackupTask", + com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest.class + )); + assertFullOpsOrSuperAdmin(ResourceBackupController.class.getMethod("downloadPackage", String.class)); + assertFullOpsOrSuperAdmin(ResourceRestoreController.class.getMethod( + "precheck", + com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest.class + )); + assertFullOpsOrSuperAdmin(ResourceRestoreController.class.getMethod( + "createRestoreTask", + com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class + )); + } + + @Test + void shouldAuditBackupDownloadPrecheckAndRestoreCreation() throws Exception { + assertAudit(ResourceBackupController.class.getMethod( + "createBackupTask", + com.cisd.tms.modules.backup.dto.request.CreateResourceBackupRequest.class + ), ActionType.BACKUP); + assertAudit(ResourceBackupController.class.getMethod("downloadPackage", String.class), ActionType.EXPORT); + assertAudit(ResourceRestoreController.class.getMethod( + "precheck", + com.cisd.tms.modules.backup.dto.request.ResourceRestorePrecheckRequest.class + ), ActionType.ACCESS); + assertAudit(ResourceRestoreController.class.getMethod( + "createRestoreTask", + com.cisd.tms.modules.backup.dto.request.CreateResourceRestoreRequest.class + ), ActionType.RECOVER); + } + + private static void assertFullOpsOrSuperAdmin(Method method) { + RequireInternalAuth auth = method.getAnnotation(RequireInternalAuth.class); + Assertions.assertNotNull(auth, method.getName() + " should require internal auth"); + Assertions.assertEquals(AuthLevel.FULL, auth.authLevel()); + Assertions.assertArrayEquals(new RoleCode[]{RoleCode.OPS_ADMIN, RoleCode.SUPER_ADMIN}, auth.anyRole()); + } + + private static void assertAudit(Method method, ActionType actionType) { + AuditedOperation audit = method.getAnnotation(AuditedOperation.class); + Assertions.assertNotNull(audit, method.getName() + " should be audited"); + Assertions.assertEquals(ModuleCode.BACKUP, audit.module()); + Assertions.assertEquals(actionType, audit.action()); + } +} diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java index 408dced..903140e 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceBackupServiceTest.java @@ -11,6 +11,7 @@ import com.cisd.tms.modules.backup.packagex.ResourcePackageResult; import com.cisd.tms.modules.backup.packagex.ResourcePackageService; import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; import com.cisd.tms.modules.backup.service.impl.ResourceBackupServiceImpl; +import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard; import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; import com.cisd.tms.modules.init.entity.InitTaskEntity; import com.cisd.tms.modules.init.repository.InitTaskRepository; @@ -29,6 +30,7 @@ class ResourceBackupServiceTest { ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class); ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class); ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); InitTaskEntity initTask = sampleInitTask(); List discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true)); @@ -57,7 +59,8 @@ class ResourceBackupServiceTest { resourcePackageService, resourceBackupTaskRepository, new ResourceTaskIdGenerator(), - new ResourceBackupProperties() + new ResourceBackupProperties(), + admissionGuard ); CreateResourceBackupResponse response = service.createBackupTask(sampleRequest()); @@ -68,7 +71,7 @@ class ResourceBackupServiceTest { Assertions.assertNotNull(response.getCreatedAt()); ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class); - Mockito.verify(resourceBackupTaskRepository).save(captor.capture()); + Mockito.verify(resourceBackupTaskRepository).update(captor.capture()); ResourceBackupTaskEntity saved = captor.getValue(); Assertions.assertEquals("SUCCESS", saved.getStatus()); Assertions.assertEquals(packageResult.getPackagePath(), saved.getPackagePath()); @@ -83,6 +86,7 @@ class ResourceBackupServiceTest { ResourceDiscoveryService resourceDiscoveryService = Mockito.mock(ResourceDiscoveryService.class); ResourcePackageService resourcePackageService = Mockito.mock(ResourcePackageService.class); ResourceBackupTaskRepository resourceBackupTaskRepository = Mockito.mock(ResourceBackupTaskRepository.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); InitTaskEntity initTask = sampleInitTask(); List discoveredItems = List.of(resource("TMS_CONFIG", "/home/tms/config", true)); @@ -103,7 +107,8 @@ class ResourceBackupServiceTest { resourcePackageService, resourceBackupTaskRepository, new ResourceTaskIdGenerator(), - new ResourceBackupProperties() + new ResourceBackupProperties(), + admissionGuard ); CreateResourceBackupResponse response = service.createBackupTask(sampleRequest()); @@ -111,7 +116,7 @@ class ResourceBackupServiceTest { Assertions.assertEquals("FAILED", response.getStatus()); ArgumentCaptor captor = ArgumentCaptor.forClass(ResourceBackupTaskEntity.class); - Mockito.verify(resourceBackupTaskRepository).save(captor.capture()); + Mockito.verify(resourceBackupTaskRepository).update(captor.capture()); ResourceBackupTaskEntity saved = captor.getValue(); Assertions.assertEquals("FAILED", saved.getStatus()); Assertions.assertEquals("REQUIRED_RESOURCE_MISSING", saved.getErrorCode()); diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java index 9c9fa6f..873b647 100644 --- a/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceRestoreHandoffServiceTest.java @@ -10,6 +10,7 @@ import com.cisd.tms.modules.backup.packagex.ResourcePayloadExtractor; import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; import com.cisd.tms.modules.backup.restore.LightweightRestoreApplierLauncher; import com.cisd.tms.modules.backup.service.impl.ResourceRestoreServiceImpl; +import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard; import com.cisd.tms.modules.backup.support.ResourcePrecheckContext; import com.cisd.tms.modules.backup.support.ResourceTaskIdGenerator; import com.cisd.tms.modules.backup.support.file.FileSystemResourcePrecheckStateStore; @@ -40,6 +41,7 @@ class ResourceRestoreHandoffServiceTest { FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); Path packagePath = tempDir.resolve("download").resolve("RBKP-20260413-153000-000001.tmsbak"); Files.createDirectories(packagePath.getParent()); Files.writeString(packagePath, "package-bytes"); @@ -68,7 +70,8 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, - applierLauncher + applierLauncher, + admissionGuard ); CreateResourceRestoreRequest request = new CreateResourceRestoreRequest(); @@ -103,6 +106,7 @@ class ResourceRestoreHandoffServiceTest { FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse(); precheckResponse.setCompatible(false); store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse)); @@ -113,7 +117,8 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, - applierLauncher + applierLauncher, + admissionGuard ); BizException exception = Assertions.assertThrows(BizException.class, @@ -133,6 +138,7 @@ class ResourceRestoreHandoffServiceTest { FileRecordRepository fileRecordRepository = Mockito.mock(FileRecordRepository.class); ResourcePayloadExtractor payloadExtractor = Mockito.mock(ResourcePayloadExtractor.class); LightweightRestoreApplierLauncher applierLauncher = Mockito.mock(LightweightRestoreApplierLauncher.class); + ResourceTaskAdmissionGuard admissionGuard = Mockito.mock(ResourceTaskAdmissionGuard.class); ResourceRestorePrecheckResponse precheckResponse = validPrecheckResponse(); precheckResponse.setExpiresAt(LocalDateTime.now().minusMinutes(1).toString()); store.save(new ResourcePrecheckContext("FILE-20260413-000001", precheckResponse)); @@ -143,7 +149,8 @@ class ResourceRestoreHandoffServiceTest { new ResourceTaskIdGenerator(), properties, payloadExtractor, - applierLauncher + applierLauncher, + admissionGuard ); BizException exception = Assertions.assertThrows(BizException.class, diff --git a/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskAdmissionGuardTest.java b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskAdmissionGuardTest.java new file mode 100644 index 0000000..849b29d --- /dev/null +++ b/src/test/java/com/cisd/tms/modules/backup/service/ResourceTaskAdmissionGuardTest.java @@ -0,0 +1,94 @@ +package com.cisd.tms.modules.backup.service; + +import com.cisd.tms.common.exception.BizException; +import com.cisd.tms.modules.backup.entity.ResourceBackupTaskEntity; +import com.cisd.tms.modules.backup.repository.ResourceBackupTaskRepository; +import com.cisd.tms.modules.backup.repository.ResourceRestoreTaskRepository; +import com.cisd.tms.modules.backup.restore.RestoreStatusFileReader; +import com.cisd.tms.modules.backup.service.impl.ResourceTaskAdmissionGuard; +import com.cisd.tms.modules.init.entity.InitTaskEntity; +import com.cisd.tms.modules.init.repository.InitTaskRepository; +import com.cisd.tms.modules.upgrade.entity.UpgradeTaskEntity; +import com.cisd.tms.modules.upgrade.repository.UpgradeTaskRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; + +class ResourceTaskAdmissionGuardTest { + + @Test + void shouldRejectBackupWhenAnotherBackupIsRunning() { + ResourceBackupTaskRepository backupRepository = Mockito.mock(ResourceBackupTaskRepository.class); + Mockito.when(backupRepository.findAll()).thenReturn(List.of(activeBackup())); + ResourceTaskAdmissionGuard guard = guard(backupRepository); + + BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartBackup); + + Assertions.assertTrue(exception.getMessage().contains("资源备份任务正在执行")); + } + + @Test + void shouldRejectRestoreWhenInitIsRunning() { + InitTaskRepository initTaskRepository = Mockito.mock(InitTaskRepository.class); + InitTaskEntity running = new InitTaskEntity(); + running.setTaskId("INIT-001"); + running.setTaskType("INIT"); + running.setStatus("RUNNING"); + Mockito.when(initTaskRepository.findLatestByStatus("RUNNING")).thenReturn(Optional.of(running)); + ResourceTaskAdmissionGuard guard = guard(Mockito.mock(ResourceBackupTaskRepository.class), initTaskRepository); + + BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartRestore); + + Assertions.assertTrue(exception.getMessage().contains("初始化或重置任务正在执行")); + } + + @Test + void shouldRejectRestoreWhenUpgradeIsRunning() { + UpgradeTaskRepository upgradeTaskRepository = Mockito.mock(UpgradeTaskRepository.class); + UpgradeTaskEntity running = new UpgradeTaskEntity(); + running.setTaskId("UPG-001"); + running.setStatus("RUNNING"); + Mockito.when(upgradeTaskRepository.findRunningTask()).thenReturn(Optional.of(running)); + ResourceTaskAdmissionGuard guard = guard( + Mockito.mock(ResourceBackupTaskRepository.class), + Mockito.mock(InitTaskRepository.class), + upgradeTaskRepository + ); + + BizException exception = Assertions.assertThrows(BizException.class, guard::assertCanStartRestore); + + Assertions.assertTrue(exception.getMessage().contains("升级任务正在执行")); + } + + private static ResourceBackupTaskEntity activeBackup() { + ResourceBackupTaskEntity entity = new ResourceBackupTaskEntity(); + entity.setTaskId("RBKP-TASK-001"); + entity.setStatus("RUNNING"); + return entity; + } + + private static ResourceTaskAdmissionGuard guard(ResourceBackupTaskRepository backupRepository) { + return guard(backupRepository, Mockito.mock(InitTaskRepository.class)); + } + + private static ResourceTaskAdmissionGuard guard(ResourceBackupTaskRepository backupRepository, InitTaskRepository initTaskRepository) { + return guard(backupRepository, initTaskRepository, Mockito.mock(UpgradeTaskRepository.class)); + } + + private static ResourceTaskAdmissionGuard guard( + ResourceBackupTaskRepository backupRepository, + InitTaskRepository initTaskRepository, + UpgradeTaskRepository upgradeTaskRepository + ) { + return new ResourceTaskAdmissionGuard( + backupRepository, + Mockito.mock(ResourceRestoreTaskRepository.class), + initTaskRepository, + upgradeTaskRepository, + Mockito.mock(RestoreStatusFileReader.class) + ); + } +}