Scert/backend/internal/repository/project_bindings.go
xuans 5180c7d73f 开发检查点:完成项目域名权限与事务审计接线
提交 P0-07 当前开发检查点,包含项目域名事务仓储、HTTP 路由接线、生产应用依赖及测试夹具调整。
2026-09-29 16:27:36 +08:00

206 lines
7.5 KiB
Go

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 candidate domain.Domain
if err := tx.QueryRowContext(ctx, Rebind(r.driver,
`SELECT id, project_id, name, certificate_type FROM domains WHERE id = ?`+r.resourceWriteLock()), domainID).Scan(&candidate.ID, &candidate.ProjectID, &candidate.Name, &candidate.CertificateType); err != nil {
return nil, projectBindingReadError(err)
}
projectID := candidate.ProjectID
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)
}
for _, item := range items {
if err := validateServerDomainPaths(ctx, tx, r.driver, item, &candidate); err != nil {
return nil, err
}
}
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 {
return r.authorizeProjectTransaction(ctx, tx, actorID, projectID,
domain.PermissionCertificateRead, domain.PermissionServerRead, domain.PermissionDeploymentExecute)
}
// authorizeProjectTransaction 在同一事务内锁定账户、项目和成员关系,保证判权不会在业务写入前被撤回。
func (r *SQLProjectMemberRepository) authorizeProjectTransaction(ctx context.Context, tx *sql.Tx, actorID, projectID string, required ...domain.ProjectPermission) 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 := make(map[domain.ProjectPermission]bool, len(required))
for _, permission := range required {
if !permission.Valid() {
return ErrProjectAccessDenied
}
permissions[permission] = 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 _, permission := range required {
if !permissions[permission] {
return ErrProjectAccessDenied
}
}
return nil
}