补齐异步证书任务执行前权限复核及撤权回归

This commit is contained in:
xuans 2026-10-02 19:02:43 +08:00
parent 657a9c0a36
commit 09616e732d
7 changed files with 301 additions and 0 deletions

View File

@ -25,6 +25,12 @@
### 异步提交响应 ### 异步提交响应
执行槽取得后还会重新检查当前账户、域名项目归属及操作权限。排队后撤权、停用账户、删除/迁移域名或权限依赖异常时,不执行证书工作流;已接收任务最终为 failed、stage 为 authorize,消息为“任务执行前权限或资源归属校验未通过,未执行证书操作”。任务及尝试、失败审计统一收尾,不附带虚构的部署报告。
上述终态以未发生取消且事务收尾成功为前提;同时收到取消时沿用取消语义,数据库收尾失败则保留中断状态供启动恢复,不伪造失败持久化成功。
该失败通过后续任务查询显示,不改变已经返回的 202,也不要求前端自动重新提交。若操作者已失去读取权限,查询仍可能返回 403;不得据此显示成功。重试按本次重试者重新授权,不沿用首次提交者身份。此复核不等同于操作开始后撤权自动中止,正在执行的任务仍使用取消入口。
返回 202 和 Location: /api/v1/tasks/任务编号。以下仅为字段示例,编号不对应真实环境: 返回 202 和 Location: /api/v1/tasks/任务编号。以下仅为字段示例,编号不对应真实环境:
```json ```json

View File

@ -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 { executionCtx = certificate.WithProgressReporter(executionCtx, func(progressCtx context.Context, progressStage, progressMessage string) error {
return s.tasks.UpdateTask(progressCtx, task.ID, "running", progressStage, progressMessage, &now, nil) 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) _, deploymentReport, issueErr := operation(executionCtx, task.DomainID)
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second) finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel() defer finishCancel()

View File

@ -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
}

View File

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

View File

@ -1,5 +1,16 @@
# Linux 生产流程测试记录 # 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 后台原子收尾整改(未部署) ## 2026年10月2日18:35 后台原子收尾整改(未部署)
- [x] 上批 background-full.log 与 background-vet.log 已取回:BACKGROUND_FULL_EXIT=0、BACKGROUND_VET_EXIT=0。第二台既有服务 active,进程检查仅保留系统 MySQL;磁盘剩余约 1014MB,不新增数据库实例。 - [x] 上批 background-full.log 与 background-vet.log 已取回:BACKGROUND_FULL_EXIT=0、BACKGROUND_VET_EXIT=0。第二台既有服务 active,进程检查仅保留系统 MySQL;磁盘剩余约 1014MB,不新增数据库实例。

View File

@ -1,5 +1,17 @@
# Scert 整改任务执行清单 # 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 后台收尾一致性执行入口 ## 2026年10月2日18:35 后台收尾一致性执行入口
需求沿用已确认的 Linux 全清单,不处理 Windows,不缩减原始范围。先开发及自动回归,再统一正式部署;本批不清线上数据、不创建辅助验收脚本。 需求沿用已确认的 Linux 全清单,不处理 Windows,不缩减原始范围。先开发及自动回归,再统一正式部署;本批不清线上数据、不创建辅助验收脚本。

View File

@ -1,5 +1,13 @@
# Scert 整改、合并与优化清单 # 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 最新开发进度入口 ## 2026年10月2日18:35 最新开发进度入口
- [x] 后台取消修复及 MySQL 报告迁移回归基线 cf3bc7a 已提交推送。上一批全后端及 go vet 已确认通过;不等于本批代码或生产验收通过。 - [x] 后台取消修复及 MySQL 报告迁移回归基线 cf3bc7a 已提交推送。上一批全后端及 go vet 已确认通过;不等于本批代码或生产验收通过。