From 5180c7d73f260a7ff111ba71a949527bb441dcd3 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 29 Sep 2026 16:27:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=A3=80=E6=9F=A5=E7=82=B9?= =?UTF-8?q?=EF=BC=9A=E5=AE=8C=E6=88=90=E9=A1=B9=E7=9B=AE=E5=9F=9F=E5=90=8D?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E4=BA=8B=E5=8A=A1=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交 P0-07 当前开发检查点,包含项目域名事务仓储、HTTP 路由接线、生产应用依赖及测试夹具调整。 --- backend/internal/app/app.go | 1 + .../internal/httpapi/aliyun_scope_api_test.go | 2 +- .../internal/httpapi/resource_scope_test.go | 7 +- backend/internal/httpapi/review_test.go | 11 +- backend/internal/httpapi/server.go | 87 ++++++--- backend/internal/httpapi/server_test.go | 8 + backend/internal/httpapi/test_helpers_test.go | 22 +++ .../internal/repository/domain_repository.go | 7 + .../internal/repository/project_bindings.go | 20 +- .../repository/project_domain_repository.go | 180 ++++++++++++++++++ .../project_domain_repository_test.go | 86 +++++++++ 11 files changed, 395 insertions(+), 36 deletions(-) create mode 100644 backend/internal/repository/project_domain_repository.go create mode 100644 backend/internal/repository/project_domain_repository_test.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index cce1d04..8657755 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -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) { diff --git a/backend/internal/httpapi/aliyun_scope_api_test.go b/backend/internal/httpapi/aliyun_scope_api_test.go index da3a935..61e965b 100644 --- a/backend/internal/httpapi/aliyun_scope_api_test.go +++ b/backend/internal/httpapi/aliyun_scope_api_test.go @@ -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"]}`)) diff --git a/backend/internal/httpapi/resource_scope_test.go b/backend/internal/httpapi/resource_scope_test.go index 48ad179..74cc2db 100644 --- a/backend/internal/httpapi/resource_scope_test.go +++ b/backend/internal/httpapi/resource_scope_test.go @@ -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") diff --git a/backend/internal/httpapi/review_test.go b/backend/internal/httpapi/review_test.go index 4793b4f..a937088 100644 --- a/backend/internal/httpapi/review_test.go +++ b/backend/internal/httpapi/review_test.go @@ -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() diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 5563242..561b171 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -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 diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index 16a7760..84ac628 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -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"}`)) diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index bbbb6da..d0d9e25 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -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 { diff --git a/backend/internal/repository/domain_repository.go b/backend/internal/repository/domain_repository.go index ad758c2..548f6fb 100644 --- a/backend/internal/repository/domain_repository.go +++ b/backend/internal/repository/domain_repository.go @@ -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 +} diff --git a/backend/internal/repository/project_bindings.go b/backend/internal/repository/project_bindings.go index d2ff6c7..5ca1198 100644 --- a/backend/internal/repository/project_bindings.go +++ b/backend/internal/repository/project_bindings.go @@ -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 } } diff --git a/backend/internal/repository/project_domain_repository.go b/backend/internal/repository/project_domain_repository.go new file mode 100644 index 0000000..103df0b --- /dev/null +++ b/backend/internal/repository/project_domain_repository.go @@ -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 +} diff --git a/backend/internal/repository/project_domain_repository_test.go b/backend/internal/repository/project_domain_repository_test.go new file mode 100644 index 0000000..60750c5 --- /dev/null +++ b/backend/internal/repository/project_domain_repository_test.go @@ -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) + } +}