检查点:保存成员接口分页与失败审计专项通过候选
This commit is contained in:
parent
66422bbac0
commit
3857dac5c8
@ -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)
|
||||
|
||||
35
backend/internal/app/project_members_api_test.go
Normal file
35
backend/internal/app/project_members_api_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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": "请求来源校验失败",
|
||||
|
||||
154
backend/internal/httpapi/project_members_api.go
Normal file
154
backend/internal/httpapi/project_members_api.go
Normal file
@ -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
|
||||
}
|
||||
181
backend/internal/httpapi/project_members_api_test.go
Normal file
181
backend/internal/httpapi/project_members_api_test.go
Normal file
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
39
backend/internal/httpapi/project_members_audit.go
Normal file
39
backend/internal/httpapi/project_members_audit.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
46
backend/internal/repository/project_member_failures.go
Normal file
46
backend/internal/repository/project_member_failures.go
Normal file
@ -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
|
||||
}
|
||||
100
backend/internal/repository/project_member_list.go
Normal file
100
backend/internal/repository/project_member_list.go
Normal file
@ -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
|
||||
}
|
||||
161
backend/internal/repository/project_member_list_test.go
Normal file
161
backend/internal/repository/project_member_list_test.go
Normal file
@ -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("失败审计不应接受成功状态")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user