开发检查点:保存项目绑定授权与事务审计源码,待统一测试

This commit is contained in:
xuans 2026-09-28 21:33:44 +08:00
parent 709c1ef063
commit f38e290b22
10 changed files with 571 additions and 42 deletions

View File

@ -159,6 +159,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
ProjectDirectory: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectLifecycle: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectResources: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
ProjectBindings: repository.NewSQLProjectMemberRepository(db.DB, runtimeDatabaseConfig.Driver),
SetupDNS: dnsVerification, Secrets: secretStore, Firewall: firewall.NewManager(), AliyunChecker: aliyunAuthChecker,
IssueCertificate: workflow.IssueForHTTP,
IssueSetupCertificate: func(ctx context.Context, request certificate.SetupCertificateRequest) (certificate.SavedResource, error) {

View File

@ -45,6 +45,7 @@ func TestApplicationWiresProjectLifecycleAPI(t *testing.T) {
}
request(http.MethodGet, "/api/v1/servers/missing", "", http.StatusForbidden)
request(http.MethodGet, "/api/v1/certificates/missing", "", http.StatusForbidden)
request(http.MethodPut, "/api/v1/domains/missing/servers", `{"server_ids":[]}`, http.StatusForbidden)
created := request(http.MethodPost, "/api/v1/projects", `{"name":"实际应用项目","description":"接线验证"}`, 201)
var item domain.Project
if err := json.Unmarshal(created.Body.Bytes(), &item); err != nil || item.ID == "" {

View File

@ -0,0 +1,69 @@
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func (s *Server) registerProjectBindingRoutes() {
api := s.router.Group("/api/v1", privateProjectResponse)
api.PUT("/domains/:id/servers", s.projectBindingFailureAudit, s.requireAuthenticated, s.replaceProjectDomainServers)
}
func (s *Server) replaceProjectDomainServers(c *gin.Context) {
if s.projectBindings == nil {
s.writeError(c, http.StatusServiceUnavailable, "项目绑定服务尚未就绪", nil)
return
}
var input struct {
ServerIDs *[]string `json:"server_ids"`
}
if !validMemberIdentifier(c.Param("id")) || c.Request.URL.RawQuery != "" ||
bindJSON(c, &input, jsonRequestBodyLimit) != nil || input.ServerIDs == nil || len(*input.ServerIDs) > repository.MaxProjectBindingServers {
s.writeError(c, http.StatusBadRequest, repository.ErrProjectBindingInput.Error(), nil)
return
}
for _, id := range *input.ServerIDs {
if !validMemberIdentifier(id) {
s.writeError(c, http.StatusBadRequest, repository.ErrProjectBindingInput.Error(), nil)
return
}
}
items, err := s.projectBindings.ReplaceProjectDomainServers(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), *input.ServerIDs)
switch {
case errors.Is(err, repository.ErrProjectAccessDenied):
s.writeError(c, http.StatusForbidden, "无权修改此项目的服务器绑定", nil)
case errors.Is(err, repository.ErrProjectBindingInput):
s.writeError(c, http.StatusBadRequest, err.Error(), nil)
case err != nil:
s.writeError(c, http.StatusInternalServerError, "保存项目服务器绑定失败,请刷新后核对", nil)
default:
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func (s *Server) projectBindingFailureAudit(c *gin.Context) {
c.Next()
if c.Writer.Status() < 400 || s.projectBindings == nil {
return
}
domainID := c.Param("id")
if !validMemberIdentifier(domainID) {
domainID = ""
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 3*time.Second)
defer cancel()
err := s.projectBindings.RecordProjectBindingFailure(ctx, repository.ProjectBindingFailure{
ActorID: c.GetString(authenticatedActorKey), DomainID: domainID,
StatusCode: c.Writer.Status(), RequestID: requestIDFromContext(c),
})
if err != nil && s.logger != nil {
s.logger.Error("项目绑定失败审计未保存", "request_id", requestIDFromContext(c), "status", c.Writer.Status())
}
}

View File

@ -0,0 +1,140 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
func TestProjectBindingAPIPermissionsAndExplicitClear(t *testing.T) {
f, _ := newProjectResourceAPIFixture(t)
path := "/api/v1/domains/domain-a/servers"
for _, tc := range []struct {
actor string
code int
}{
{"manager", 200}, {"primary-super-admin", 200}, {"viewer", 403}, {"global-admin", 403}, {"target", 403},
} {
response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodPut, path, `{"server_ids":["server-a","server-a"]}`)
if response.Code != tc.code || response.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("绑定权限或缓存错误:%s,%d,%s", tc.actor, response.Code, response.Body.String())
}
entry := f.lastAudit(t)
if entry.Action != "domain.servers.replace" || entry.ProjectID != "project-a" || entry.OperatorID != tc.actor || strings.Contains(entry.Message, "server_ids") {
t.Fatalf("绑定审计不正确或包含请求体:%+v", entry)
}
}
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, path, `{"server_ids":[]}`)
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"items":[]`) {
t.Fatalf("清空响应错误:%d,%s", response.Code, response.Body.String())
}
items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a")
if err != nil || len(items) != 0 {
t.Fatalf("绑定未清空:%+v,%v", items, err)
}
}
func TestProjectBindingAPIRejectsForgeryAndKeepsOldBindings(t *testing.T) {
f, _ := newProjectResourceAPIFixture(t)
for _, tc := range []struct {
id, query, body string
code int
}{
{"domain-a", "", `{}`, 400},
{"domain-a", "", `{"server_ids":null}`, 400},
{"domain-a", "", `{"server_ids":[],"project_id":"project-b"}`, 400},
{"domain-a", "?actor_id=primary-super-admin", `{"server_ids":[]}`, 400},
{"domain-a", "", `{"server_ids":["../server"]}`, 400},
{"domain-a", "", `{"server_ids":["server-b"]}`, 403},
{"domain-a", "", `{"server_ids":["missing"]}`, 403},
{"domain-b", "", `{"server_ids":[]}`, 403},
{"missing", "", `{"server_ids":[]}`, 403},
} {
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/domains/"+tc.id+"/servers"+tc.query, tc.body)
if response.Code != tc.code {
t.Fatalf("无效或越权请求未拒绝:%s,%d,%s", tc.id, response.Code, response.Body.String())
}
items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a")
if err != nil || len(items) != 1 || items[0].ID != "server-a" {
t.Fatalf("拒绝请求改变了原绑定:%+v,%v", items, err)
}
entry := f.lastAudit(t)
wantProject := "project-a"
if tc.id == "domain-b" {
wantProject = "project-b"
} else if tc.id == "missing" {
wantProject = ""
}
if entry.ProjectID != wantProject || entry.OperatorID != "manager" || entry.Status != "failed" {
t.Fatalf("失败审计未按真实资源归属记录:%+v", entry)
}
}
}
type bindingWriteBoundary struct {
repository.ProjectBindingRepository
before func()
}
func (b bindingWriteBoundary) ReplaceProjectDomainServers(ctx context.Context, actor, id string, ids []string) ([]deployment.Server, error) {
b.before()
return b.ProjectBindingRepository.ReplaceProjectDomainServers(ctx, actor, id, ids)
}
func TestProjectBindingAPIRechecksAfterSessionAndFailsClosed(t *testing.T) {
for _, mode := range []string{"revoke", "disable", "missing_dependency"} {
t.Run(mode, func(t *testing.T) {
f, _ := newProjectResourceAPIFixture(t)
f.server.projectBindings = bindingWriteBoundary{ProjectBindingRepository: f.members, before: func() {
if mode == "revoke" {
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
} else if _, err := f.db.ExecContext(t.Context(), `UPDATE users SET status='disabled' WHERE id='manager'`); err != nil {
t.Fatal(err)
}
}}
want := http.StatusForbidden
if mode == "missing_dependency" {
f.server.projectBindings = nil
want = http.StatusServiceUnavailable
}
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodPut, "/api/v1/domains/domain-a/servers", `{"server_ids":[]}`)
if response.Code != want {
t.Fatalf("会话后撤权或缺少依赖未拒绝:%d,%s", response.Code, response.Body.String())
}
items, err := f.server.servers.ListServersByDomainID(t.Context(), "domain-a")
if err != nil || len(items) != 1 {
t.Fatalf("失败后丢失原绑定:%+v,%v", items, err)
}
})
}
}
func TestProjectBindingAPIAuthenticationAndOrigin(t *testing.T) {
f, _ := newProjectResourceAPIFixture(t)
for _, authenticated := range []bool{false, true} {
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-a/servers", strings.NewReader(`{"server_ids":[]}`))
request.Header.Set("Content-Type", "application/json")
want := http.StatusUnauthorized
if authenticated {
request.AddCookie(f.cookies["manager"])
request.Header.Set("Origin", "https://foreign.example.test")
want = http.StatusForbidden
}
response := httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" {
t.Fatalf("未登录或跨来源未拒绝:%d,%s", response.Code, response.Body.String())
}
if entry := f.lastAudit(t); entry.Status != "failed" || entry.Action != "domain.servers.replace" {
t.Fatalf("认证失败缺少审计:%+v", entry)
}
}
}

View File

@ -50,7 +50,7 @@ func newProjectMemberAPIFixture(t *testing.T) projectMemberAPIFixture {
}
f.server = New(Dependencies{
Config: cfg, Users: users, ProjectMembers: f.members, ProjectDirectory: f.members,
ProjectLifecycle: f.members, ProjectResources: f.members, Audit: f.audit,
ProjectLifecycle: f.members, ProjectResources: f.members, ProjectBindings: f.members, Audit: f.audit,
})
for _, account := range []struct{ id, role string }{
{"primary-super-admin", repository.UserRoleSuperAdmin}, {"manager", repository.UserRoleUser},

View File

@ -52,6 +52,7 @@ type Dependencies struct {
ProjectDirectory repository.ProjectDirectoryRepository
ProjectLifecycle repository.ProjectLifecycleRepository
ProjectResources repository.ProjectResourceReader
ProjectBindings repository.ProjectBindingRepository
SSHKeys *sshkey.Service
DeploymentFactory deployment.ClientFactory
SetupDNS setupDNSVerifier
@ -87,6 +88,7 @@ type Server struct {
projectDirectory repository.ProjectDirectoryRepository
projectLifecycle repository.ProjectLifecycleRepository
projectResources repository.ProjectResourceReader
projectBindings repository.ProjectBindingRepository
sshKeys *sshkey.Service
deploymentFactory deployment.ClientFactory
setupDNS setupDNSVerifier
@ -151,6 +153,7 @@ func New(deps Dependencies) *Server {
projectDirectory: deps.ProjectDirectory,
projectLifecycle: deps.ProjectLifecycle,
projectResources: deps.ProjectResources,
projectBindings: deps.ProjectBindings,
sshKeys: deps.SSHKeys,
deploymentFactory: deps.DeploymentFactory,
issueCertificate: deps.IssueCertificate,
@ -201,6 +204,7 @@ func (s *Server) registerRoutes() {
s.registerProjectMemberRoutes()
s.registerProjectLifecycleRoutes()
s.registerProjectResourceRoutes()
s.registerProjectBindingRoutes()
s.router.GET("/api/v1/projects", privateProjectResponse, s.requireAuthenticated, s.listVisibleProjects)
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
@ -222,7 +226,6 @@ func (s *Server) registerRoutes() {
adminAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate)
adminAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent)
adminAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
adminAPI.PUT("/domains/:id/servers", s.replaceDomainServers)
adminAPI.POST("/servers", s.createServer)
adminAPI.PUT("/servers/:id", s.updateServer)
adminAPI.DELETE("/servers/:id", s.deleteServer)
@ -402,10 +405,6 @@ type serverRequest struct {
ReloadCommand []string `json:"reload_command"`
}
type domainServersRequest struct {
ServerIDs []string `json:"server_ids"`
}
func (s *Server) health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "scert"})
}
@ -964,39 +963,6 @@ func (s *Server) listDomainServers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (s *Server) replaceDomainServers(c *gin.Context) {
if !s.requireDomainRepository(c) {
return
}
if s.servers == nil {
s.writeError(c, http.StatusNotImplemented, "服务器管理接口尚未配置", errors.New("服务器仓储未配置"))
return
}
if _, err := s.repository.GetByID(c.Request.Context(), c.Param("id")); err != nil {
if repository.IsNotFound(err) {
s.writeError(c, http.StatusNotFound, "域名不存在", err)
return
}
s.writeError(c, http.StatusInternalServerError, "读取域名失败", err)
return
}
var request domainServersRequest
if err := bindJSON(c, &request, jsonRequestBodyLimit); err != nil {
s.writeError(c, http.StatusBadRequest, "请求参数无效", err)
return
}
if err := s.servers.ReplaceDomainServers(c.Request.Context(), c.Param("id"), request.ServerIDs); err != nil {
s.writeError(c, serverErrorStatus(err), "保存域名部署服务器失败", err)
return
}
items, err := s.servers.ListServersByDomainID(c.Request.Context(), c.Param("id"))
if err != nil {
s.writeError(c, http.StatusInternalServerError, "读取域名部署服务器失败", err)
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func serverFromRequest(request serverRequest) deployment.Server {
return deployment.Server{
ID: domain.NewID(), Name: request.Name, Address: request.Address, Port: request.Port,

View File

@ -0,0 +1,32 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
)
// 失败范围由实际域名解析;不存在的资源保留未知范围,不相信请求中的项目或操作者。
func (r *SQLProjectMemberRepository) RecordProjectBindingFailure(ctx context.Context, failure ProjectBindingFailure) error {
if err := r.ensureDB(); err != nil {
return err
}
if failure.StatusCode < 400 || failure.StatusCode > 599 || len(failure.ActorID) > 128 || len(failure.DomainID) > 128 || len(failure.RequestID) > 128 {
return errors.New("项目绑定失败审计参数无效")
}
result, err := r.db.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
SELECT 'domain.servers.replace', 'domain', ?, 'failed', ?, ?,
CASE WHEN d.id IS NULL THEN 'unknown' ELSE 'project' END, COALESCE(d.project_id, ''), '', ?
FROM (SELECT 1 AS marker) audit_event LEFT JOIN domains d ON d.id = ?`),
failure.DomainID, fmt.Sprintf("绑定请求被拒绝或执行失败:HTTP %d;请求编号:%s", failure.StatusCode, failure.RequestID),
time.Now().UTC(), failure.ActorID, failure.DomainID)
if err != nil {
return fmt.Errorf("保存项目绑定失败审计失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return errors.New("项目绑定失败审计未写入")
}
return nil
}

View File

@ -0,0 +1,191 @@
package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
const MaxProjectBindingServers = 100
var ErrProjectBindingInput = errors.New("服务器列表必须明确提供,最多包含 100 个有效服务器标识")
type ProjectBindingFailure struct {
ActorID string
DomainID string
StatusCode int
RequestID string
}
type ProjectBindingRepository interface {
ReplaceProjectDomainServers(context.Context, string, string, []string) ([]deployment.Server, error)
RecordProjectBindingFailure(context.Context, ProjectBindingFailure) error
}
var _ ProjectBindingRepository = (*SQLProjectMemberRepository)(nil)
// 绑定变更只负责保存关联,不部署证书、不连接 SSH、不接收客户端的项目归属。
func (r *SQLProjectMemberRepository) ReplaceProjectDomainServers(ctx context.Context, actorID, domainID string, serverIDs []string) ([]deployment.Server, error) {
if err := r.resourceReaderReady(actorID); err != nil {
return nil, err
}
if domainID == "" || len(domainID) > 128 {
return nil, ErrProjectAccessDenied
}
if serverIDs == nil || len(serverIDs) > MaxProjectBindingServers {
return nil, ErrProjectBindingInput
}
ids := make([]string, 0, len(serverIDs))
seen := make(map[string]bool, len(serverIDs))
for _, id := range serverIDs {
if id == "" || len(id) > 128 || strings.TrimSpace(id) != id {
return nil, ErrProjectBindingInput
}
if !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
// 所有请求按相同次序锁定服务器,降低反向选择时的死锁概率。
sort.Strings(ids)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("开始项目绑定事务失败:%w", err)
}
defer tx.Rollback()
if r.driver == "mysql" {
var count int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema=DATABASE() AND table_name IN
('users', 'projects', 'project_members', 'domains', 'servers', 'domain_servers', 'audit_logs')
AND engine='InnoDB'`).Scan(&count); err != nil {
return nil, fmt.Errorf("检查项目绑定存储失败:%w", err)
}
if count != 7 {
return nil, errors.New("项目绑定及授权审计相关表必须使用 InnoDB")
}
}
var projectID string
if err := tx.QueryRowContext(ctx, Rebind(r.driver,
`SELECT project_id FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&projectID); err != nil {
return nil, projectBindingReadError(err)
}
if err := r.authorizeBindingTransaction(ctx, tx, actorID, projectID); err != nil {
return nil, err
}
items := make([]deployment.Server, 0, len(ids))
for _, id := range ids {
item, err := scanServer(tx.QueryRowContext(ctx, Rebind(r.driver,
serverSelectQualified+` WHERE servers.id = ?`+r.resourceWriteLock()), id))
if err != nil {
return nil, projectBindingReadError(err)
}
if item.ProjectID != projectID {
return nil, ErrProjectAccessDenied
}
items = append(items, item)
}
if _, err := tx.ExecContext(ctx, Rebind(r.driver, `DELETE FROM domain_servers WHERE domain_id = ?`), domainID); err != nil {
return nil, fmt.Errorf("清理项目绑定失败:%w", err)
}
for _, id := range ids {
result, err := tx.ExecContext(ctx, Rebind(r.driver,
`INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, ?)`), domainID, id, projectID)
if err != nil {
return nil, fmt.Errorf("保存项目绑定失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return nil, errors.New("项目绑定未完整写入,已回滚本次变更")
}
}
result, err := tx.ExecContext(ctx, Rebind(r.driver, `INSERT INTO audit_logs
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
VALUES ('domain.servers.replace', 'domain', ?, 'success', ?, ?, 'project', ?, '', ?)`),
domainID, fmt.Sprintf("已更新部署服务器绑定,共 %d 台", len(ids)), time.Now().UTC(), projectID, actorID)
if err != nil {
return nil, fmt.Errorf("保存项目绑定审计失败:%w", err)
}
if count, err := result.RowsAffected(); err != nil || count != 1 {
return nil, errors.New("项目绑定审计未写入,已回滚本次变更")
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("提交项目绑定失败:%w", err)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Name == items[j].Name {
return items[i].ID < items[j].ID
}
return items[i].Name < items[j].Name
})
return items, nil
}
func (r *SQLProjectMemberRepository) resourceWriteLock() string {
if r.driver == "mysql" || r.driver == "postgres" {
return " FOR UPDATE"
}
return ""
}
func projectBindingReadError(err error) error {
if errors.Is(err, sql.ErrNoRows) {
return ErrProjectAccessDenied
}
return fmt.Errorf("读取项目绑定资源失败:%w", err)
}
// 使用事务内的当前读取并持有账户、项目和现有成员行锁,避免撤权或停用后继续写入。
// SQLite 使用自身事务串行写入保证;遇到并发升级失败时回滚,不绕过授权重试。
func (r *SQLProjectMemberRepository) authorizeBindingTransaction(ctx context.Context, tx *sql.Tx, actorID, projectID string) error {
var status, userRole, existingProject string
if err := tx.QueryRowContext(ctx, Rebind(r.driver,
`SELECT status, role FROM users WHERE id = ?`+r.resourceWriteLock()), actorID).Scan(&status, &userRole); err != nil {
return projectBindingReadError(err)
}
if status != UserStatusActive || (userRole != UserRoleSuperAdmin && userRole != UserRoleAdmin && userRole != UserRoleUser) {
return ErrProjectAccessDenied
}
if err := tx.QueryRowContext(ctx, Rebind(r.driver,
`SELECT id FROM projects WHERE id = ?`+r.resourceWriteLock()), projectID).Scan(&existingProject); err != nil {
return projectBindingReadError(err)
}
if userRole == UserRoleSuperAdmin {
return nil
}
rows, err := tx.QueryContext(ctx, Rebind(r.driver,
`SELECT role FROM project_members WHERE project_id = ? AND user_id = ? ORDER BY role`+r.resourceWriteLock()), projectID, actorID)
if err != nil {
return fmt.Errorf("读取绑定操作权限失败:%w", err)
}
defer rows.Close()
permissions := map[domain.ProjectPermission]bool{
domain.PermissionCertificateRead: false,
domain.PermissionServerRead: false,
domain.PermissionDeploymentExecute: false,
}
for rows.Next() {
var role domain.ProjectRole
if err := rows.Scan(&role); err != nil {
return err
}
for permission := range permissions {
permissions[permission] = permissions[permission] || role.Allows(permission)
}
}
if err := rows.Err(); err != nil {
return err
}
for _, allowed := range permissions {
if !allowed {
return ErrProjectAccessDenied
}
}
return nil
}

View File

@ -0,0 +1,124 @@
package repository
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
)
// 复用真实资源与六类项目成员夹具;由 SQLite 和隔离 MySQL 入口分别调用。
func checkProjectBindings(t *testing.T, db, admin *sql.DB, repo *SQLProjectMemberRepository, driver string) {
t.Helper()
unchanged := func(t *testing.T) {
t.Helper()
items, err := NewSQLServerRepository(db, driver).ListServersByDomainID(t.Context(), "domain-a")
if err != nil || len(items) != 1 || items[0].ID != "server-a" {
t.Fatalf("原绑定未保留:%+v,%v", items, err)
}
}
t.Run("bindings_role_matrix_and_audit", func(t *testing.T) {
for _, actor := range []string{"root", "manager", "deploy", "cert", "dns", "audit", "reader", "outsider", "missing"} {
items, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{"server-a", "server-a"})
allowed := actor == "root" || actor == "manager" || actor == "deploy"
if !allowed {
if !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("未授权角色仍可写绑定:%s,%v", actor, err)
}
continue
}
if err != nil || len(items) != 1 || items[0].ID != "server-a" {
t.Fatalf("授权绑定失败或未去重:%s,%+v,%v", actor, items, err)
}
var count int
if err := db.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs
WHERE action='domain.servers.replace' AND status='success' AND project_id='project-a'
AND resource_id='domain-a' AND operator_id=?`), actor).Scan(&count); err != nil || count != 1 {
t.Fatalf("成功审计范围或操作者错误:%s,%d,%v", actor, count, err)
}
}
unchanged(t)
})
t.Run("bindings_cross_scope_and_unknown_preserve_existing", func(t *testing.T) {
for _, actor := range []string{"manager", "root"} {
for _, id := range []string{"server-b", "missing", "server-a' OR 1=1 --"} {
if _, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{"server-a", id}); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权、未知或注入资源未拒绝:%s,%s,%v", actor, id, err)
}
unchanged(t)
}
}
for _, id := range []string{"domain-b", "missing"} {
if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", id, []string{}); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("未知或越权域名允许清空:%s,%v", id, err)
}
}
})
t.Run("bindings_explicit_empty_and_invalid_input", func(t *testing.T) {
items, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{})
if err != nil || items == nil || len(items) != 0 {
t.Fatalf("明确清空未返回空列表:%+v,%v", items, err)
}
loaded, err := NewSQLServerRepository(db, driver).ListServersByDomainID(t.Context(), "domain-a")
if err != nil || len(loaded) != 0 {
t.Fatalf("清空未持久化:%+v,%v", loaded, err)
}
if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{"server-a"}); err != nil {
t.Fatal(err)
}
for _, ids := range [][]string{nil, {""}, {" server-a"}, make([]string, MaxProjectBindingServers+1)} {
if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", ids); !errors.Is(err, ErrProjectBindingInput) {
t.Fatalf("非法列表未拒绝:%v", err)
}
unchanged(t)
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if _, err := repo.ReplaceProjectDomainServers(ctx, "root", "domain-a", []string{}); err == nil {
t.Fatal("取消后仍修改了绑定")
}
unchanged(t)
})
t.Run("bindings_audit_failure_rolls_back", func(t *testing.T) {
executor := db
triggerName := "reject_binding_audit"
trigger := `CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs
BEGIN SELECT RAISE(ABORT, 'audit-failure'); END`
if driver == "mysql" {
var schema string
if err := db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil {
t.Fatal(err)
}
// 仅隔离测试管理员建触发器,不提升应用账户权限。
triggerName = "`" + strings.ReplaceAll(schema, "`", "``") + "`.reject_binding_audit"
trigger = "CREATE TRIGGER " + triggerName + " BEFORE INSERT ON `" + strings.ReplaceAll(schema, "`", "``") +
"`.audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'"
executor = admin
}
if _, err := executor.ExecContext(t.Context(), trigger); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if _, err := executor.ExecContext(context.Background(), "DROP TRIGGER "+triggerName); err != nil {
t.Error(err)
}
})
if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{}); err == nil {
t.Fatal("审计失败时仍提交了清空操作")
}
unchanged(t)
})
if driver == "sqlite" {
t.Run("bindings_ignored_audit_rolls_back", func(t *testing.T) {
if _, err := db.ExecContext(t.Context(), `CREATE TRIGGER ignore_binding_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(IGNORE); END`); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = db.ExecContext(context.Background(), "DROP TRIGGER ignore_binding_audit") })
if _, err := repo.ReplaceProjectDomainServers(t.Context(), "manager", "domain-a", []string{}); err == nil {
t.Fatal("审计未落库仍返回成功")
}
unchanged(t)
})
}
}

View File

@ -2,6 +2,7 @@ package repository
import (
"context"
"database/sql"
"errors"
"testing"
@ -13,15 +14,15 @@ import (
)
func TestProjectResourcesSQLite(t *testing.T) {
checkProjectResources(t, config.Default(t.TempDir()).Database)
checkProjectResources(t, config.Default(t.TempDir()).Database, nil)
}
func TestProjectResourcesMySQLReal(t *testing.T) {
server := mysqltest.New(t)
checkProjectResources(t, server.Database(t))
checkProjectResources(t, server.Database(t), server.Admin())
}
func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) {
func checkProjectResources(t *testing.T, cfg config.DatabaseConfig, admin *sql.DB) {
t.Helper()
db, err := database.Open(t.Context(), cfg)
if err != nil {
@ -135,6 +136,7 @@ func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) {
t.Fatalf("越权绑定查询未拒绝:%v", err)
}
})
checkProjectBindings(t, db.DB, admin, repo, cfg.Driver)
t.Run("revocation_and_disabled_account_are_not_cached", func(t *testing.T) {
if err := repo.RevokeProjectRole(t.Context(), "root", "project-a", "reader", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
@ -146,6 +148,9 @@ func checkProjectResources(t *testing.T, cfg config.DatabaseConfig) {
t.Fatal(err)
}
for _, actor := range []string{"reader", "manager"} {
if _, err := repo.ReplaceProjectDomainServers(t.Context(), actor, "domain-a", []string{}); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("撤权或停用后仍可修改绑定:%s,%v", actor, err)
}
items, err := repo.ListVisibleServers(t.Context(), actor)
if err != nil || len(items) != 0 {
t.Fatalf("撤权或停用后仍有资源:%s,%+v,%v", actor, items, err)