115 lines
4.8 KiB
Go
115 lines
4.8 KiB
Go
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 {
|
||
return err
|
||
}
|
||
actorID, projectID, userID = strings.TrimSpace(actorID), strings.TrimSpace(projectID), strings.TrimSpace(userID)
|
||
if actorID == "" || projectID == "" || userID == "" || !role.Valid() {
|
||
return ErrProjectAccessDenied
|
||
}
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("开始项目成员变更事务失败:%w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
if r.driver == "mysql" {
|
||
// 历史库可能使用非事务引擎,不能仅凭 BeginTx 成功就承诺审计和权限一起回滚。
|
||
var transactionalTables int
|
||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||
WHERE table_schema=DATABASE() AND table_name IN ('project_members', 'audit_logs')
|
||
AND engine='InnoDB'`).Scan(&transactionalTables); err != nil {
|
||
return fmt.Errorf("检查项目成员审计存储失败:%w", err)
|
||
}
|
||
if transactionalTables != 2 {
|
||
return fmt.Errorf("项目成员和审计表必须使用 InnoDB,拒绝无法保证回滚的权限变更")
|
||
}
|
||
}
|
||
now := time.Now().UTC()
|
||
if err := r.writeProjectRole(ctx, tx, actorID, projectID, userID, role, grant, now); err != nil {
|
||
return err
|
||
}
|
||
action, message := "project_member.revoke", "撤销项目角色:"
|
||
if grant {
|
||
action, message = "project_member.grant", "授予项目角色:"
|
||
}
|
||
// 范围来自刚通过授权条件的实际成员变更,不在自我撤权后重新推测角色或审计范围。
|
||
// 不调用使用独立连接的审计方法,以免脱离事务或阻塞单连接 SQLite。
|
||
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 (?, 'project_member', ?, 'success', ?, ?, ?, ?, '', ?)`),
|
||
action, userID, message+string(role), now, ScopeProject, projectID, actorID)
|
||
if err != nil {
|
||
return fmt.Errorf("保存项目成员变更审计失败:%w", err)
|
||
}
|
||
count, err := result.RowsAffected()
|
||
if err != nil {
|
||
return fmt.Errorf("读取项目成员审计写入结果失败:%w", err)
|
||
}
|
||
if count != 1 {
|
||
return fmt.Errorf("项目成员审计未写入,已拒绝本次权限变更")
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("提交项目成员变更失败:%w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (r *SQLProjectMemberRepository) writeProjectRole(ctx context.Context, tx *sql.Tx, actorID, projectID, userID string, role domain.ProjectRole, grant bool, now time.Time) error {
|
||
query := `DELETE FROM project_members WHERE project_id = ? AND user_id = ? AND role = ? AND ` + projectManagerPredicate
|
||
args := []any{projectID, userID, role, actorID, projectID}
|
||
if grant {
|
||
query = `INSERT INTO project_members (project_id, user_id, role, created_at)
|
||
SELECT p.id, u.id, ?, ? FROM projects p JOIN users u ON u.id = ? AND u.status = 'active'
|
||
WHERE p.id = ? AND ` + projectManagerPredicate
|
||
args = []any{role, now, userID, projectID, actorID, projectID}
|
||
} else if r.driver == "mysql" {
|
||
// MySQL 撤销使用自连接,避免不兼容的同表删除子查询。
|
||
query = `DELETE target FROM project_members target
|
||
JOIN users a ON a.id = ? AND a.status = 'active'
|
||
LEFT JOIN project_members manager ON manager.project_id = target.project_id
|
||
AND manager.user_id = a.id AND manager.role = 'project_admin'
|
||
WHERE target.project_id = ? AND target.user_id = ? AND target.role = ?
|
||
AND (a.role = 'super_admin' OR (a.role IN ('admin', 'user') AND manager.user_id IS NOT NULL))`
|
||
args = []any{actorID, projectID, userID, role}
|
||
}
|
||
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"
|
||
}
|