开发检查点:完成项目域名权限与事务审计接线

提交 P0-07 当前开发检查点,包含项目域名事务仓储、HTTP 路由接线、生产应用依赖及测试夹具调整。
This commit is contained in:
xuans 2026-09-29 16:27:36 +08:00
parent ef6365591b
commit 5180c7d73f
11 changed files with 395 additions and 36 deletions

View File

@ -160,6 +160,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectResources: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectBindings: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectDomains: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {

View File

@ -194,7 +194,7 @@ func TestAliyunDomainAPIRejectsProtectedSANWithoutSideEffects(t *testing.T) {
checker := &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}}
repo := &stubDomainRepository{}
server := New(Dependencies{
Config: cfg, Repository: repo, Secrets: store, AliyunChecker: checker,
Config: cfg, Repository: repo, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}, Secrets: store, AliyunChecker: checker,
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["pcux.cn"]}`))

View File

@ -24,8 +24,13 @@ func TestDomainResourceScopeAPI(t *testing.T) {
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
t.Fatal(err)
}
for _, userID := range []string{"creator", "editor"} {
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO users (id, username, password_hash, role, status) VALUES (?, ?, 'test-only-no-login', 'super_admin', 'active')`, userID, userID); err != nil {
t.Fatal(err)
}
}
repo := repository.NewSQLDomainRepository(db.DB)
server := New(Dependencies{Config: cfg, Repository: repo})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectDomains: repository.NewSQLProjectMemberRepository(db.DB)})
request := func(method, path, body, user string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")

View File

@ -118,7 +118,7 @@ func TestHTTPServerUsesProductionTimeoutsAndTLSMinimum(t *testing.T) {
func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{}
server := New(Dependencies{Config: cfg, Repository: repo, ProjectResources: projectResourceReaderStub{domains: repo}})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectResources: projectResourceReaderStub{domains: repo}, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}})
invalid := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["bad_domain"]}`))
invalid.Header.Set("Content-Type", "application/json")
@ -150,7 +150,7 @@ func TestDomainAPIValidatesSANAndHidesInternalErrors(t *testing.T) {
func TestDeleteMissingDomainReturnsNotFound(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{deleteErr: sql.ErrNoRows}
server := New(Dependencies{Config: cfg, Repository: repo})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}})
response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/domains/missing", nil))
if response.Code != http.StatusNotFound || strings.Contains(response.Body.String(), "detail") {
@ -162,7 +162,7 @@ func TestUpdateDomainAPI(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
loaded := domain.Domain{ID: "domain-1", Name: "example.com", CertificateType: domain.CertificateSingle, SANs: []string{"example.com"}, Status: "active"}
repo := &stubDomainRepository{loaded: loaded}
server := New(Dependencies{Config: cfg, Repository: repo})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}})
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-1", strings.NewReader(`{"name":"example.com","certificate_type":"san","sans":["www.example.com"],"auto_renew":false,"acme_environment":"production","dns_provider":"cloudflare"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
@ -178,7 +178,7 @@ func TestUpdateDomainAPI(t *testing.T) {
func TestCreateDomainConflictReturnsConflict(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
repo := &stubDomainRepository{createErr: errors.New("UNIQUE constraint failed: domains.name")}
server := New(Dependencies{Config: cfg, Repository: repo})
server := New(Dependencies{Config: cfg, Repository: repo, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}})
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"example.com","certificate_type":"single"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
@ -191,7 +191,8 @@ func TestCreateDomainConflictReturnsConflict(t *testing.T) {
func TestUpdateDomainRejectsInvalidEnvironment(t *testing.T) {
cfg := readyTestConfig(t.TempDir())
loaded := domain.Domain{ID: "domain-1", Name: "example.com"}
server := New(Dependencies{Config: cfg, Repository: &stubDomainRepository{loaded: loaded}})
repo := &stubDomainRepository{loaded: loaded}
server := New(Dependencies{Config: cfg, Repository: repo, ProjectDomains: projectDomainRepositoryStub{DomainRepository: repo}})
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-1", strings.NewReader(`{"name":"example.com","certificate_type":"single","acme_environment":"invalid"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()

View File

@ -4,7 +4,6 @@ import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
"errors"
"fmt"
"net"
@ -53,6 +52,7 @@ type Dependencies struct {
ProjectLifecycle repository.ProjectLifecycleRepository
ProjectResources repository.ProjectResourceReader
ProjectBindings repository.ProjectBindingRepository
ProjectDomains repository.ProjectDomainRepository
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
@ -89,6 +89,7 @@ type Server struct {
projectLifecycle repository.ProjectLifecycleRepository
projectResources repository.ProjectResourceReader
projectBindings repository.ProjectBindingRepository
projectDomains repository.ProjectDomainRepository
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
@ -154,6 +155,7 @@ func New(deps Dependencies) *Server {
projectLifecycle: deps.ProjectLifecycle,
projectResources: deps.ProjectResources,
projectBindings: deps.ProjectBindings,
projectDomains: deps.ProjectDomains,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
@ -206,9 +208,10 @@ func (s *Server) registerRoutes() {
s.registerProjectResourceRoutes()
s.registerProjectBindingRoutes()
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
adminAPI.DELETE("/domains/:id", s.deleteDomain)
domainAPI := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
domainAPI.POST("/domains", s.dnsOperation, s.createDomain)
domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
domainAPI.DELETE("/domains/:id", s.deleteDomain)
adminAPI.POST("/domains/:id/issue", s.longOperation(certificateTaskTimeout), s.issueDomain)
adminAPI.GET("/tasks", s.listTasks)
adminAPI.GET("/tasks/:id", s.getTask)
@ -524,6 +527,31 @@ func (s *Server) requireDomainRepository(c *gin.Context) bool {
return false
}
func (s *Server) requireProjectDomainRepository(c *gin.Context) bool {
if s.projectDomains != nil {
return true
}
s.writeError(c, http.StatusServiceUnavailable, "项目域名服务尚未就绪", nil)
return false
}
func (s *Server) authorizeDomainMutation(c *gin.Context, projectID string) bool {
authorizer, ok := s.projectDomains.(repository.ProjectAuthorizer)
if !ok {
s.writeError(c, http.StatusServiceUnavailable, "项目权限服务尚未就绪", nil)
return false
}
if err := authorizer.AuthorizeProject(c.Request.Context(), c.GetString(authenticatedActorKey), projectID, domain.PermissionCertificateManage); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
} else {
s.writeError(c, http.StatusInternalServerError, "检查项目域名权限失败", err)
}
return false
}
return true
}
func (s *Server) listDomains(c *gin.Context) {
if !s.requireProjectResourceReader(c) {
return
@ -537,7 +565,7 @@ func (s *Server) listDomains(c *gin.Context) {
}
func (s *Server) createDomain(c *gin.Context) {
if !s.requireDomainRepository(c) {
if !s.requireProjectDomainRepository(c) {
return
}
var request domainRequest
@ -550,12 +578,19 @@ func (s *Server) createDomain(c *gin.Context) {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
return
}
item.ResourceScope = s.newResourceScope(c)
if !s.authorizeDomainMutation(c, item.ProjectID) {
return
}
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
s.writeError(c, http.StatusBadRequest, err.Error(), err)
return
}
item.ResourceScope = s.newResourceScope(c)
if err := s.repository.Create(c.Request.Context(), item); err != nil {
if err := s.projectDomains.CreateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if repository.IsConflict(err) {
s.writeError(c, http.StatusConflict, "域名已经存在", err)
return
@ -567,7 +602,7 @@ func (s *Server) createDomain(c *gin.Context) {
}
func (s *Server) updateDomain(c *gin.Context) {
if !s.requireDomainRepository(c) {
if !s.requireProjectDomainRepository(c) || !s.requireDomainRepository(c) {
return
}
var request domainRequest
@ -584,6 +619,9 @@ func (s *Server) updateDomain(c *gin.Context) {
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
return
}
if !s.authorizeDomainMutation(c, existing.ProjectID) {
return
}
item, err := newDomainFromRequest(request)
if err != nil {
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
@ -598,7 +636,15 @@ func (s *Server) updateDomain(c *gin.Context) {
item.CreatedAt = existing.CreatedAt
item.Status = "pending"
item.ExpiresAt = nil
if err := s.repository.Update(c.Request.Context(), item); err != nil {
if err := s.projectDomains.UpdateProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), item); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if errors.Is(err, deployment.ErrSharedPathConflict) {
s.writeError(c, http.StatusConflict, deployment.ErrSharedPathConflict.Error(), nil)
return
}
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
@ -626,24 +672,19 @@ func newDomainFromRequest(request domainRequest) (domain.Domain, error) {
}
func (s *Server) deleteDomain(c *gin.Context) {
if !s.requireDomainRepository(c) {
if !s.requireProjectDomainRepository(c) {
return
}
domainID := strings.TrimSpace(c.Param("id"))
if s.tasks != nil {
for _, taskType := range []string{"certificate_issue", "certificate_validate", "certificate_deploy", "certificate_import", "certificate_restore"} {
_, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), domainID, taskType)
if err == nil {
s.writeError(c, http.StatusConflict, "域名存在正在执行的证书任务,请等待任务完成或取消后再删除", nil)
return
}
if !errors.Is(err, sql.ErrNoRows) {
s.writeError(c, http.StatusInternalServerError, "检查域名任务状态失败", err)
return
}
if err := s.projectDomains.DeleteProjectDomain(c.Request.Context(), c.GetString(authenticatedActorKey), domainID); err != nil {
if errors.Is(err, repository.ErrProjectAccessDenied) {
s.writeError(c, http.StatusForbidden, "无权管理此项目的域名", nil)
return
}
if errors.Is(err, repository.ErrProjectDomainBusy) {
s.writeError(c, http.StatusConflict, err.Error(), nil)
return
}
}
if err := s.repository.Delete(c.Request.Context(), domainID); err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return

View File

@ -315,9 +315,13 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
Users: repository.NewSQLUserRepository(db.DB),
ProjectResources: repository.NewSQLProjectMemberRepository(db.DB),
ProjectBindings: repository.NewSQLProjectMemberRepository(db.DB),
ProjectDomains: repository.NewSQLProjectMemberRepository(db.DB),
})
// 此旧格式用例同样使用真实账户与项目成员,不能依赖全局管理员绕过绑定权限。
_ = httpUserSessionCookie(server, "test-admin", repository.UserRoleAdmin)
if _, err := db.DB.ExecContext(t.Context(), `INSERT OR IGNORE INTO users (id, username, password_hash, role, status) VALUES ('test-admin', 'test-admin', 'test-only-no-login', 'admin', 'active')`); err != nil {
t.Fatal(err)
}
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'test-admin', 'project_admin')`); err != nil {
t.Fatal(err)
}
@ -700,6 +704,9 @@ func TestDeleteDomainRejectsActiveCertificateTask(t *testing.T) {
if err := database.Migrate(context.Background(), db.DB, "sqlite"); err != nil {
t.Fatalf("数据库迁移失败:%v", err)
}
if _, err := db.DB.ExecContext(context.Background(), `INSERT OR IGNORE INTO users (id, username, password_hash, role, status) VALUES ('test-admin', 'test-admin', 'test-only-no-login', 'super_admin', 'active')`); err != nil {
t.Fatalf("创建测试账户失败:%v", err)
}
domainRepo := repository.NewSQLDomainRepository(db.DB)
taskRepo := repository.NewSQLTaskRepository(db.DB)
@ -707,6 +714,7 @@ func TestDeleteDomainRejectsActiveCertificateTask(t *testing.T) {
Config: cfg,
Repository: domainRepo,
Tasks: taskRepo,
ProjectDomains: repository.NewSQLProjectMemberRepository(db.DB),
})
createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{"name":"active-task.example.com","certificate_type":"single"}`))

View File

@ -12,6 +12,28 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
// projectDomainRepositoryStub 仅用于保留旧 HTTP 参数校验测试的存根行为;生产路由始终注入真实项目域名仓储。
type projectDomainRepositoryStub struct {
repository.DomainRepository
authorizeErr error
}
func (r projectDomainRepositoryStub) AuthorizeProject(context.Context, string, string, domain.ProjectPermission) error {
return r.authorizeErr
}
func (r projectDomainRepositoryStub) CreateProjectDomain(ctx context.Context, actorID string, item domain.Domain) error {
return r.DomainRepository.Create(ctx, item)
}
func (r projectDomainRepositoryStub) UpdateProjectDomain(ctx context.Context, actorID string, item domain.Domain) error {
return r.DomainRepository.Update(ctx, item)
}
func (r projectDomainRepositoryStub) DeleteProjectDomain(ctx context.Context, actorID, id string) error {
return r.DomainRepository.Delete(ctx, id)
}
// 仅供旧的响应格式与错误脱敏单元测试显式注入;权限验证使用真实 SQL 资源夹具。
// 不在产品构造器或通用请求帮助函数中自动补齐,缺失依赖测试必须保持拒绝。
type projectResourceReaderStub struct {

View File

@ -15,3 +15,10 @@ type DomainRepository interface {
Delete(ctx context.Context, id string) error
UpdateCertificateState(ctx context.Context, id, status string, expiresAt *time.Time) error
}
// ProjectDomainRepository 在项目边界内执行域名资料变更,并将成功审计与业务写入放在同一事务。
type ProjectDomainRepository interface {
CreateProjectDomain(context.Context, string, domain.Domain) error
UpdateProjectDomain(context.Context, string, domain.Domain) error
DeleteProjectDomain(context.Context, string, string) error
}

View File

@ -150,6 +150,12 @@ func projectBindingReadError(err error) error {
// 使用事务内的当前读取并持有账户、项目和现有成员行锁,避免撤权或停用后继续写入。
// SQLite 使用自身事务串行写入保证;遇到并发升级失败时回滚,不绕过授权重试。
func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Context, tx *sql.Tx, actorID, projectID string) error {
return r.authorizeProjectTransaction(ctx, tx, actorID, projectID,
domain.PermissionCertificateRead, domain.PermissionServerRead, domain.PermissionDeploymentExecute)
}
// authorizeProjectTransaction 在同一事务内锁定账户、项目和成员关系,保证判权不会在业务写入前被撤回。
func (r *SQLProjectMemberRepository) authorizeProjectTransaction(ctx context.Context, tx *sql.Tx, actorID, projectID string, required ...domain.ProjectPermission) error {
var status, userRole, existingProject string
if err := tx.QueryRowContext(ctx, Rebind(r.driver,
`SELECT status, role FROM users WHERE id = ?`+r.resourceWriteLock()), actorID).Scan(&status, &userRole); err != nil {
@ -171,10 +177,12 @@ func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Con
return fmt.Errorf("读取绑定操作权限失败:%w", err)
}
defer rows.Close()
permissions := map[domain.ProjectPermission]bool{
domain.PermissionCertificateRead: false,
domain.PermissionServerRead: false,
domain.PermissionDeploymentExecute: false,
permissions := make(map[domain.ProjectPermission]bool, len(required))
for _, permission := range required {
if !permission.Valid() {
return ErrProjectAccessDenied
}
permissions[permission] = false
}
for rows.Next() {
var role domain.ProjectRole
@ -188,8 +196,8 @@ func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Con
if err := rows.Err(); err != nil {
return err
}
for _, allowed := range permissions {
if !allowed {
for _, permission := range required {
if !permissions[permission] {
return ErrProjectAccessDenied
}
}

View File

@ -0,0 +1,180 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
var ErrProjectDomainBusy = errors.New("域名存在正在执行的任务,暂不能删除")
var _ ProjectDomainRepository = (*SQLProjectMemberRepository)(nil)
func (r *SQLProjectMemberRepository) CreateProjectDomain(ctx context.Context, actorID string, item domain.Domain) error {
if err := r.ensureDB(); err != nil {
return err
}
item.ResourceScope = item.ResourceScope.Normalize()
payload, err := json.Marshal(item.SANs)
if err != nil {
return fmt.Errorf("编码域名 SAN 失败:%w", err)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开始域名创建事务失败:%w", err)
}
defer tx.Rollback()
if err := r.authorizeProjectTransaction(ctx, tx, actorID, item.ProjectID, domain.PermissionCertificateManage); err != nil {
return err
}
_, err = tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO domains
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt,
item.ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy)
if err != nil {
return fmt.Errorf("保存域名失败:%w", err)
}
if err := writeDomainAudit(ctx, tx, r.driver, "domain.create", item.ID, item.ProjectID, actorID, "创建域名"); err != nil {
return err
}
return commitProjectDomain(tx, "提交域名创建事务失败")
}
func (r *SQLProjectMemberRepository) UpdateProjectDomain(ctx context.Context, actorID string, item domain.Domain) error {
if err := r.ensureDB(); err != nil {
return err
}
payload, err := json.Marshal(item.SANs)
if err != nil {
return fmt.Errorf("编码域名 SAN 失败:%w", err)
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开始域名更新事务失败:%w", err)
}
defer tx.Rollback()
var stored domain.Domain
var certificateType string
if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT id, name, certificate_type, project_id FROM domains WHERE id = ?`+r.resourceWriteLock()), item.ID).
Scan(&stored.ID, &stored.Name, &certificateType, &stored.ProjectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return fmt.Errorf("读取待更新域名失败:%w", err)
}
stored.CertificateType = domain.CertificateType(certificateType)
if err := r.authorizeProjectTransaction(ctx, tx, actorID, stored.ProjectID, domain.PermissionCertificateManage); err != nil {
return err
}
item.ProjectID = stored.ProjectID
if err := r.validateDomainPathsInTransaction(ctx, tx, item); err != nil {
return err
}
result, err := tx.ExecContext(ctx, Rebind(r.driver, `UPDATE domains SET
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?
WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment,
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, item.ID, stored.ProjectID)
if err != nil {
return fmt.Errorf("更新域名失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return sql.ErrNoRows
}
if err := writeDomainAudit(ctx, tx, r.driver, "domain.update", item.ID, stored.ProjectID, actorID, "更新域名"); err != nil {
return err
}
return commitProjectDomain(tx, "提交域名更新事务失败")
}
func (r *SQLProjectMemberRepository) DeleteProjectDomain(ctx context.Context, actorID, domainID string) error {
if err := r.ensureDB(); err != nil {
return err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("开始域名删除事务失败:%w", err)
}
defer tx.Rollback()
var projectID string
if err := tx.QueryRowContext(ctx, Rebind(r.driver, `SELECT project_id FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&projectID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return sql.ErrNoRows
}
return fmt.Errorf("读取待删除域名失败:%w", err)
}
if err := r.authorizeProjectTransaction(ctx, tx, actorID, projectID, domain.PermissionCertificateManage); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, Rebind(r.driver, `SELECT id FROM tasks
WHERE domain_id = ? AND task_type IN ('certificate_issue', 'certificate_validate', 'certificate_deploy', 'certificate_import', 'certificate_restore')
AND status IN ('queued', 'running')`+r.resourceWriteLock()), domainID)
if err != nil {
return fmt.Errorf("检查域名任务失败:%w", err)
}
busy := rows.Next()
rows.Close()
if busy {
return ErrProjectDomainBusy
}
if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil {
return fmt.Errorf("删除域名服务器关联失败:%w", err)
}
result, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domains WHERE id = ? AND project_id = ?`), domainID, projectID)
if err != nil {
return fmt.Errorf("删除域名失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return sql.ErrNoRows
}
if err := writeDomainAudit(ctx, tx, r.driver, "domain.delete", domainID, projectID, actorID, "删除域名"); err != nil {
return err
}
return commitProjectDomain(tx, "提交域名删除事务失败")
}
func (r *SQLProjectMemberRepository) validateDomainPathsInTransaction(ctx context.Context, tx *sql.Tx, item domain.Domain) error {
rows, err := tx.QueryContext(ctx, Rebind(r.driver, serverSelectQualified+` INNER JOIN domain_servers ds ON ds.server_id = servers.id WHERE ds.domain_id = ? ORDER BY servers.id`+r.resourceWriteLock()), item.ID)
if err != nil {
return fmt.Errorf("查询域名服务器路径失败:%w", err)
}
defer rows.Close()
for rows.Next() {
server, err := scanServer(rows)
if err != nil {
return err
}
if server.ProjectID != item.ProjectID {
return ErrProjectAccessDenied
}
if err := validateServerDomainPaths(ctx, tx, r.driver, server, &item); err != nil {
return err
}
}
return rows.Err()
}
func writeDomainAudit(ctx context.Context, tx *sql.Tx, driver, action, domainID, projectID, actorID, message string) error {
result, err := tx.ExecContext(ctx, Rebind(driver, `INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
VALUES (?, 'domain', ?, 'success', ?, ?, 'project', ?, '', ?)`), action, domainID, message, time.Now().UTC(), projectID, actorID)
if err != nil {
return fmt.Errorf("写入域名操作审计失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return errors.New("域名操作审计未写入,业务变更已回滚")
}
return nil
}
func commitProjectDomain(tx *sql.Tx, message string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("%s:%w", message, err)
}
return nil
}

View File

@ -0,0 +1,86 @@
package repository
import (
"context"
"database/sql"
"errors"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func TestProjectDomainRepositorySQLite(t *testing.T) {
cfg := config.Default(t.TempDir()).Database
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil {
t.Fatal(err)
}
for _, user := range []struct{ id, role string }{
{"root-domain", UserRoleSuperAdmin}, {"domain-manager", UserRoleUser}, {"domain-outsider", UserRoleUser},
} {
if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
(id, username, password_hash, role, status) VALUES (?, ?, 'test-only-no-login', ?, 'active')`), user.id, user.id, user.role); err != nil {
t.Fatal(err)
}
}
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
if err := projects.CreateProject(t.Context(), domain.Project{ID: "domain-project", Name: "域名项目"}); err != nil {
t.Fatal(err)
}
writer := NewSQLProjectMemberRepository(db.DB, cfg.Driver)
if err := writer.GrantProjectRole(t.Context(), "root-domain", "domain-project", "domain-manager", domain.ProjectRoleCertificateAdmin); err != nil {
t.Fatal(err)
}
item, err := domain.NewDomain("managed.example.test", domain.CertificateSingle, nil)
if err != nil {
t.Fatal(err)
}
item.ID = "managed-domain"
item.ProjectID = "domain-project"
if err := writer.CreateProjectDomain(t.Context(), "domain-manager", item); err != nil {
t.Fatal(err)
}
stored, err := NewSQLDomainRepository(db.DB, cfg.Driver).GetByID(t.Context(), item.ID)
if err != nil || stored.ProjectID != item.ProjectID {
t.Fatalf("项目域名未按真实归属保存:%+v,%v", stored, err)
}
if err := writer.CreateProjectDomain(t.Context(), "domain-outsider", item); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权创建应拒绝:%v", err)
}
item.Name = "updated.example.test"
if err := writer.UpdateProjectDomain(t.Context(), "domain-manager", item); err != nil {
t.Fatal(err)
}
if _, err := NewSQLDomainRepository(db.DB, cfg.Driver).GetByID(t.Context(), item.ID); err != nil {
t.Fatal(err)
}
taskRepo := NewSQLTaskRepository(db.DB, cfg.Driver)
if err := taskRepo.CreateTask(t.Context(), Task{HistoryScope: HistoryScope{ProjectID: item.ProjectID, ScopeType: ScopeProject}, ID: "domain-active-task", DomainID: item.ID, TaskType: "certificate_issue", Status: "running"}); err != nil {
t.Fatal(err)
}
if err := writer.DeleteProjectDomain(t.Context(), "domain-manager", item.ID); !errors.Is(err, ErrProjectDomainBusy) {
t.Fatalf("在途任务下删除应拒绝:%v", err)
}
if _, err := db.DB.ExecContext(context.Background(), Rebind(cfg.Driver, `UPDATE tasks SET status = 'success' WHERE id = ?`), "domain-active-task"); err != nil {
t.Fatal(err)
}
if err := writer.DeleteProjectDomain(t.Context(), "domain-manager", item.ID); err != nil {
t.Fatal(err)
}
var count int
if err := db.DB.QueryRowContext(t.Context(), Rebind(cfg.Driver, `SELECT COUNT(*) FROM audit_logs WHERE resource_id = ? AND status = 'success'`), item.ID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("域名成功操作审计数量错误:%d", count)
}
if _, err := NewSQLDomainRepository(db.DB, cfg.Driver).GetByID(t.Context(), item.ID); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("删除后域名应不存在:%v", err)
}
}