From 09616e732dc24f5cc35bf26caf76714c8393ff74 Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 19:02:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=BC=82=E6=AD=A5=E8=AF=81?= =?UTF-8?q?=E4=B9=A6=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C=E5=89=8D=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=A4=8D=E6=A0=B8=E5=8F=8A=E6=92=A4=E6=9D=83=E5=9B=9E?= =?UTF-8?q?=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/docs/ai/async-tasks/api-handoff.md | 6 + backend/internal/httpapi/task_api.go | 11 + .../httpapi/task_execution_authorization.go | 42 ++++ .../task_execution_authorization_test.go | 211 ++++++++++++++++++ doc/Linux生产流程测试记录.md | 11 + doc/Scert整改任务执行清单.md | 12 + doc/Scert整改合并优化清单.md | 8 + 7 files changed, 301 insertions(+) create mode 100644 backend/internal/httpapi/task_execution_authorization.go create mode 100644 backend/internal/httpapi/task_execution_authorization_test.go diff --git a/.claude/docs/ai/async-tasks/api-handoff.md b/.claude/docs/ai/async-tasks/api-handoff.md index a1ddba6..af12293 100644 --- a/.claude/docs/ai/async-tasks/api-handoff.md +++ b/.claude/docs/ai/async-tasks/api-handoff.md @@ -25,6 +25,12 @@ ### 异步提交响应 +执行槽取得后还会重新检查当前账户、域名项目归属及操作权限。排队后撤权、停用账户、删除/迁移域名或权限依赖异常时,不执行证书工作流;已接收任务最终为 failed、stage 为 authorize,消息为“任务执行前权限或资源归属校验未通过,未执行证书操作”。任务及尝试、失败审计统一收尾,不附带虚构的部署报告。 + +上述终态以未发生取消且事务收尾成功为前提;同时收到取消时沿用取消语义,数据库收尾失败则保留中断状态供启动恢复,不伪造失败持久化成功。 + +该失败通过后续任务查询显示,不改变已经返回的 202,也不要求前端自动重新提交。若操作者已失去读取权限,查询仍可能返回 403;不得据此显示成功。重试按本次重试者重新授权,不沿用首次提交者身份。此复核不等同于操作开始后撤权自动中止,正在执行的任务仍使用取消入口。 + 返回 202 和 Location: /api/v1/tasks/任务编号。以下仅为字段示例,编号不对应真实环境: ```json diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 2996103..d8feb48 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -203,6 +203,17 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a executionCtx = certificate.WithProgressReporter(executionCtx, func(progressCtx context.Context, progressStage, progressMessage string) error { return s.tasks.UpdateTask(progressCtx, task.ID, "running", progressStage, progressMessage, &now, nil) }) + if err := s.authorizeCertificateTaskExecution(executionCtx, task, attempt); err != nil { + if s.finishCancelledTask(task, attempt, executionCtx) { + return + } + attempt.Stage = "authorize" + s.finishTaskBeforeExecution(task, attempt, "failed", "任务执行前权限或资源归属校验未通过,未执行证书操作") + return + } + if s.finishCancelledTask(task, attempt, executionCtx) { + return + } _, deploymentReport, issueErr := operation(executionCtx, task.DomainID) finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second) defer finishCancel() diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go new file mode 100644 index 0000000..d4027ac --- /dev/null +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -0,0 +1,42 @@ +package httpapi + +import ( + "context" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +// 排队期间权限和资源归属可能变化;历史快照只用于审计,不作为授权凭证。 +// 只检查本次即将执行的操作,后台自动续期不从此 HTTP 任务入口执行。 +func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task repository.Task, attempt repository.TaskAttempt) error { + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok || s.projectResources == nil || task.OperatorID == "" || task.ScopeType != repository.ScopeProject || task.ProjectID == "" { + return repository.ErrProjectAccessDenied + } + resource, err := s.projectResources.GetVisibleDomain(ctx, task.OperatorID, task.DomainID) + if err != nil { + return err + } + if resource.ID != task.DomainID || resource.ResourceScope.Normalize().ProjectID != task.ProjectID { + return repository.ErrProjectAccessDenied + } + permissions := []domain.ProjectPermission{domain.PermissionDeploymentExecute} + switch task.TaskType { + case "certificate_issue": + permissions = append(permissions, domain.PermissionCertificateManage, domain.PermissionDNSCredentialUse) + case "certificate_deploy": + default: + return repository.ErrProjectAccessDenied + } + if attempt.AttemptNumber > 1 { + // 重试仓储已将操作者更新为本次重试者,不能沿用首次提交者权限。 + permissions = append(permissions, domain.PermissionTaskRetry, domain.PermissionCertificateManage) + } + for _, permission := range permissions { + if err := authorizer.AuthorizeProject(ctx, task.OperatorID, task.ProjectID, permission); err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/httpapi/task_execution_authorization_test.go b/backend/internal/httpapi/task_execution_authorization_test.go new file mode 100644 index 0000000..98319f7 --- /dev/null +++ b/backend/internal/httpapi/task_execution_authorization_test.go @@ -0,0 +1,211 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestAsyncExecutionRechecksQueuedAuthorization(t *testing.T) { + for _, taskType := range []string{"certificate_issue", "certificate_deploy"} { + for _, scenario := range []string{ + "unchanged", "disabled_user", "removed_member", "read_only_member", + "moved_domain", "deleted_domain", "deployment_denied", "certificate_denied", + "dns_denied", "authorization_error", "missing_authorizer", "missing_reader", + } { + t.Run(taskType+"/"+scenario, func(t *testing.T) { + var calls atomic.Int32 + operation := func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + calls.Add(1) + return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil + } + server, tasks, audit, cleanup := newTaskAPITestServer(t, operation) + defer cleanup() + server.deployCertificateTask = operation + server.asyncTasks = make(map[string]bool) + server.taskSlots = make(chan struct{}, 1) + server.taskSlots <- struct{}{} + path := "/api/v1/domains/domain-1/issue" + if taskType == "certificate_deploy" { + path = "/api/v1/domains/domain-1/deploy" + } + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, path, nil)) + task := submittedTask(t, response) + if calls.Load() != 0 { + t.Fatal("占满执行槽后任务仍执行") + } + db, err := database.Open(t.Context(), server.config.Database) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var statements []string + switch scenario { + case "disabled_user": + statements = []string{"UPDATE users SET status = 'disabled' WHERE id = 'test-admin'"} + case "removed_member": + statements = []string{"DELETE FROM project_members WHERE user_id = 'test-admin'"} + case "read_only_member": + statements = []string{"UPDATE project_members SET role = 'read_only' WHERE user_id = 'test-admin'"} + case "moved_domain": + // 保留两项目权限,确保拒绝来自归属变化而非不可见。 + statements = []string{ + "INSERT INTO projects (id, name, description) VALUES ('moved', '迁移后的项目', '')", + "INSERT INTO project_members (project_id, user_id, role) VALUES ('moved', 'test-admin', 'project_admin')", + "UPDATE domains SET project_id = 'moved' WHERE id = 'domain-1'", + } + case "deleted_domain": + statements = []string{"DELETE FROM domains WHERE id = 'domain-1'"} + case "missing_authorizer": + server.projectMembers = nil + case "missing_reader": + server.projectResources = nil + case "deployment_denied", "certificate_denied", "dns_denied", "authorization_error": + server.projectMembers = permissionProjectMemberRepository{ + ProjectMemberRepository: server.projectMembers, + allow: func(permission domain.ProjectPermission) error { + if scenario == "authorization_error" { + return errors.New("private-authorization-detail") + } + if scenario == "deployment_denied" && permission == domain.PermissionDeploymentExecute || + scenario == "certificate_denied" && permission == domain.PermissionCertificateManage || + scenario == "dns_denied" && permission == domain.PermissionDNSCredentialUse { + return repository.ErrProjectAccessDenied + } + return nil + }, + } + } + for _, statement := range statements { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + <-server.taskSlots + waitTaskWorkersForTest(t, server) + want := "failed" + if scenario == "unchanged" || taskType == "certificate_deploy" && (scenario == "dns_denied" || scenario == "certificate_denied") { + want = "success" + } + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != want { + t.Fatalf("执行前权限变化未正确处理:%+v %v,外部调用 %d", stored, err, calls.Load()) + } + attempts, err := tasks.ListAttempts(t.Context(), task.ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != want { + t.Fatalf("尝试状态不一致:%+v %v", attempts, err) + } + if want == "success" { + if calls.Load() != 1 { + t.Fatal("正常授权必须执行一次") + } + return + } + if calls.Load() != 0 || stored.Stage != "authorize" || strings.Contains(stored.Message, "private-") { + t.Fatalf("拒绝授权仍发生副作用或暴露内部错误:%+v,调用 %d", stored, calls.Load()) + } + logs, err := audit.ListAuditLogs(t.Context(), 20) + if err != nil { + t.Fatal(err) + } + completed := 0 + for _, log := range logs { + if log.Action == taskType && log.Status == "error" { + completed++ + } + } + if completed != 1 { + t.Fatalf("拒绝执行必须保留一次失败审计:%+v", logs) + } + }) + } + } +} + +func TestAsyncRetryRechecksCurrentOperatorAuthorization(t *testing.T) { + for _, scenario := range []string{"unchanged", "disabled_retry_operator", "retry_denied", "certificate_denied"} { + t.Run(scenario, func(t *testing.T) { + var calls atomic.Int32 + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + if calls.Add(1) == 1 { + return certificate.IssueResult{}, deployment.Report{}, errors.New("首次申请失败") + } + return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil + }) + defer cleanup() + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + task := submittedTask(t, response) + waitTaskWorkersForTest(t, server) + db, err := database.Open(t.Context(), server.config.Database) + if err != nil { + t.Fatal(err) + } + defer db.Close() + for _, statement := range []string{ + "INSERT INTO users (id, username, password_hash, role, status) VALUES ('retry-admin', 'retry-admin', 'test-only-no-login', 'admin', 'active')", + "INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'retry-admin', 'project_admin')", + "UPDATE users SET status = 'disabled' WHERE id = 'test-admin'", + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + server.taskSlots <- struct{}{} + server.taskSlots <- struct{}{} + server.taskSlots <- struct{}{} + server.taskSlots <- struct{}{} + response = httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+task.ID+"/retry", nil) + request.AddCookie(httpUserSessionCookie(server, "retry-admin", repository.UserRoleAdmin)) + server.Handler().ServeHTTP(response, request) + submittedTask(t, response) + if scenario == "disabled_retry_operator" { + if _, err := db.DB.ExecContext(t.Context(), "UPDATE users SET status = 'disabled' WHERE id = 'retry-admin'"); err != nil { + t.Fatal(err) + } + } else if scenario != "unchanged" { + server.projectMembers = permissionProjectMemberRepository{ + ProjectMemberRepository: server.projectMembers, + allow: func(permission domain.ProjectPermission) error { + if scenario == "retry_denied" && permission == domain.PermissionTaskRetry || + scenario == "certificate_denied" && permission == domain.PermissionCertificateManage { + return repository.ErrProjectAccessDenied + } + return nil + }, + } + } + <-server.taskSlots + waitTaskWorkersForTest(t, server) + want, wantCalls := "failed", int32(1) + if scenario == "unchanged" { + want, wantCalls = "success", 2 + } + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.OperatorID != "retry-admin" || stored.Status != want || calls.Load() != wantCalls { + t.Fatalf("重试未使用本次操作者实时权限:%+v %v,调用 %d", stored, err, calls.Load()) + } + attempts, err := tasks.ListAttempts(t.Context(), task.ID) + if err != nil || len(attempts) != 2 { + t.Fatalf("重试记录缺失:%+v %v", attempts, err) + } + for _, attempt := range attempts { + if attempt.AttemptNumber == 2 && attempt.Status != want { + t.Fatalf("重试终态不一致:%+v", attempt) + } + } + }) + } +} diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 19577ff..307eeaf 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,16 @@ # Linux 生产流程测试记录 +## 2026年10月2日19:01 排队任务实时授权回归(未部署) + +- [x] 修改前基线 657a9c0 已推送。第二台既有 SSH 会话已实时复核,测试目录为 /var/tmp/scert-async-20261002-1556/source/backend,磁盘剩余约 961MB;未清理或连接线上业务数据库。 +- [x] 新测试文件两端 SHA256 为 18047f955d7509f9a1258aaffd1f5df92f5ba5e64b98ddc082f8b56aa30fb8de。authorization-before.log 中旧代码在排队后权限变化时仍执行一次外部操作,AUTHORIZATION_BEFORE_EXIT=1,耗时 3.498 秒。 +- [x] 修复及扩展重试测试归档 authorization-fix-20261002.tar,两端核验 SHA256 为 acb218c45242e08d2637fb7df397778daea7da8eb4671a1665c28e8ebf3603f4;仅展开至隔离源码目录,不替换线上程序。 +- [x] authorization-after.log:二十四个申请/部署场景及四个重试场景通过,AUTHORIZATION_AFTER_EXIT=0,耗时 3.476 秒;拒绝时外部调用为零,任务与尝试终态一致且保留失败审计;错误详情未进入任务消息。 +- [x] authorization-full.log 和 authorization-vet.log:本批原生 Linux 全后端与静态检查通过,AUTHORIZATION_FULL_EXIT=0、AUTHORIZATION_VET_EXIT=0。采用 nobody、单并行、GOMEMLIMIT=384MiB,不提权执行测试;未启用外部数据库的全量测试不算三库实库验收。 +- [x] authorization-repeat.log:新增两组专项重复二十次通过,AUTHORIZATION_REPEAT_EXIT=0,耗时 74.602 秒;旧代码失败子场景实数为二十。既有 Scert、Nginx、MySQL 均 active;磁盘剩余约 920MB,不新增数据库实例或无限制竞争检测。 +- [!] 仅证明 HTTP 异步工作线程开始执行前的实时复核,不代表自动续期链路、操作开始后撤权强制中止或全部权限竞态均已验收;本批未部署,三库首次 Web、竞争检测及总体生产门禁仍未完成。 + + ## 2026年10月2日18:35 后台原子收尾整改(未部署) - [x] 上批 background-full.log 与 background-vet.log 已取回:BACKGROUND_FULL_EXIT=0、BACKGROUND_VET_EXIT=0。第二台既有服务 active,进程检查仅保留系统 MySQL;磁盘剩余约 1014MB,不新增数据库实例。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 963bc82..04836d9 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,17 @@ # Scert 整改任务执行清单 +## 2026年10月2日19:01 排队任务执行前权限复核 + +需求沿用已确认的 Linux 全清单,先开发及自动回归,再统一部署;不缩减范围、不处理 Windows。本批修改前已推送基线 657a9c0,工作区干净。 + +- [x] P0-07/P2-03 子项:异步任务取得执行槽后,重新核查当前账户、域名归属及操作权限;不把提交时权限或历史项目快照作为执行授权。校验失败不调用签发或部署工作流,以事务保存失败任务、尝试及审计。 +- [x] 旧代码负向回归复现排队期间撤权后仍执行外部操作;新增覆盖停用账户、撤销成员/操作权限、移动或删除域名、授权服务故障及缺失依赖。 +- [x] 手动重试使用本次操作者和实时重试权限;正常申请/部署、首次操作者停用但有效新操作者重试均保留。修复后两组共二十八个子场景通过,AUTHORIZATION_AFTER_EXIT=0。 +- [x] 原生 Linux 全后端回归与静态检查通过,AUTHORIZATION_FULL_EXIT=0、AUTHORIZATION_VET_EXIT=0;新增专项重复二十次通过,耗时 74.602 秒。普通重复测试不替代竞争检测。 +- [x] 清单、Linux 测试记录及接口说明已同步;本批代码保存提交,不含运行产物。本批未部署,未取得竞争检测或生产验收通过结论。 +- [ ] 后续继续导入、恢复、校验异步化及上传文件生命周期;升级、通知重置、三库、备份恢复及整体门禁仍保持未关闭。 + + ## 2026年10月2日18:35 后台收尾一致性执行入口 需求沿用已确认的 Linux 全清单,不处理 Windows,不缩减原始范围。先开发及自动回归,再统一正式部署;本批不清线上数据、不创建辅助验收脚本。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 8b8ac2b..5a64553 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,13 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日19:01 排队执行前权限开发进度 + +- [x] P0-07/P2-03 子项:确认并修复异步申请、部署及重试只有提交授权、缺少执行前复核的问题。执行槽取得后读取当前账户和域名归属,复核本次操作权限;归属变化即拒绝,不因仍可访问新项目而沿用旧任务。 +- [x] 二十八个排队及重试子场景通过,包括撤权、停用、资源变化、依赖故障及正常执行;首次申请需要 DNS/证书/部署权限,已有证书首次部署不误加 DNS 或证书管理权限,重试沿用完整重试权限契约。 +- [x] 旧代码二十个负向子场景复现撤权后仍执行;修复后原生 Linux 专项、全后端及静态检查通过,新增专项重复二十次通过。证据见执行清单及 Linux 记录,普通重复回归不替代竞争检测。 +- [!] 本批未部署,不代表 P0-07、P0-10、P2-03 整项或生产门禁关闭。其他长操作异步化、升级、通知重置、数据库与备份恢复继续保留;不处理 Windows。 + + ## 2026年10月2日18:35 最新开发进度入口 - [x] 后台取消修复及 MySQL 报告迁移回归基线 cf3bc7a 已提交推送。上一批全后端及 go vet 已确认通过;不等于本批代码或生产验收通过。