修复HTTP验证测试并按实际签发配置复核项目权限

This commit is contained in:
xuans 2026-10-04 02:49:49 +08:00
parent 16224fa1f5
commit 201a272562
10 changed files with 221 additions and 2 deletions

View File

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

View File

@ -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("拒绝授权仍修改证书状态")
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,不重复第二台推送测试。