From 201a272562283ecdcfbc0579595c8fdfe9f6e262 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 4 Oct 2026 02:49:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DHTTP=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=B9=B6=E6=8C=89=E5=AE=9E=E9=99=85=E7=AD=BE?= =?UTF-8?q?=E5=8F=91=E9=85=8D=E7=BD=AE=E5=A4=8D=E6=A0=B8=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../certificate/issue_authorization.go | 25 +++++ .../certificate/issue_authorization_test.go | 36 ++++++ backend/internal/certificate/service.go | 3 + .../sqlite_resource_replacement_test.go | 5 + .../internal/httpapi/http_challenge_test.go | 2 +- .../issue_snapshot_authorization_test.go | 105 ++++++++++++++++++ backend/internal/httpapi/server.go | 6 +- backend/internal/httpapi/task_api.go | 3 + .../httpapi/task_execution_authorization.go | 28 +++++ doc/Scert整改任务执行清单.md | 10 ++ 10 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 backend/internal/certificate/issue_authorization.go create mode 100644 backend/internal/certificate/issue_authorization_test.go create mode 100644 backend/internal/httpapi/issue_snapshot_authorization_test.go diff --git a/backend/internal/certificate/issue_authorization.go b/backend/internal/certificate/issue_authorization.go new file mode 100644 index 0000000..6adbefb --- /dev/null +++ b/backend/internal/certificate/issue_authorization.go @@ -0,0 +1,25 @@ +package certificate + +import ( + "context" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +type issueAuthorizerContextKey struct{} + +// IssueAuthorizer 检查签发实际使用的配置快照,而不是先前读取的验证方式。 +type IssueAuthorizer func(context.Context, domain.Domain) error + +// WithIssueAuthorizer 为用户发起的工作流绑定实时授权;系统续期不使用用户会话。 +func WithIssueAuthorizer(ctx context.Context, authorize IssueAuthorizer) context.Context { + return context.WithValue(ctx, issueAuthorizerContextKey{}, authorize) +} + +func authorizeIssueSnapshot(ctx context.Context, item domain.Domain) error { + authorize, _ := ctx.Value(issueAuthorizerContextKey{}).(IssueAuthorizer) + if authorize == nil { + return nil + } + return authorize(ctx, item) +} diff --git a/backend/internal/certificate/issue_authorization_test.go b/backend/internal/certificate/issue_authorization_test.go new file mode 100644 index 0000000..df1be40 --- /dev/null +++ b/backend/internal/certificate/issue_authorization_test.go @@ -0,0 +1,36 @@ +package certificate + +import ( + "context" + "errors" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/httpchallenge" +) + +func TestServiceIssueAuthorizationBeforeSideEffects(t *testing.T) { + for _, kind := range []string{domain.ChallengeDNS01, domain.ChallengeHTTP01} { + t.Run(kind, func(t *testing.T) { + item := domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle, ChallengeType: kind} + repo := &serviceRepository{item: item} + service := NewService(certificateTestConfig(t.TempDir()), repo, nil, nil, serviceAccounts{}) + service.SetHTTPChallenges(httpchallenge.NewStore()) + denied := errors.New("拒绝当前签发配置") + calls := 0 + ctx := WithIssueAuthorizer(t.Context(), func(_ context.Context, actual domain.Domain) error { + calls++ + if actual.ID != item.ID || actual.ChallengeType != kind { + t.Fatal("授权未使用实际读取的配置") + } + return denied + }) + if _, err := service.Issue(ctx, item.ID); !errors.Is(err, denied) || calls != 1 { + t.Fatal("授权错误未传播或授权调用次数错误:", err, calls) + } + if repo.updateCalls != 0 { + t.Fatal("拒绝授权仍修改证书状态") + } + }) + } +} diff --git a/backend/internal/certificate/service.go b/backend/internal/certificate/service.go index 3efdc5d..3766d03 100644 --- a/backend/internal/certificate/service.go +++ b/backend/internal/certificate/service.go @@ -158,6 +158,9 @@ func (s *Service) Issue(ctx context.Context, domainID string) (IssueResult, erro if err != nil { return IssueResult{}, err } + if err := authorizeIssueSnapshot(ctx, item); err != nil { + return IssueResult{}, fmt.Errorf("签发配置权限校验失败:%w", err) + } providerID := "" var lease *httpchallenge.Lease if challengeType == domain.ChallengeHTTP01 { diff --git a/backend/internal/database/sqlite_resource_replacement_test.go b/backend/internal/database/sqlite_resource_replacement_test.go index d9c91fa..c60e3c6 100644 --- a/backend/internal/database/sqlite_resource_replacement_test.go +++ b/backend/internal/database/sqlite_resource_replacement_test.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "fmt" + "strings" "testing" ) @@ -67,6 +68,10 @@ func snapshotSQLiteReplacementState(t *testing.T, db *sql.DB) map[string]sqliteH result := snapshotSQLiteHistoricalBusiness(t, db) for _, table := range []string{"projects", "domains", "servers"} { query := "SELECT rowid, * FROM " + table + " ORDER BY id" + if table == "domains" { + // 固定迁移前已存在的列,新增字段不能被误判为历史数据遭到修改。 + query = strings.Replace(result[table].query, "SELECT ", "SELECT rowid, ", 1) + } if table == "servers" { query = "SELECT rowid, id, name, address, port, username, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json, created_at, updated_at, validation_command_json, password_ref, project_id, owner_id, created_by, updated_by FROM servers ORDER BY id" } diff --git a/backend/internal/httpapi/http_challenge_test.go b/backend/internal/httpapi/http_challenge_test.go index 6abc3ee..2c73099 100644 --- a/backend/internal/httpapi/http_challenge_test.go +++ b/backend/internal/httpapi/http_challenge_test.go @@ -103,7 +103,7 @@ func TestHTTPTaskAuthorizationRetainsProjectPermissions(t *testing.T) { if (calls == 1) != want { t.Fatalf("HTTP 签发项目授权错误:%d %s", w.Code, w.Body.String()) } - err := server.authorizeCertificateTaskExecution(t.Context(), repository.Task{DomainID: "domain-1", ProjectID: "project-a", ScopeType: repository.ScopeProject, OperatorID: "actor", TaskType: "certificate_issue"}, repository.TaskAttempt{}) + err := server.authorizeCertificateTaskExecution(t.Context(), repository.Task{DomainID: "domain-1", HistoryScope: repository.HistoryScope{ProjectID: "project-a", ScopeType: repository.ScopeProject}, OperatorID: "actor", TaskType: "certificate_issue"}, repository.TaskAttempt{}) if (err == nil) != want { t.Fatal("执行前授权与验证方式不匹配:", err) } diff --git a/backend/internal/httpapi/issue_snapshot_authorization_test.go b/backend/internal/httpapi/issue_snapshot_authorization_test.go new file mode 100644 index 0000000..066ad97 --- /dev/null +++ b/backend/internal/httpapi/issue_snapshot_authorization_test.go @@ -0,0 +1,105 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/certificate" + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/httpchallenge" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +var errSnapshotAccountReached = errors.New("测试已通过授权并到达账户阶段") + +type snapshotTestAccounts struct{} + +func (snapshotTestAccounts) LoadOrCreate(context.Context, string, string) (*certificate.AccountUser, error) { + return nil, errSnapshotAccountReached +} + +func (snapshotTestAccounts) Save(context.Context, string, *certificate.AccountUser) error { + return errors.New("本测试不应保存账户") +} + +// 在前置授权和实际签发读取之间确定性切换快照,不依赖不稳定的调度时序。 +func TestHTTPIssueReauthorizesActualSnapshot(t *testing.T) { + for _, async := range []bool{false, true} { + for _, change := range []string{"unchanged", "dns", "legacy_dns", "project", "domain", "certificate_revoked", "deployment_revoked"} { + t.Run(map[bool]string{false: "sync", true: "async"}[async]+"/"+change, func(t *testing.T) { + initial := domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle, ChallengeType: domain.ChallengeHTTP01, ResourceScope: domain.ResourceScope{ProjectID: "default"}} + visible := &stubDomainRepository{loaded: initial} + actual := &stubDomainRepository{loaded: initial} + var server *Server + var issueErr error + started := false + operation := func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + started = true + switch change { + case "dns": + actual.loaded.ChallengeType = domain.ChallengeDNS01 + case "legacy_dns": + actual.loaded.ChallengeType = "" + case "project": + actual.loaded.ProjectID = "another-project" + case "domain": + actual.loaded.ID = "another-domain" + } + service := certificate.NewService(server.config, actual, nil, nil, snapshotTestAccounts{}) + service.SetHTTPChallenges(httpchallenge.NewStore()) + result, err := service.Issue(ctx, id) + issueErr = err + return result, deployment.Report{}, err + } + if async { + var cleanup func() + server, _, _, cleanup = newTaskAPITestServer(t, operation) + defer cleanup() + } else { + server = New(Dependencies{Config: readyTestConfig(t.TempDir()), Repository: visible, IssueCertificate: operation}) + } + server.projectResources = projectResourceReaderStub{domains: visible} + server.projectMembers = permissionProjectMemberRepository{allow: func(permission domain.ProjectPermission) error { + if permission == domain.PermissionDNSCredentialUse || started && (change == "certificate_revoked" && permission == domain.PermissionCertificateManage || + change == "deployment_revoked" && permission == domain.PermissionDeploymentExecute) { + return repository.ErrProjectAccessDenied + } + return nil + }} + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if async { + submittedTask(t, response) + waitTaskWorkersForTest(t, server) + } + want := repository.ErrProjectAccessDenied + if change == "unchanged" { + want = errSnapshotAccountReached + } + if !started || !errors.Is(issueErr, want) { + t.Fatalf("实际签发配置未正确重新授权:started=%v err=%v,响应=%d %s", started, issueErr, response.Code, response.Body.String()) + } + }) + } + } +} + +func TestHTTPIssueSnapshotRetainsRetryPermission(t *testing.T) { + item := domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle, ChallengeType: domain.ChallengeHTTP01, ResourceScope: domain.ResourceScope{ProjectID: "default"}} + server := New(Dependencies{Config: readyTestConfig(t.TempDir()), ProjectMembers: permissionProjectMemberRepository{allow: func(permission domain.ProjectPermission) error { + if permission == domain.PermissionTaskRetry { + return repository.ErrProjectAccessDenied + } + return nil + }}}) + service := certificate.NewService(server.config, &stubDomainRepository{loaded: item}, nil, nil, snapshotTestAccounts{}) + service.SetHTTPChallenges(httpchallenge.NewStore()) + ctx := server.withCertificateIssueAuthorization(t.Context(), "actor", item.ID, "default", true) + if _, err := service.Issue(ctx, item.ID); !errors.Is(err, repository.ErrProjectAccessDenied) { + t.Fatal("重试授权未在实际签发快照处生效:", err) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 6744bbe..e1be0b2 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -778,7 +778,11 @@ func (s *Server) authorizeSynchronousCertificateIssue(c *gin.Context) bool { s.writeProjectResourceError(c, err) return false } - return s.authorizeIssueChallenge(c, resource) + if !s.authorizeIssueChallenge(c, resource) { + return false + } + c.Request = c.Request.WithContext(s.withCertificateIssueAuthorization(c.Request.Context(), c.GetString(authenticatedActorKey), resource.ID, resource.ResourceScope.Normalize().ProjectID, false)) + return true } func (s *Server) issueDomain(c *gin.Context) { diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 7520d8e..8e16304 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -273,6 +273,9 @@ func (s *Server) runCertificateOperation(ctx context.Context, task repository.Ta if s.finishCancelledTask(task, attempt, executionCtx) { return } + if task.TaskType == "certificate_issue" { + executionCtx = s.withCertificateIssueAuthorization(executionCtx, task.OperatorID, task.DomainID, task.ProjectID, attempt.AttemptNumber > 1) + } result, issueErr := operation(executionCtx, task.DomainID) certificateResult := result.Certificate var deploymentReport deployment.Report diff --git a/backend/internal/httpapi/task_execution_authorization.go b/backend/internal/httpapi/task_execution_authorization.go index c771912..34d854a 100644 --- a/backend/internal/httpapi/task_execution_authorization.go +++ b/backend/internal/httpapi/task_execution_authorization.go @@ -3,10 +3,38 @@ package httpapi import ( "context" + "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/domain" "git.pcux.cn/xuans/Scert/backend/internal/repository" ) +// 把操作者与项目绑定到实际签发快照,防止授权后修改验证方式绕过 DNS 权限。 +func (s *Server) withCertificateIssueAuthorization(ctx context.Context, actor, domainID, projectID string, retry bool) context.Context { + return certificate.WithIssueAuthorizer(ctx, func(ctx context.Context, item domain.Domain) error { + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok || actor == "" || projectID == "" || item.ID != domainID || item.ResourceScope.Normalize().ProjectID != projectID { + return repository.ErrProjectAccessDenied + } + kind, err := domain.NormalizeChallengeType(item.ChallengeType, append([]string{item.Name}, item.SANs...)) + if err != nil { + return err + } + permissions := []domain.ProjectPermission{domain.PermissionCertificateManage, domain.PermissionDeploymentExecute} + if kind == domain.ChallengeDNS01 { + permissions = append(permissions, domain.PermissionDNSCredentialUse) + } + if retry { + permissions = append(permissions, domain.PermissionTaskRetry) + } + for _, permission := range permissions { + if err := authorizer.AuthorizeProject(ctx, actor, projectID, permission); err != nil { + return err + } + } + return nil + }) +} + // 排队期间权限和资源归属可能变化;历史快照只用于审计,不作为授权凭证。 // 只检查本次即将执行的操作,后台自动续期不从此 HTTP 任务入口执行。 func (s *Server) authorizeCertificateTaskExecution(ctx context.Context, task repository.Task, attempt repository.TaskAttempt) error { diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index ec34052..0d932e0 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,15 @@ # Scert 整改任务执行清单 +## 2026年10月4日02:46 HTTP 验证回归修复任务 + +沿用已确认需求,修改前检查点 `16224fa` 已推送;第一台只运行隔离回归,不修改线上数据,不操作第二台。 + +- [x] 首批原生 Linux 回归已发现接口测试结构体编译错误和 SQLite 历史快照新增列误报;已修复,等待重跑,不隐藏失败。 +- [~] 同步及异步用户签发在实际配置快照上重新授权,覆盖 HTTP 切换 DNS、项目归属变化及重试权限;补充回归用例。 +- [x] 核对正式发布脚本:完整复制 nginx 目录,因此新增挑战片段会被打包;实际产物仍待检查。 +- [~] 完成 Linux 全量、静态检查、竞态和三数据库迁移,再更新主清单及正式部署记录。 +- [!] 公共入口切换、系统通知授权和首次 Web 全矩阵仍未通过,不将单测当作生产验收。 + ## 2026年10月4日 HTTP-01 开发任务 沿用已确认的 P1-03 要求,仅处理 Linux 与 Web。开发前检查点 `e829d66` 已推送;不修改公共 DNS,不重复第二台推送测试。