diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 089b677..cce1d04 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -159,6 +159,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), ProjectResources: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver), + ProjectBindings: 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/app/project_lifecycle_api_test.go b/backend/internal/app/project_lifecycle_api_test.go index c7decfc..b77e805 100644 --- a/backend/internal/app/project_lifecycle_api_test.go +++ b/backend/internal/app/project_lifecycle_api_test.go @@ -45,6 +45,7 @@ func TestApplicationWiresProjectLifecycleAPI(t *testing.T) { } request(http.MethodGet, "/api/v1/servers/missing", "", http.StatusForbidden) request(http.MethodGet, "/api/v1/certificates/missing", "", http.StatusForbidden) + request(http.MethodPut, "/api/v1/domains/missing/servers", `{"server_ids":[]}`, http.StatusForbidden) created := request(http.MethodPost, "/api/v1/projects", `{"name":"实际应用项目","description":"接线验证"}`, 201) var item domain.Project if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil || item.ID == "" { diff --git a/backend/internal/httpapi/project_bindings_api.go b/backend/internal/httpapi/project_bindings_api.go new file mode 100644 index 0000000..808b846 --- /dev/null +++ b/backend/internal/httpapi/project_bindings_api.go @@ -0,0 +1,69 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func (s *Server) registerProjectBindingRoutes() { + api := s.router.Group("/api/v1", privateProjectResponse) + api.PUT("/domains/:id/servers", s.projectBindingFailureAudit, s.requireAuthenticated, s.replaceProjectDomainServers) +} + +func (s *Server) replaceProjectDomainServers(c *gin.Context) { + if s.projectBindings == nil { + s.writeError(c, http.StatusServiceUnavailable, "项目绑定服务尚未就绪", nil) + return + } + var input struct { + ServerIDs *[]string `json:"server_ids"` + } + if !validMemberIdentifier(c.Param("id")) || c.Request.URL.RawQuery != "" || + bindJSON(c, &input, jsonRequestBodyLimit) != nil || input.ServerIDs == nil || len(*input.ServerIDs) > repository.MaxProjectBindingServers { + s.writeError(c, http.StatusBadRequest, repository.ErrProjectBindingInput.Error(), nil) + return + } + for _, id := range *input.ServerIDs { + if !validMemberIdentifier(id) { + s.writeError(c, http.StatusBadRequest, repository.ErrProjectBindingInput.Error(), nil) + return + } + } + items, err := s.projectBindings.ReplaceProjectDomainServers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), *input.ServerIDs) + switch { + case errors.Is(err, repository.ErrProjectAccessDenied): + s.writeError(c, http.StatusForbidden, "无权修改此项目的服务器绑定", nil) + case errors.Is(err, repository.ErrProjectBindingInput): + s.writeError(c, http.StatusBadRequest, err.Error(), nil) + case err != nil: + s.writeError(c, http.StatusInternalServerError, "保存项目服务器绑定失败,请刷新后核对", nil) + default: + c.JSON(http.StatusOK, gin.H{"items": items}) + } +} + +func (s *Server) projectBindingFailureAudit(c *gin.Context) { + c.Next() + if c.Writer.Status() < 400 || s.projectBindings == nil { + return + } + domainID := c.Param("id") + if !validMemberIdentifier(domainID) { + domainID = "" + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second) + defer cancel() + err := s.projectBindings.RecordProjectBindingFailure(ctx, repository.ProjectBindingFailure{ + ActorID: c.GetString(authenticatedActorKey), DomainID: domainID, + StatusCode: c.Writer.Status(), RequestID: requestIDFromContext(c), + }) + if err != nil && s.logger != nil { + s.logger.Error("项目绑定失败审计未保存", "request_id", requestIDFromContext(c), "status", c.Writer.Status()) + } +} diff --git a/backend/internal/httpapi/project_bindings_api_test.go b/backend/internal/httpapi/project_bindings_api_test.go new file mode 100644 index 0000000..742a9c9 --- /dev/null +++ b/backend/internal/httpapi/project_bindings_api_test.go @@ -0,0 +1,140 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +func TestProjectBindingAPIPermissionsAndExplicitClear(t *testing.T) { + f, _ := newProjectResourceAPIFixture(t) + path := "/api/v1/domains/domain-a/servers" + for _, tc := range []struct { + actor string + code int + }{ + {"manager", 200}, {"primary-super-admin", 200}, {"viewer", 403}, {"global-admin", 403}, {"target", 403}, + } { + response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodPut, path, `{"server_ids":["server-a","server-a"]}`) + if response.Code != tc.code || response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("绑定权限或缓存错误:%s,%d,%s", tc.actor, response.Code, response.Body.String()) + } + entry := f.lastAudit(t) + if entry.Action != "domain.servers.replace" || entry.ProjectID != "project-a" || entry.OperatorID != tc.actor || strings.Contains(entry.Message, "server_ids") { + t.Fatalf("绑定审计不正确或包含请求体:%+v", entry) + } + } + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, path, `{"server_ids":[]}`) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"items":[]`) { + t.Fatalf("清空响应错误:%d,%s", response.Code, response.Body.String()) + } + items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a") + if err != nil || len(items) != 0 { + t.Fatalf("绑定未清空:%+v,%v", items, err) + } +} + +func TestProjectBindingAPIRejectsForgeryAndKeepsOldBindings(t *testing.T) { + f, _ := newProjectResourceAPIFixture(t) + for _, tc := range []struct { + id, query, body string + code int + }{ + {"domain-a", "", `{}`, 400}, + {"domain-a", "", `{"server_ids":null}`, 400}, + {"domain-a", "", `{"server_ids":[],"project_id":"project-b"}`, 400}, + {"domain-a", "?actor_id=primary-super-admin", `{"server_ids":[]}`, 400}, + {"domain-a", "", `{"server_ids":["../server"]}`, 400}, + {"domain-a", "", `{"server_ids":["server-b"]}`, 403}, + {"domain-a", "", `{"server_ids":["missing"]}`, 403}, + {"domain-b", "", `{"server_ids":[]}`, 403}, + {"missing", "", `{"server_ids":[]}`, 403}, + } { + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/domains/"+tc.id+"/servers"+tc.query, tc.body) + if response.Code != tc.code { + t.Fatalf("无效或越权请求未拒绝:%s,%d,%s", tc.id, response.Code, response.Body.String()) + } + items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a") + if err != nil || len(items) != 1 || items[0].ID != "server-a" { + t.Fatalf("拒绝请求改变了原绑定:%+v,%v", items, err) + } + entry := f.lastAudit(t) + wantProject := "project-a" + if tc.id == "domain-b" { + wantProject = "project-b" + } else if tc.id == "missing" { + wantProject = "" + } + if entry.ProjectID != wantProject || entry.OperatorID != "manager" || entry.Status != "failed" { + t.Fatalf("失败审计未按真实资源归属记录:%+v", entry) + } + } +} + +type bindingWriteBoundary struct { + repository.ProjectBindingRepository + before func() +} + +func (b bindingWriteBoundary) ReplaceProjectDomainServers(ctx context.Context, actor, id string, ids []string) ([]deployment.Server, error) { + b.before() + return b.ProjectBindingRepository.ReplaceProjectDomainServers(ctx, actor, id, ids) +} + +func TestProjectBindingAPIRechecksAfterSessionAndFailsClosed(t *testing.T) { + for _, mode := range []string{"revoke", "disable", "missing_dependency"} { + t.Run(mode, func(t *testing.T) { + f, _ := newProjectResourceAPIFixture(t) + f.server.projectBindings = bindingWriteBoundary{ProjectBindingRepository: f.members, before: func() { + if mode == "revoke" { + if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + } else if _, err := f.db.ExecContext(t.Context(), `UPDATE users SET status='disabled' WHERE id='manager'`); err != nil { + t.Fatal(err) + } + }} + want := http.StatusForbidden + if mode == "missing_dependency" { + f.server.projectBindings = nil + want = http.StatusServiceUnavailable + } + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/domains/domain-a/servers", `{"server_ids":[]}`) + if response.Code != want { + t.Fatalf("会话后撤权或缺少依赖未拒绝:%d,%s", response.Code, response.Body.String()) + } + items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a") + if err != nil || len(items) != 1 { + t.Fatalf("失败后丢失原绑定:%+v,%v", items, err) + } + }) + } +} + +func TestProjectBindingAPIAuthenticationAndOrigin(t *testing.T) { + f, _ := newProjectResourceAPIFixture(t) + for _, authenticated := range []bool{false, true} { + request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-a/servers", strings.NewReader(`{"server_ids":[]}`)) + request.Header.Set("Content-Type", "application/json") + want := http.StatusUnauthorized + if authenticated { + request.AddCookie(f.cookies["manager"]) + request.Header.Set("Origin", "https://foreign.example.test") + want = http.StatusForbidden + } + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, request) + if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("未登录或跨来源未拒绝:%d,%s", response.Code, response.Body.String()) + } + if entry := f.lastAudit(t); entry.Status != "failed" || entry.Action != "domain.servers.replace" { + t.Fatalf("认证失败缺少审计:%+v", entry) + } + } +} diff --git a/backend/internal/httpapi/project_members_test_helpers_test.go b/backend/internal/httpapi/project_members_test_helpers_test.go index c2b2719..6a6a9f4 100644 --- a/backend/internal/httpapi/project_members_test_helpers_test.go +++ b/backend/internal/httpapi/project_members_test_helpers_test.go @@ -50,7 +50,7 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture { } f.server = New(Dependencies{ Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members, - ProjectLifecycle: f.members, ProjectResources: f.members, Audit: f.audit, + ProjectLifecycle: f.members, ProjectResources: f.members, ProjectBindings: f.members, Audit: f.audit, }) for _, account := range []struct{ id, role string }{ {"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser}, diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 7a03b37..e43a183 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -52,6 +52,7 @@ type Dependencies struct { ProjectDirectory repository.ProjectDirectoryRepository ProjectLifecycle repository.ProjectLifecycleRepository ProjectResources repository.ProjectResourceReader + ProjectBindings repository.ProjectBindingRepository SSHKeys *sshkey.Service DeploymentFactory deployment.ClientFactory SetupDNS setupDNSVerifier @@ -87,6 +88,7 @@ type Server struct { projectDirectory repository.ProjectDirectoryRepository projectLifecycle repository.ProjectLifecycleRepository projectResources repository.ProjectResourceReader + projectBindings repository.ProjectBindingRepository sshKeys *sshkey.Service deploymentFactory deployment.ClientFactory setupDNS setupDNSVerifier @@ -151,6 +153,7 @@ func New(deps Dependencies) *Server { projectDirectory: deps.ProjectDirectory, projectLifecycle: deps.ProjectLifecycle, projectResources: deps.ProjectResources, + projectBindings: deps.ProjectBindings, sshKeys: deps.SSHKeys, deploymentFactory: deps.DeploymentFactory, issueCertificate: deps.IssueCertificate, @@ -201,6 +204,7 @@ func (s *Server) registerRoutes() { s.registerProjectMemberRoutes() s.registerProjectLifecycleRoutes() 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) @@ -222,7 +226,6 @@ func (s *Server) registerRoutes() { adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate) adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent) adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion) - adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers) adminAPI.POST("/servers", s.createServer) adminAPI.PUT("/servers/:id", s.updateServer) adminAPI.DELETE("/servers/:id", s.deleteServer) @@ -402,10 +405,6 @@ type serverRequest struct { ReloadCommand []string `json:"reload_command"` } -type domainServersRequest struct { - ServerIDs []string `json:"server_ids"` -} - func (s *Server) health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "scert"}) } @@ -964,39 +963,6 @@ func (s *Server) listDomainServers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) } -func (s *Server) replaceDomainServers(c *gin.Context) { - if !s.requireDomainRepository(c) { - return - } - if s.servers == nil { - s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置")) - return - } - if _, err := s.repository.GetByID(c.Request.Context(), c.Param("id")); err != nil { - if repository.IsNotFound(err) { - s.writeError(c, http.StatusNotFound, "域名不存在", err) - return - } - s.writeError(c, http.StatusInternalServerError, "读取域名失败", err) - return - } - var request domainServersRequest - if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil { - s.writeError(c, http.StatusBadRequest, "请求参数无效", err) - return - } - if err := s.servers.ReplaceDomainServers(c.Request.Context(), c.Param("id"), request.ServerIDs); err != nil { - s.writeError(c, serverErrorStatus(err), "保存域名部署服务器失败", err) - return - } - items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id")) - if err != nil { - s.writeError(c, http.StatusInternalServerError, "读取域名部署服务器失败", err) - return - } - c.JSON(http.StatusOK, gin.H{"items": items}) -} - func serverFromRequest(request serverRequest) deployment.Server { return deployment.Server{ ID: domain.NewID(), Name: request.Name, Address: request.Address, Port: request.Port, diff --git a/backend/internal/repository/project_binding_failure.go b/backend/internal/repository/project_binding_failure.go new file mode 100644 index 0000000..f967334 --- /dev/null +++ b/backend/internal/repository/project_binding_failure.go @@ -0,0 +1,32 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" +) + +// 失败范围由实际域名解析;不存在的资源保留未知范围,不相信请求中的项目或操作者。 +func (r *SQLProjectMemberRepository) RecordProjectBindingFailure(ctx context.Context, failure ProjectBindingFailure) error { + if err := r.ensureDB(); err != nil { + return err + } + if failure.StatusCode < 400 || failure.StatusCode > 599 || len(failure.ActorID) > 128 || len(failure.DomainID) > 128 || len(failure.RequestID) > 128 { + return errors.New("项目绑定失败审计参数无效") + } + result, err := r.db.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + SELECT 'domain.servers.replace', 'domain', ?, 'failed', ?, ?, + CASE WHEN d.id IS NULL THEN 'unknown' ELSE 'project' END, COALESCE(d.project_id, ''), '', ? + FROM (SELECT 1 AS marker) audit_event LEFT JOIN domains d ON d.id = ?`), + failure.DomainID, fmt.Sprintf("绑定请求被拒绝或执行失败:HTTP %d;请求编号:%s", failure.StatusCode, failure.RequestID), + time.Now().UTC(), failure.ActorID, failure.DomainID) + if err != nil { + return fmt.Errorf("保存项目绑定失败审计失败:%w", err) + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return errors.New("项目绑定失败审计未写入") + } + return nil +} diff --git a/backend/internal/repository/project_bindings.go b/backend/internal/repository/project_bindings.go new file mode 100644 index 0000000..be1b0a0 --- /dev/null +++ b/backend/internal/repository/project_bindings.go @@ -0,0 +1,191 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +const MaxProjectBindingServers = 100 + +var ErrProjectBindingInput = errors.New("服务器列表必须明确提供,最多包含 100 个有效服务器标识") + +type ProjectBindingFailure struct { + ActorID string + DomainID string + StatusCode int + RequestID string +} + +type ProjectBindingRepository interface { + ReplaceProjectDomainServers(context.Context, string, string, []string) ([]deployment.Server, error) + RecordProjectBindingFailure(context.Context, ProjectBindingFailure) error +} + +var _ ProjectBindingRepository = (*SQLProjectMemberRepository)(nil) + +// 绑定变更只负责保存关联,不部署证书、不连接 SSH、不接收客户端的项目归属。 +func (r *SQLProjectMemberRepository) ReplaceProjectDomainServers(ctx context.Context, actorID, domainID string, serverIDs []string) ([]deployment.Server, error) { + if err := r.resourceReaderReady(actorID); err != nil { + return nil, err + } + if domainID == "" || len(domainID) > 128 { + return nil, ErrProjectAccessDenied + } + if serverIDs == nil || len(serverIDs) > MaxProjectBindingServers { + return nil, ErrProjectBindingInput + } + ids := make([]string, 0, len(serverIDs)) + seen := make(map[string]bool, len(serverIDs)) + for _, id := range serverIDs { + if id == "" || len(id) > 128 || strings.TrimSpace(id) != id { + return nil, ErrProjectBindingInput + } + if !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + // 所有请求按相同次序锁定服务器,降低反向选择时的死锁概率。 + sort.Strings(ids) + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("开始项目绑定事务失败:%w", err) + } + defer tx.Rollback() + if r.driver == "mysql" { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema=DATABASE() AND table_name IN + ('users', 'projects', 'project_members', 'domains', 'servers', 'domain_servers', 'audit_logs') + AND engine='InnoDB'`).Scan(&count); err != nil { + return nil, fmt.Errorf("检查项目绑定存储失败:%w", err) + } + if count != 7 { + return nil, errors.New("项目绑定及授权审计相关表必须使用 InnoDB") + } + } + 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 { + return nil, projectBindingReadError(err) + } + if err := r.authorizeBindingTransaction(ctx, tx, actorID, projectID); err != nil { + return nil, err + } + items := make([]deployment.Server, 0, len(ids)) + for _, id := range ids { + item, err := scanServer(tx.QueryRowContext(ctx, Rebind(r.driver, + serverSelectQualified+` WHERE servers.id = ?`+r.resourceWriteLock()), id)) + if err != nil { + return nil, projectBindingReadError(err) + } + if item.ProjectID != projectID { + return nil, ErrProjectAccessDenied + } + items = append(items, item) + } + if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil { + return nil, fmt.Errorf("清理项目绑定失败:%w", err) + } + for _, id := range ids { + result, err := tx.ExecContext(ctx, Rebind(r.driver, + `INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, ?)`), domainID, id, projectID) + if err != nil { + return nil, fmt.Errorf("保存项目绑定失败:%w", err) + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return nil, errors.New("项目绑定未完整写入,已回滚本次变更") + } + } + result, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs + (action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id) + VALUES ('domain.servers.replace', 'domain', ?, 'success', ?, ?, 'project', ?, '', ?)`), + domainID, fmt.Sprintf("已更新部署服务器绑定,共 %d 台", len(ids)), time.Now().UTC(), projectID, actorID) + if err != nil { + return nil, fmt.Errorf("保存项目绑定审计失败:%w", err) + } + if count, err := result.RowsAffected(); err != nil || count != 1 { + return nil, errors.New("项目绑定审计未写入,已回滚本次变更") + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("提交项目绑定失败:%w", err) + } + sort.Slice(items, func(i, j int) bool { + if items[i].Name == items[j].Name { + return items[i].ID < items[j].ID + } + return items[i].Name < items[j].Name + }) + return items, nil +} + +func (r *SQLProjectMemberRepository) resourceWriteLock() string { + if r.driver == "mysql" || r.driver == "postgres" { + return " FOR UPDATE" + } + return "" +} + +func projectBindingReadError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrProjectAccessDenied + } + return fmt.Errorf("读取项目绑定资源失败:%w", err) +} + +// 使用事务内的当前读取并持有账户、项目和现有成员行锁,避免撤权或停用后继续写入。 +// SQLite 使用自身事务串行写入保证;遇到并发升级失败时回滚,不绕过授权重试。 +func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Context, tx *sql.Tx, actorID, projectID string) 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 { + return projectBindingReadError(err) + } + if status != UserStatusActive || (userRole != UserRoleSuperAdmin && userRole != UserRoleAdmin && userRole != UserRoleUser) { + return ErrProjectAccessDenied + } + if err := tx.QueryRowContext(ctx, Rebind(r.driver, + `SELECT id FROM projects WHERE id = ?`+r.resourceWriteLock()), projectID).Scan(&existingProject); err != nil { + return projectBindingReadError(err) + } + if userRole == UserRoleSuperAdmin { + return nil + } + rows, err := tx.QueryContext(ctx, Rebind(r.driver, + `SELECT role FROM project_members WHERE project_id = ? AND user_id = ? ORDER BY role`+r.resourceWriteLock()), projectID, actorID) + if err != nil { + return fmt.Errorf("读取绑定操作权限失败:%w", err) + } + defer rows.Close() + permissions := map[domain.ProjectPermission]bool{ + domain.PermissionCertificateRead: false, + domain.PermissionServerRead: false, + domain.PermissionDeploymentExecute: false, + } + for rows.Next() { + var role domain.ProjectRole + if err := rows.Scan(&role); err != nil { + return err + } + for permission := range permissions { + permissions[permission] = permissions[permission] || role.Allows(permission) + } + } + if err := rows.Err(); err != nil { + return err + } + for _, allowed := range permissions { + if !allowed { + return ErrProjectAccessDenied + } + } + return nil +} diff --git a/backend/internal/repository/project_bindings_test.go b/backend/internal/repository/project_bindings_test.go new file mode 100644 index 0000000..d9ec990 --- /dev/null +++ b/backend/internal/repository/project_bindings_test.go @@ -0,0 +1,124 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" +) + +// 复用真实资源与六类项目成员夹具;由 SQLite 和隔离 MySQL 入口分别调用。 +func checkProjectBindings(t *testing.T, db, admin *sql.DB, repo *SQLProjectMemberRepository, driver string) { + t.Helper() + unchanged := func(t *testing.T) { + t.Helper() + items, err := NewSQLServerRepository(db, driver).ListServersByDomainID(t.Context(), "domain-a") + if err != nil || len(items) != 1 || items[0].ID != "server-a" { + t.Fatalf("原绑定未保留:%+v,%v", items, err) + } + } + t.Run("bindings_role_matrix_and_audit", func(t *testing.T) { + for _, actor := range []string{"root", "manager", "deploy", "cert", "dns", "audit", "reader", "outsider", "missing"} { + items, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{"server-a", "server-a"}) + allowed := actor == "root" || actor == "manager" || actor == "deploy" + if !allowed { + if !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("未授权角色仍可写绑定:%s,%v", actor, err) + } + continue + } + if err != nil || len(items) != 1 || items[0].ID != "server-a" { + t.Fatalf("授权绑定失败或未去重:%s,%+v,%v", actor, items, err) + } + var count int + if err := db.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs + WHERE action='domain.servers.replace' AND status='success' AND project_id='project-a' + AND resource_id='domain-a' AND operator_id=?`), actor).Scan(&count); err != nil || count != 1 { + t.Fatalf("成功审计范围或操作者错误:%s,%d,%v", actor, count, err) + } + } + unchanged(t) + }) + t.Run("bindings_cross_scope_and_unknown_preserve_existing", func(t *testing.T) { + for _, actor := range []string{"manager", "root"} { + for _, id := range []string{"server-b", "missing", "server-a' OR 1=1 --"} { + if _, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{"server-a", id}); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("越权、未知或注入资源未拒绝:%s,%s,%v", actor, id, err) + } + unchanged(t) + } + } + for _, id := range []string{"domain-b", "missing"} { + if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", id, []string{}); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("未知或越权域名允许清空:%s,%v", id, err) + } + } + }) + t.Run("bindings_explicit_empty_and_invalid_input", func(t *testing.T) { + items, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{}) + if err != nil || items == nil || len(items) != 0 { + t.Fatalf("明确清空未返回空列表:%+v,%v", items, err) + } + loaded, err := NewSQLServerRepository(db, driver).ListServersByDomainID(t.Context(), "domain-a") + if err != nil || len(loaded) != 0 { + t.Fatalf("清空未持久化:%+v,%v", loaded, err) + } + if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{"server-a"}); err != nil { + t.Fatal(err) + } + for _, ids := range [][]string{nil, {""}, {" server-a"}, make([]string, MaxProjectBindingServers+1)} { + if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", ids); !errors.Is(err, ErrProjectBindingInput) { + t.Fatalf("非法列表未拒绝:%v", err) + } + unchanged(t) + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := repo.ReplaceProjectDomainServers(ctx, "root", "domain-a", []string{}); err == nil { + t.Fatal("取消后仍修改了绑定") + } + unchanged(t) + }) + t.Run("bindings_audit_failure_rolls_back", func(t *testing.T) { + executor := db + triggerName := "reject_binding_audit" + trigger := `CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs + BEGIN SELECT RAISE(ABORT, 'audit-failure'); END` + if driver == "mysql" { + var schema string + if err := db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil { + t.Fatal(err) + } + // 仅隔离测试管理员建触发器,不提升应用账户权限。 + triggerName = "`" + strings.ReplaceAll(schema, "`", "``") + "`.reject_binding_audit" + trigger = "CREATE TRIGGER " + triggerName + " BEFORE INSERT ON `" + strings.ReplaceAll(schema, "`", "``") + + "`.audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'" + executor = admin + } + if _, err := executor.ExecContext(t.Context(), trigger); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if _, err := executor.ExecContext(context.Background(), "DROP TRIGGER "+triggerName); err != nil { + t.Error(err) + } + }) + if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{}); err == nil { + t.Fatal("审计失败时仍提交了清空操作") + } + unchanged(t) + }) + if driver == "sqlite" { + t.Run("bindings_ignored_audit_rolls_back", func(t *testing.T) { + if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER ignore_binding_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(IGNORE); END`); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = db.ExecContext(context.Background(), "DROP TRIGGER ignore_binding_audit") }) + if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{}); err == nil { + t.Fatal("审计未落库仍返回成功") + } + unchanged(t) + }) + } +} diff --git a/backend/internal/repository/project_resources_test.go b/backend/internal/repository/project_resources_test.go index 45e9769..ca686cf 100644 --- a/backend/internal/repository/project_resources_test.go +++ b/backend/internal/repository/project_resources_test.go @@ -2,6 +2,7 @@ package repository import ( "context" + "database/sql" "errors" "testing" @@ -13,15 +14,15 @@ import ( ) func TestProjectResourcesSQLite(t *testing.T) { - checkProjectResources(t, config.Default(t.TempDir()).Database) + checkProjectResources(t, config.Default(t.TempDir()).Database, nil) } func TestProjectResourcesMySQLReal(t *testing.T) { server := mysqltest.New(t) - checkProjectResources(t, server.Database(t)) + checkProjectResources(t, server.Database(t), server.Admin()) } -func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) { +func checkProjectResources(t *testing.T, cfg config.DatabaseConfig, admin *sql.DB) { t.Helper() db, err := database.Open(t.Context(), cfg) if err != nil { @@ -135,6 +136,7 @@ func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) { t.Fatalf("越权绑定查询未拒绝:%v", err) } }) + checkProjectBindings(t, db.DB, admin, repo, cfg.Driver) t.Run("revocation_and_disabled_account_are_not_cached", func(t *testing.T) { if err := repo.RevokeProjectRole(t.Context(), "root", "project-a", "reader", domain.ProjectRoleReadOnly); err != nil { t.Fatal(err) @@ -146,6 +148,9 @@ func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) { t.Fatal(err) } for _, actor := range []string{"reader", "manager"} { + if _, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{}); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("撤权或停用后仍可修改绑定:%s,%v", actor, err) + } items, err := repo.ListVisibleServers(t.Context(), actor) if err != nil || len(items) != 0 { t.Fatalf("撤权或停用后仍有资源:%s,%+v,%v", actor, items, err)