修复HTTP验证测试并按实际签发配置复核项目权限
This commit is contained in:
parent
16224fa1f5
commit
201a272562
25
backend/internal/certificate/issue_authorization.go
Normal file
25
backend/internal/certificate/issue_authorization.go
Normal 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)
|
||||
}
|
||||
36
backend/internal/certificate/issue_authorization_test.go
Normal file
36
backend/internal/certificate/issue_authorization_test.go
Normal 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("拒绝授权仍修改证书状态")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
105
backend/internal/httpapi/issue_snapshot_authorization_test.go
Normal file
105
backend/internal/httpapi/issue_snapshot_authorization_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,不重复第二台推送测试。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user