From 3857dac5c8e4f8a606c5f71eec9a0ebe03f50d7c Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 18:57:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E6=88=90=E5=91=98=E6=8E=A5=E5=8F=A3=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E5=A4=B1=E8=B4=A5=E5=AE=A1=E8=AE=A1=E4=B8=93=E9=A1=B9?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=80=99=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/app.go | 3 +- .../internal/app/project_members_api_test.go | 35 ++++ backend/internal/httpapi/middleware.go | 6 +- .../internal/httpapi/project_members_api.go | 154 +++++++++++++++ .../httpapi/project_members_api_test.go | 181 ++++++++++++++++++ .../internal/httpapi/project_members_audit.go | 39 ++++ .../project_members_test_helpers_test.go | 73 +++++++ backend/internal/httpapi/server.go | 4 + .../repository/project_member_changes.go | 22 +++ .../repository/project_member_failures.go | 46 +++++ .../repository/project_member_list.go | 100 ++++++++++ .../repository/project_member_list_test.go | 161 ++++++++++++++++ 12 files changed, 822 insertions(+), 2 deletions(-) create mode 100644 backend/internal/app/project_members_api_test.go create mode 100644 backend/internal/httpapi/project_members_api.go create mode 100644 backend/internal/httpapi/project_members_api_test.go create mode 100644 backend/internal/httpapi/project_members_audit.go create mode 100644 backend/internal/httpapi/project_members_test_helpers_test.go create mode 100644 backend/internal/repository/project_member_failures.go create mode 100644 backend/internal/repository/project_member_list.go create mode 100644 backend/internal/repository/project_member_list_test.go diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index a4be770..d546a4b 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -155,7 +155,8 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string }, management) server = httpapi.New(httpapi.Dependencies{ Config: cfg, ConfigPath: configPath, Logger: logger, Repository: repo, Servers: serverRepo, Users: userRepo, SSHKeys: sshKeys, DeploymentFactory: deployment.SSHFactory{Secrets: secretStore}, - SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker, + ProjectMembers: 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) { return certificates.IssueForSetup(ctx, request) diff --git a/backend/internal/app/project_members_api_test.go b/backend/internal/app/project_members_api_test.go new file mode 100644 index 0000000..9936164 --- /dev/null +++ b/backend/internal/app/project_members_api_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +// 从真实应用装配入口登录并读取默认项目,防止只在 HTTP 测试夹具中注入新仓储。 +func TestApplicationWiresProjectMemberAPI(t *testing.T) { + cfg := readyApplicationConfig(t) + application, err := NewWithConfigPath(t.Context(), cfg, filepath.Join(cfg.Paths.Config, "scert.json")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = application.Close() }) + login := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"Test1234!"}`)) + login.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + application.HTTP.Handler().ServeHTTP(response, login) + if response.Code != http.StatusOK { + t.Fatalf("实际应用登录失败:%d,%s", response.Code, response.Body.String()) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/projects/default/members?page_size=5", nil) + for _, cookie := range response.Result().Cookies() { + request.AddCookie(cookie) + } + listed := httptest.NewRecorder() + application.HTTP.Handler().ServeHTTP(listed, request) + if listed.Code != http.StatusOK || !strings.Contains(listed.Body.String(), `"items":[]`) { + t.Fatalf("实际应用未接入成员仓储或默认项目隐式增加成员:%d,%s", listed.Code, listed.Body.String()) + } +} diff --git a/backend/internal/httpapi/middleware.go b/backend/internal/httpapi/middleware.go index 5f4339f..cc27ee0 100644 --- a/backend/internal/httpapi/middleware.go +++ b/backend/internal/httpapi/middleware.go @@ -15,6 +15,7 @@ import ( const requestIDHeader = "X-Scert-Request-ID" const jsonRequestBodyLimit = 64 * 1024 +const authenticatedActorKey = "authenticated_actor_id" func (s *Server) requestMiddleware() gin.HandlerFunc { return func(c *gin.Context) { @@ -85,13 +86,16 @@ func (s *Server) requireAuthenticated(c *gin.Context) { }) return } - if _, ok := s.currentAuthSession(c); !ok { + session, ok := s.currentAuthSession(c) + if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "请先登录管理后台", "request_id": requestIDFromContext(c), }) return } + // 仅保存本次已验证身份,供失败审计使用;业务权限仍须在仓储内实时判定。 + c.Set(authenticatedActorKey, session.UserID) if !s.sameOriginForAPI(c.Request) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "error": "请求来源校验失败", diff --git a/backend/internal/httpapi/project_members_api.go b/backend/internal/httpapi/project_members_api.go new file mode 100644 index 0000000..bf96474 --- /dev/null +++ b/backend/internal/httpapi/project_members_api.go @@ -0,0 +1,154 @@ +package httpapi + +import ( + "errors" + "io" + "net/http" + "net/url" + "strconv" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +const projectMemberTargetKey = "project_member_target_id" + +func (s *Server) registerProjectMemberRoutes() { + api := s.router.Group("/api/v1/projects/:projectID/members") + api.GET("", s.projectMemberFailureAudit("project_member.list"), s.requireAuthenticated, s.listProjectMembers) + api.POST("", s.projectMemberFailureAudit("project_member.grant"), s.requireAuthenticated, s.grantProjectMember) + api.DELETE("/:userID/roles/:role", s.projectMemberFailureAudit("project_member.revoke"), s.requireAuthenticated, s.revokeProjectMember) +} + +func (s *Server) projectMemberRequestReady(c *gin.Context) bool { + if s.projectMembers == nil { + s.writeError(c, http.StatusServiceUnavailable, "项目成员服务尚未就绪", nil) + return false + } + if !validMemberIdentifier(c.Param("projectID")) { + s.writeError(c, http.StatusBadRequest, "项目标识无效", nil) + return false + } + return true +} + +func (s *Server) listProjectMembers(c *gin.Context) { + if !s.projectMemberRequestReady(c) { + return + } + query, err := projectMemberPageQuery(c) + if err != nil { + s.writeError(c, http.StatusBadRequest, "分页参数无效,每页仅支持 5、10、15 或 30 位成员", nil) + return + } + page, err := s.projectMembers.ListProjectMembers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), query.After, query.Size) + if err != nil { + s.writeProjectMemberError(c, err) + return + } + c.JSON(http.StatusOK, page) +} + +func (s *Server) grantProjectMember(c *gin.Context) { + if !s.projectMemberRequestReady(c) { + return + } + var request struct { + UserID string `json:"user_id"` + Role domain.ProjectRole `json:"role"` + } + if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil || !validMemberIdentifier(request.UserID) || !request.Role.Valid() || c.Request.URL.RawQuery != "" { + s.writeError(c, http.StatusBadRequest, "项目成员参数无效", nil) + return + } + c.Set(projectMemberTargetKey, request.UserID) + if err := s.projectMembers.GrantProjectRole(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), request.UserID, request.Role); err != nil { + s.writeProjectMemberError(c, err) + return + } + c.JSON(http.StatusCreated, gin.H{"message": "项目角色已授予"}) +} + +func (s *Server) revokeProjectMember(c *gin.Context) { + if !s.projectMemberRequestReady(c) { + return + } + role := domain.ProjectRole(c.Param("role")) + if c.Request.Body != nil { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1)) + if err != nil || len(body) != 0 { + s.writeError(c, http.StatusBadRequest, "撤销角色请求不接受请求体", nil) + return + } + } + if !validMemberIdentifier(c.Param("userID")) || !role.Valid() || c.Request.URL.RawQuery != "" { + s.writeError(c, http.StatusBadRequest, "项目成员参数无效", nil) + return + } + c.Set(projectMemberTargetKey, c.Param("userID")) + if err := s.projectMembers.RevokeProjectRole(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("projectID"), c.Param("userID"), role); err != nil { + s.writeProjectMemberError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +func (s *Server) writeProjectMemberError(c *gin.Context, err error) { + switch { + case errors.Is(err, repository.ErrProjectAccessDenied): + // 不区分无权限、项目不存在或目标不满足条件,避免泄露其他项目与账户信息。 + s.writeError(c, http.StatusForbidden, "无权访问项目或执行此操作", nil) + case errors.Is(err, repository.ErrProjectMemberConflict): + s.writeError(c, http.StatusConflict, "项目成员关系冲突,请刷新后重试", nil) + default: + s.writeError(c, http.StatusInternalServerError, "项目成员操作失败,请稍后重试", nil) + } +} + +type projectMemberQuery struct { + After string + Size int +} + +func projectMemberPageQuery(c *gin.Context) (projectMemberQuery, error) { + result := projectMemberQuery{Size: 10} + values, err := url.ParseQuery(c.Request.URL.RawQuery) + if err != nil { + return result, err + } + for key, entries := range values { + if (key != "after" && key != "page_size") || len(entries) != 1 { + return result, errors.New("分页参数重复或未知") + } + } + if values.Has("page_size") { + result.Size, err = strconv.Atoi(values.Get("page_size")) + if err != nil { + return result, err + } + } + switch result.Size { + case 5, 10, 15, 30: + default: + return result, errors.New("每页成员数量无效") + } + result.After = values.Get("after") + if result.After != "" && !validMemberIdentifier(result.After) { + return result, errors.New("分页游标无效") + } + return result, nil +} + +func validMemberIdentifier(value string) bool { + if len(value) == 0 || len(value) > 128 { + return false + } + for _, ch := range value { + if !(ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' || ch == '-' || ch == '_') { + return false + } + } + return true +} diff --git a/backend/internal/httpapi/project_members_api_test.go b/backend/internal/httpapi/project_members_api_test.go new file mode 100644 index 0000000..339e32f --- /dev/null +++ b/backend/internal/httpapi/project_members_api_test.go @@ -0,0 +1,181 @@ +package httpapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +const memberTestPath = "/api/v1/projects/project-a/members" + +func TestProjectMemberAPIAuthorizationAndFailureAudit(t *testing.T) { + f := newProjectMemberAPIFixture(t) + for _, tc := range []struct { + name, actor, path string + status int + }{ + {"anonymous", "", memberTestPath, http.StatusUnauthorized}, + {"global_admin_is_not_project_admin", "global-admin", memberTestPath, http.StatusForbidden}, + {"read_only_cannot_list_members", "viewer", memberTestPath, http.StatusForbidden}, + {"other_project", "manager", "/api/v1/projects/project-b/members", http.StatusForbidden}, + {"missing_project", "primary-super-admin", "/api/v1/projects/missing/members", http.StatusForbidden}, + {"project_admin_is_regular_user", "manager", memberTestPath, http.StatusOK}, + {"super_admin_empty_project", "primary-super-admin", "/api/v1/projects/project-b/members", http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, tc.path, "") + if response.Code != tc.status { + t.Fatalf("成员列表权限错误:%d,%s", response.Code, response.Body.String()) + } + for _, secret := range []string{"password_hash", "FixturePass123", "email", "groups", "example.test"} { + if strings.Contains(response.Body.String(), secret) { + t.Fatalf("成员列表暴露非必要账户字段:%s", response.Body.String()) + } + } + if tc.status >= 400 { + log := f.lastAudit(t) + if log.OperatorID != tc.actor || log.Status != "failed" || log.Action != "project_member.list" || !strings.Contains(log.Message, response.Header().Get(requestIDHeader)) { + t.Fatalf("失败审计身份或关联错误:%+v", log) + } + if tc.name == "missing_project" && (log.ScopeType != repository.ScopeUnknown || log.ProjectID != "") { + t.Fatalf("不存在项目被伪造归属:%+v", log) + } + } else { + var page repository.ProjectMemberPage + if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || page.Items == nil { + t.Fatalf("成员列表不是稳定数组响应:%s,%v", response.Body.String(), err) + } + } + }) + } + t.Run("cross_origin_identity_is_verified", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, memberTestPath, strings.NewReader(`{"user_id":"target","role":"dns_admin"}`)) + request.AddCookie(f.cookies["manager"]) + request.Header.Set("Origin", "https://untrusted.example.test") + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, request) + log := f.lastAudit(t) + if response.Code != http.StatusForbidden || log.OperatorID != "manager" || log.Status != "failed" { + t.Fatalf("来源拒绝丢失已验证身份:%d,%+v", response.Code, log) + } + }) +} + +func TestProjectMemberAPIMutations(t *testing.T) { + f := newProjectMemberAPIFixture(t) + body := `{"user_id":"target","role":"dns_admin"}` + for _, actor := range []string{"global-admin", "viewer"} { + response := serveUserRequest(f.server, f.cookies[actor], http.MethodPost, memberTestPath, body) + log := f.lastAudit(t) + if response.Code != http.StatusForbidden || log.OperatorID != actor || log.ResourceID != "target" || log.Status != "failed" { + t.Fatalf("未授权授予或审计错误:%d,%+v", response.Code, log) + } + } + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPost, memberTestPath, body) + log := f.lastAudit(t) + if response.Code != http.StatusCreated || log.OperatorID != "manager" || log.ProjectID != "project-a" || log.Status != "success" || log.Action != "project_member.grant" { + t.Fatalf("授予角色或事务审计错误:%d,%+v", response.Code, log) + } + duplicate := serveUserRequest(f.server, f.cookies["manager"], http.MethodPost, memberTestPath, body) + if duplicate.Code != http.StatusConflict || f.lastAudit(t).Status != "failed" { + t.Fatalf("重复授予未明确拒绝:%d,%s", duplicate.Code, duplicate.Body.String()) + } + response = serveUserRequest(f.server, f.cookies["manager"], http.MethodDelete, memberTestPath+"/target/roles/dns_admin", "") + log = f.lastAudit(t) + if response.Code != http.StatusNoContent || log.Status != "success" || log.Action != "project_member.revoke" || log.OperatorID != "manager" { + t.Fatalf("撤销角色或审计错误:%d,%+v", response.Code, log) + } + response = serveUserRequest(f.server, f.cookies["manager"], http.MethodDelete, memberTestPath+"/manager/roles/project_admin", "") + if response.Code != http.StatusNoContent || f.lastAudit(t).OperatorID != "manager" { + t.Fatalf("自我撤权丢失身份:%d", response.Code) + } + for _, method := range []string{http.MethodGet, http.MethodPost} { + response = serveUserRequest(f.server, f.cookies["manager"], method, memberTestPath, body) + if response.Code != http.StatusForbidden { + t.Fatalf("自我撤权后仍可读取或授权:%s,%d", method, response.Code) + } + } +} + +func TestProjectMemberAPIPageAndInputValidation(t *testing.T) { + f := newProjectMemberAPIFixture(t) + for i := 0; i < 11; i++ { + id := fmt.Sprintf("paged-%02d", i) + httpUserSessionCookie(f.server, id, repository.UserRoleUser) + for _, role := range []domain.ProjectRole{domain.ProjectRoleDNSAdmin, domain.ProjectRoleReadOnly} { + if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", id, role); err != nil { + t.Fatal(err) + } + } + } + for _, size := range []int{5, 10, 15, 30} { + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, fmt.Sprintf("%s?page_size=%d", memberTestPath, size), "") + var page repository.ProjectMemberPage + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || len(page.Items) != min(size, 13) || page.PageSize != size || page.HasMore != (size < 13) { + t.Fatalf("成员分页响应错误:%d,%s", response.Code, response.Body.String()) + } + if page.HasMore { + next := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, fmt.Sprintf("%s?page_size=%d&after=%s", memberTestPath, size, page.NextCursor), "") + var nextPage repository.ProjectMemberPage + if next.Code != http.StatusOK || json.Unmarshal(next.Body.Bytes(), &nextPage) != nil || len(nextPage.Items) == 0 || nextPage.Items[0].UserID <= page.NextCursor { + t.Fatalf("成员下一页游标错误:%s", next.Body.String()) + } + } + } + for _, query := range []string{"page_size=0", "page_size=100000", "page_size=-5", "page_size=abc", "page_size=5&page_size=10", "after=a&after=b", "after=%ZZ", "after=x;bad=y", "actor_id=primary-super-admin", "after=" + strings.Repeat("a", 129)} { + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, memberTestPath+"?"+query, "") + if response.Code != http.StatusBadRequest || f.lastAudit(t).Status != "failed" { + t.Fatalf("非法分页参数未拒绝:%s,%d", query, response.Code) + } + } + for _, body := range []string{ + `{"user_id":"target","role":"dns_admin","actor_id":"primary-super-admin"}`, + `{"user_id":"target","role":"super_admin"}`, `{"user_id":"target","role":"dns_admin"} {}`, + `{"user_id":"sensitive-marker","role":"invalid"}`, strings.Repeat("x", 70*1024), + } { + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPost, memberTestPath, body) + log := f.lastAudit(t) + if response.Code != http.StatusBadRequest || log.OperatorID != "manager" || strings.Contains(log.Message, "sensitive-marker") || strings.Contains(log.Message, "actor_id") || strings.Contains(log.ResourceID, "sensitive-marker") { + t.Fatalf("非法授权参数未拒绝或审计泄漏正文:%d,%+v", response.Code, log) + } + } +} + +func TestProjectMemberAPIStaleSessionAndAuditRollback(t *testing.T) { + t.Run("disabled_session_is_not_attributed", func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + if _, err := f.db.ExecContext(t.Context(), "UPDATE users SET status='disabled' WHERE id='manager'"); err != nil { + t.Fatal(err) + } + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPost, memberTestPath, `{"user_id":"target","role":"dns_admin"}`) + if response.Code != http.StatusUnauthorized || f.lastAudit(t).OperatorID != "" { + t.Fatalf("无效会话仍被记录为已验证身份:%d,%+v", response.Code, f.lastAudit(t)) + } + }) + t.Run("success_audit_failure_rolls_back_membership", func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + if _, err := f.db.ExecContext(t.Context(), `CREATE TRIGGER reject_member_success BEFORE INSERT ON audit_logs + WHEN NEW.resource_type='project_member' AND NEW.status='success' + BEGIN SELECT RAISE(ABORT, 'sensitive-marker'); END`); err != nil { + t.Fatal(err) + } + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPost, memberTestPath, `{"user_id":"target","role":"dns_admin"}`) + if response.Code != http.StatusInternalServerError || strings.Contains(response.Body.String(), "sensitive-marker") { + t.Fatalf("审计失败误报成功或泄露底层错误:%d,%s", response.Code, response.Body.String()) + } + var count int + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='target'").Scan(&count); err != nil || count != 0 { + t.Fatalf("审计失败仍留下成员授权:%d,%v", count, err) + } + log := f.lastAudit(t) + if log.Status != "failed" || log.OperatorID != "manager" || strings.Contains(log.Message, "sensitive-marker") { + t.Fatalf("事务回滚后失败审计缺失或泄密:%+v", log) + } + }) +} diff --git a/backend/internal/httpapi/project_members_audit.go b/backend/internal/httpapi/project_members_audit.go new file mode 100644 index 0000000..4cfad97 --- /dev/null +++ b/backend/internal/httpapi/project_members_audit.go @@ -0,0 +1,39 @@ +package httpapi + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" + + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +// 失败审计覆盖会话、来源、参数及仓储拒绝;成功审计由仓储在成员变更事务内保证。 +func (s *Server) projectMemberFailureAudit(action string) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if c.Writer.Status() < 400 || s.projectMembers == nil { + return + } + projectID := c.Param("projectID") + if !validMemberIdentifier(projectID) { + projectID = "" + } + target := c.GetString(projectMemberTargetKey) + if target == "" && validMemberIdentifier(c.Param("userID")) { + target = c.Param("userID") + } + // 客户端取消请求不能取消已发生失败的审计;独立等待有上限,不无限阻塞关闭。 + ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second) + defer cancel() + err := s.projectMembers.RecordProjectMemberFailure(ctx, repository.ProjectMemberFailure{ + ActorID: c.GetString(authenticatedActorKey), ProjectID: projectID, UserID: target, + Action: action, 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_members_test_helpers_test.go b/backend/internal/httpapi/project_members_test_helpers_test.go new file mode 100644 index 0000000..128b4da --- /dev/null +++ b/backend/internal/httpapi/project_members_test_helpers_test.go @@ -0,0 +1,73 @@ +package httpapi + +import ( + "database/sql" + "net/http" + "testing" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +type projectMemberAPIFixture struct { + server *Server + db *sql.DB + members *repository.SQLProjectMemberRepository + audit *repository.SQLAuditRepository + cookies map[string]*http.Cookie +} + +func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture { + t.Helper() + cfg := readyTestConfig(t.TempDir()) + cfg.Admin.Username = "owner" + cfg.Admin.Email = "owner@example.test" + cfg.Admin.PasswordHash = httpUserPasswordHash(t, "FixturePass123") + db, err := database.Open(t.Context(), cfg.Database) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.Migrate(t.Context(), db.DB, cfg.Database.Driver); err != nil { + t.Fatal(err) + } + users := repository.NewSQLUserRepository(db.DB, cfg.Database.Driver) + if err := users.EnsureSuperAdmin(t.Context(), repository.User{ + ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash, + }); err != nil { + t.Fatal(err) + } + projects := repository.NewSQLProjectRepository(db.DB, cfg.Database.Driver) + for _, id := range []string{"project-a", "project-b"} { + if err := projects.CreateProject(t.Context(), domain.Project{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + f := projectMemberAPIFixture{ + db: db.DB, members: repository.NewSQLProjectMemberRepository(db.DB, cfg.Database.Driver), + audit: repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver), cookies: make(map[string]*http.Cookie), + } + f.server = New(Dependencies{Config: cfg, Users: users, ProjectMembers: f.members, Audit: f.audit}) + for _, account := range []struct{ id, role string }{ + {"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser}, + {"global-admin", repository.UserRoleAdmin}, {"viewer", repository.UserRoleUser}, {"target", repository.UserRoleUser}, + } { + f.cookies[account.id] = httpUserSessionCookie(f.server, account.id, account.role) + } + for user, role := range map[string]domain.ProjectRole{"manager": domain.ProjectRoleAdmin, "viewer": domain.ProjectRoleReadOnly} { + if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", user, role); err != nil { + t.Fatal(err) + } + } + return f +} + +func (f projectMemberAPIFixture) lastAudit(t *testing.T) repository.AuditLog { + t.Helper() + logs, err := f.audit.ListAuditLogs(t.Context(), 1) + if err != nil || len(logs) != 1 { + t.Fatalf("成员审计缺失:%+v,%v", logs, err) + } + return logs[0] +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index d909bc1..793a9cc 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -48,6 +48,7 @@ type Dependencies struct { Repository repository.DomainRepository Servers repository.ServerRepository Users repository.UserRepository + ProjectMembers repository.ProjectMemberRepository SSHKeys *sshkey.Service DeploymentFactory deployment.ClientFactory SetupDNS setupDNSVerifier @@ -79,6 +80,7 @@ type Server struct { repository repository.DomainRepository servers repository.ServerRepository users repository.UserRepository + projectMembers repository.ProjectMemberRepository sshKeys *sshkey.Service deploymentFactory deployment.ClientFactory setupDNS setupDNSVerifier @@ -139,6 +141,7 @@ func New(deps Dependencies) *Server { repository: deps.Repository, setupDNS: deps.SetupDNS, secrets: deps.Secrets, firewall: deps.Firewall, router: router, servers: deps.Servers, users: deps.Users, + projectMembers: deps.ProjectMembers, sshKeys: deps.SSHKeys, deploymentFactory: deps.DeploymentFactory, issueCertificate: deps.IssueCertificate, @@ -186,6 +189,7 @@ func (s *Server) registerRoutes() { userAPI.GET("/:id", s.getUser) userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser) userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser) + s.registerProjectMemberRoutes() adminAPI.GET("/domains", s.listDomains) adminAPI.POST("/domains", s.dnsOperation, s.createDomain) adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain) diff --git a/backend/internal/repository/project_member_changes.go b/backend/internal/repository/project_member_changes.go index 084dfaf..f01e020 100644 --- a/backend/internal/repository/project_member_changes.go +++ b/backend/internal/repository/project_member_changes.go @@ -3,13 +3,18 @@ package repository import ( "context" "database/sql" + "errors" "fmt" "strings" "time" + "github.com/go-sql-driver/mysql" + "git.pcux.cn/xuans/Scert/backend/internal/domain" ) +var ErrProjectMemberConflict = errors.New("项目成员角色已存在") + // 成员写入仍在单条语句内检查权限;审计与变更共用事务,任何一步失败都不留下成功变更。 func (r *SQLProjectMemberRepository) changeProjectRole(ctx context.Context, actorID, projectID, userID string, role domain.ProjectRole, grant bool) error { if err := r.ensureDB(); err != nil { @@ -86,7 +91,24 @@ func (r *SQLProjectMemberRepository) writeProjectRole(ctx context.Context, tx *s } result, err := tx.ExecContext(ctx, Rebind(r.driver, query), args...) if err != nil { + if grant && projectMemberUniqueViolation(err) { + return ErrProjectMemberConflict + } return fmt.Errorf("变更项目角色失败:%w", err) } return projectMemberWriteResult(result) } + +// 只将成员写入的确定唯一键错误映射为冲突;审计触发器或其他约束失败不能伪装成重复授权。 +func projectMemberUniqueViolation(err error) bool { + var sqliteCode interface{ Code() int } + if errors.As(err, &sqliteCode) { + return sqliteCode.Code() == 1555 || sqliteCode.Code() == 2067 + } + var mysqlError *mysql.MySQLError + if errors.As(err, &mysqlError) { + return mysqlError.Number == 1062 + } + var sqlState interface{ SQLState() string } + return errors.As(err, &sqlState) && sqlState.SQLState() == "23505" +} diff --git a/backend/internal/repository/project_member_failures.go b/backend/internal/repository/project_member_failures.go new file mode 100644 index 0000000..0dedcc4 --- /dev/null +++ b/backend/internal/repository/project_member_failures.go @@ -0,0 +1,46 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" +) + +// ProjectMemberFailure 的操作者必须来自已验证会话快照;未登录时留空,不能从请求体读取。 +type ProjectMemberFailure struct { + ActorID string + ProjectID string + UserID string + Action string + StatusCode int + RequestID string +} + +// 失败事件独立于已回滚的变更事务,范围由实际项目解析,不将不存在的项目伪装为系统范围。 +func (r *SQLProjectMemberRepository) RecordProjectMemberFailure(ctx context.Context, failure ProjectMemberFailure) error { + if err := r.ensureDB(); err != nil { + return err + } + if failure.Action != "project_member.list" && failure.Action != "project_member.grant" && failure.Action != "project_member.revoke" { + return errors.New("项目成员失败审计动作无效") + } + if failure.StatusCode < 400 || failure.StatusCode > 599 || len(failure.ActorID) > 128 || len(failure.UserID) > 128 || len(failure.ProjectID) > 128 || len(failure.RequestID) > 128 { + return errors.New("项目成员失败审计参数无效") + } + message := fmt.Sprintf("请求被拒绝或执行失败:HTTP %d;请求编号:%s", failure.StatusCode, failure.RequestID) + 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 ?, 'project_member', ?, 'failed', ?, ?, + CASE WHEN p.id IS NULL THEN 'unknown' ELSE 'project' END, COALESCE(p.id, ''), '', ? + FROM (SELECT 1 AS marker) audit_event LEFT JOIN projects p ON p.id = ?`), + failure.Action, failure.UserID, message, time.Now().UTC(), failure.ActorID, failure.ProjectID) + if err != nil { + return fmt.Errorf("保存项目成员失败审计失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil || count != 1 { + return errors.New("项目成员失败审计未完整写入") + } + return nil +} diff --git a/backend/internal/repository/project_member_list.go b/backend/internal/repository/project_member_list.go new file mode 100644 index 0000000..cf30eab --- /dev/null +++ b/backend/internal/repository/project_member_list.go @@ -0,0 +1,100 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +// ProjectMemberRepository 不接受客户端声明的操作者角色,所有操作均重新核对实际账户与成员关系。 +type ProjectMemberRepository interface { + ListProjectMembers(context.Context, string, string, string, int) (ProjectMemberPage, error) + GrantProjectRole(context.Context, string, string, string, domain.ProjectRole) error + RevokeProjectRole(context.Context, string, string, string, domain.ProjectRole) error + RecordProjectMemberFailure(context.Context, ProjectMemberFailure) error +} + +var _ ProjectMemberRepository = (*SQLProjectMemberRepository)(nil) + +type ProjectMember struct { + UserID string `json:"user_id"` + Username string `json:"username"` + Status string `json:"status"` + Roles []domain.ProjectRole `json:"roles"` +} + +type ProjectMemberPage struct { + Items []ProjectMember `json:"items"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` +} + +// ListProjectMembers 使用成员账户而非角色关系分页,同一账户的角色不会被拆到不同页。 +// 授权门与分页读取共用一条 SQL,空列表仍返回授权门行,未授权或项目不存在则没有行。 +func (r *SQLProjectMemberRepository) ListProjectMembers(ctx context.Context, actorID, projectID, after string, limit int) (ProjectMemberPage, error) { + page := ProjectMemberPage{Items: make([]ProjectMember, 0), PageSize: limit} + if err := r.ensureDB(); err != nil { + return page, err + } + if limit < 1 || limit > 100 || len(after) > 128 { + return page, errors.New("项目成员分页参数无效") + } + if strings.TrimSpace(actorID) == "" || strings.TrimSpace(projectID) == "" { + return page, ErrProjectAccessDenied + } + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT p.id, member_page.id, member_page.username, member_page.status, roles.role + FROM projects p + LEFT JOIN ( + SELECT u.id, u.username, u.status FROM users u + WHERE u.id > ? AND EXISTS (SELECT 1 FROM project_members selected + WHERE selected.project_id = ? AND selected.user_id = u.id) + ORDER BY u.id LIMIT ? + ) member_page ON 1 = 1 + LEFT JOIN project_members roles ON roles.project_id = p.id AND roles.user_id = member_page.id + WHERE p.id = ? AND `+projectManagerPredicate+` + ORDER BY member_page.id, roles.role`), after, projectID, limit+1, projectID, actorID, projectID) + if err != nil { + return page, fmt.Errorf("查询项目成员失败:%w", err) + } + defer rows.Close() + allowed := false + for rows.Next() { + var actualProject string + var id, username, status, role sql.NullString + if err := rows.Scan(&actualProject, &id, &username, &status, &role); err != nil { + return page, fmt.Errorf("读取项目成员失败:%w", err) + } + allowed = true + if !id.Valid { + continue + } + if !role.Valid || !domain.ProjectRole(role.String).Valid() { + return ProjectMemberPage{}, errors.New("项目成员角色数据无效") + } + if len(page.Items) == 0 || page.Items[len(page.Items)-1].UserID != id.String { + page.Items = append(page.Items, ProjectMember{ + UserID: id.String, Username: username.String, Status: status.String, + Roles: make([]domain.ProjectRole, 0, 6), + }) + } + last := &page.Items[len(page.Items)-1] + last.Roles = append(last.Roles, domain.ProjectRole(role.String)) + } + if err := rows.Err(); err != nil { + return ProjectMemberPage{}, fmt.Errorf("读取项目成员结果失败:%w", err) + } + if !allowed { + return page, ErrProjectAccessDenied + } + if len(page.Items) > limit { + page.HasMore = true + page.Items = page.Items[:limit] + page.NextCursor = page.Items[limit-1].UserID + } + return page, nil +} diff --git a/backend/internal/repository/project_member_list_test.go b/backend/internal/repository/project_member_list_test.go new file mode 100644 index 0000000..25c8fa4 --- /dev/null +++ b/backend/internal/repository/project_member_list_test.go @@ -0,0 +1,161 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "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" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestProjectMemberListingSQLite(t *testing.T) { + checkProjectMemberListing(t, config.Default(t.TempDir()).Database) +} + +func TestProjectMemberListingMySQLReal(t *testing.T) { + server := mysqltest.New(t) + checkProjectMemberListing(t, server.Database(t)) +} + +func checkProjectMemberListing(t *testing.T, cfg config.DatabaseConfig) { + t.Helper() + 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) + } + projects := NewSQLProjectRepository(db.DB, cfg.Driver) + for _, id := range []string{"page-project", "empty-project"} { + if err := projects.CreateProject(t.Context(), domain.Project{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + for _, user := range []struct{ id, role string }{ + {"root", UserRoleSuperAdmin}, {"manager", UserRoleUser}, {"global-admin", UserRoleAdmin}, + } { + _, 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) + if err != nil { + t.Fatal(err) + } + } + repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver) + if err := repo.GrantProjectRole(t.Context(), "root", "page-project", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + for i := 0; i < 12; i++ { + id := fmt.Sprintf("user-%02d", i) + if _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users (id, username, password_hash, role, status) + VALUES (?, ?, 'test-only-no-login', 'user', 'active')`), id, id); err != nil { + t.Fatal(err) + } + for _, role := range []domain.ProjectRole{domain.ProjectRoleReadOnly, domain.ProjectRoleDNSAdmin} { + if err := repo.GrantProjectRole(t.Context(), "root", "page-project", id, role); err != nil { + t.Fatal(err) + } + } + } + t.Run("pagination_keeps_roles_together", func(t *testing.T) { + for _, size := range []int{1, 5, 10, 15, 30} { + after := "" + seen := make(map[string]bool) + for pages := 0; ; pages++ { + if pages > 13 { + t.Fatal("分页游标未前进") + } + page, err := repo.ListProjectMembers(t.Context(), "manager", "page-project", after, size) + if err != nil || page.Items == nil || len(page.Items) > size || page.PageSize != size { + t.Fatalf("成员分页错误:%+v,%v", page, err) + } + for _, member := range page.Items { + if seen[member.UserID] || member.Username != member.UserID || member.Status != UserStatusActive { + t.Fatalf("成员重复或身份字段错误:%+v", member) + } + seen[member.UserID] = true + if member.UserID != "manager" && (len(member.Roles) != 2 || member.Roles[0] != domain.ProjectRoleDNSAdmin || member.Roles[1] != domain.ProjectRoleReadOnly) { + t.Fatalf("多角色被拆分或顺序不稳定:%+v", member) + } + } + if !page.HasMore { + if page.NextCursor != "" || len(seen) != 13 { + t.Fatalf("末页游标或累计人数错误:%+v,%d", page, len(seen)) + } + break + } + if len(page.Items) != size || page.NextCursor != page.Items[size-1].UserID { + t.Fatalf("下一页游标错误:%+v", page) + } + after = page.NextCursor + } + } + }) + t.Run("empty_and_forbidden", func(t *testing.T) { + for _, tc := range []struct{ actor, project string }{ + {"global-admin", "page-project"}, {"user-00", "page-project"}, {"manager", "empty-project"}, {"root", "missing-project"}, {"missing-actor", "page-project"}, + } { + page, err := repo.ListProjectMembers(t.Context(), tc.actor, tc.project, "", 5) + if !errors.Is(err, ErrProjectAccessDenied) || len(page.Items) != 0 { + t.Fatalf("未授权列表泄露:%+v,%+v,%v", tc, page, err) + } + } + for _, tc := range []struct{ project, after string }{{"empty-project", ""}, {"page-project", "zzzz"}} { + page, err := repo.ListProjectMembers(t.Context(), "root", tc.project, tc.after, 5) + if err != nil || page.Items == nil || len(page.Items) != 0 || page.HasMore || page.NextCursor != "" { + t.Fatalf("有效空页被误拒绝:%+v,%v", page, err) + } + } + }) + t.Run("disabled_and_revoked", func(t *testing.T) { + if _, err := db.DB.ExecContext(t.Context(), "UPDATE users SET status='disabled' WHERE id='manager'"); err != nil { + t.Fatal(err) + } + if _, err := repo.ListProjectMembers(t.Context(), "manager", "page-project", "", 5); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("停用账户仍能读取成员:%v", err) + } + if _, err := db.DB.ExecContext(t.Context(), "UPDATE users SET status='active' WHERE id='manager'"); err != nil { + t.Fatal(err) + } + if err := repo.RevokeProjectRole(t.Context(), "manager", "page-project", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + if _, err := repo.ListProjectMembers(t.Context(), "manager", "page-project", "", 5); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("自我撤权后仍能读取成员:%v", err) + } + }) + t.Run("failure_scope_and_identity", func(t *testing.T) { + checkProjectMemberFailureRecords(t, db.DB, repo, cfg.Driver) + }) +} + +func checkProjectMemberFailureRecords(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { + t.Helper() + for _, project := range []string{"page-project", "missing-project"} { + for _, actor := range []string{"manager", ""} { + failure := ProjectMemberFailure{ActorID: actor, ProjectID: project, UserID: "user-00", Action: "project_member.grant", StatusCode: 403, RequestID: "request-test"} + if err := repo.RecordProjectMemberFailure(t.Context(), failure); err != nil { + t.Fatal(err) + } + var gotActor, scope, gotProject, message, status string + if err := db.QueryRowContext(t.Context(), "SELECT operator_id, scope_type, project_id, message, status FROM audit_logs ORDER BY id DESC LIMIT 1").Scan(&gotActor, &scope, &gotProject, &message, &status); err != nil { + t.Fatal(err) + } + wantScope, wantProject := ScopeProject, project + if project == "missing-project" { + wantScope, wantProject = ScopeUnknown, "" + } + if gotActor != actor || scope != wantScope || gotProject != wantProject || status != "failed" || message != "请求被拒绝或执行失败:HTTP 403;请求编号:request-test" { + t.Fatalf("失败审计范围或身份错误:%s %s %s %s %s", gotActor, scope, gotProject, status, message) + } + } + } + if err := repo.RecordProjectMemberFailure(t.Context(), ProjectMemberFailure{Action: "project_member.grant", StatusCode: 200}); err == nil { + t.Fatal("失败审计不应接受成功状态") + } +}