检查点:保存成员接口分页与失败审计专项通过候选

This commit is contained in:
xuans 2026-09-28 18:57:10 +08:00
parent 66422bbac0
commit 3857dac5c8
12 changed files with 822 additions and 2 deletions

View File

@ -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)

View 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())
}
}

View File

@ -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": "请求来源校验失败",

View 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
}

View 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)
}
})
}

View 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())
}
}
}

View File

@ -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]
}

View File

@ -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)

View File

@ -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"
}

View 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
}

View 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
}

View 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("失败审计不应接受成功状态")
}
}