检查点:保存成员事务审计待验证实现并开始修复回归阻塞
This commit is contained in:
parent
0c46f5fdef
commit
83a3ada166
92
backend/internal/repository/project_member_changes.go
Normal file
92
backend/internal/repository/project_member_changes.go
Normal file
@ -0,0 +1,92 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
// 成员写入仍在单条语句内检查权限;审计与变更共用事务,任何一步失败都不留下成功变更。
|
||||
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 {
|
||||
return fmt.Errorf("变更项目角色失败:%w", err)
|
||||
}
|
||||
return projectMemberWriteResult(result)
|
||||
}
|
||||
296
backend/internal/repository/project_member_changes_test.go
Normal file
296
backend/internal/repository/project_member_changes_test.go
Normal file
@ -0,0 +1,296 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type memberChangeFixture struct {
|
||||
db *sql.DB
|
||||
repo *SQLProjectMemberRepository
|
||||
driver string
|
||||
admin *sql.DB
|
||||
}
|
||||
|
||||
func TestProjectMemberChangesSQLite(t *testing.T) {
|
||||
checkProjectMemberChanges(t, func(t *testing.T) config.DatabaseConfig {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
cfg.DSN = filepath.Join(t.TempDir(), "member-audit.db")
|
||||
return cfg
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func TestProjectMemberChangesMySQLReal(t *testing.T) {
|
||||
server := mysqltest.New(t)
|
||||
checkProjectMemberChanges(t, server.Database, server.Admin())
|
||||
}
|
||||
|
||||
func checkProjectMemberChanges(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig, admin *sql.DB) {
|
||||
t.Helper()
|
||||
run := func(name string, test func(*testing.T, memberChangeFixture)) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := makeConfig(t)
|
||||
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)
|
||||
if err := projects.CreateProject(t.Context(), domain.Project{ID: "audit-project", Name: "审计测试项目"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range []struct{ id, role, status string }{
|
||||
{"audit-root", UserRoleSuperAdmin, UserStatusActive},
|
||||
{"audit-manager", UserRoleUser, UserStatusActive},
|
||||
{"audit-target", UserRoleUser, UserStatusActive},
|
||||
{"audit-disabled", UserRoleUser, UserStatusDisabled},
|
||||
} {
|
||||
_, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
|
||||
(id, username, email, password_hash, role, status) VALUES (?, ?, ?, 'test-only-no-login', ?, ?)`),
|
||||
u.id, u.id, u.id+"@example.invalid", u.role, u.status)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 夹具直接准备初始管理员,待验证的业务变更一律走正式仓储方法。
|
||||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role)
|
||||
VALUES ('audit-project', 'audit-manager', 'project_admin')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test(t, memberChangeFixture{db: db.DB, repo: NewSQLProjectMemberRepository(db.DB, cfg.Driver), driver: cfg.Driver, admin: admin})
|
||||
})
|
||||
}
|
||||
memberChangeSuccessCases(t, run)
|
||||
memberChangeFailureCases(t, run)
|
||||
memberChangeHistoryCases(t, run)
|
||||
}
|
||||
|
||||
func memberChangeSuccessCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
|
||||
t.Helper()
|
||||
run("grant_and_revoke", func(t *testing.T, f memberChangeFixture) {
|
||||
before := time.Now().UTC().Add(-time.Second)
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMemberAudit(t, f, 2, 0)
|
||||
rows, err := f.db.QueryContext(t.Context(), `SELECT action, resource_type, resource_id, status, message,
|
||||
created_at, scope_type, project_id, task_id, operator_id FROM audit_logs ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for _, action := range []string{"project_member.grant", "project_member.revoke"} {
|
||||
var item AuditLog
|
||||
if !rows.Next() {
|
||||
t.Fatalf("缺少成员审计:%s", action)
|
||||
}
|
||||
if err := rows.Scan(&item.Action, &item.ResourceType, &item.ResourceID, &item.Status, &item.Message,
|
||||
&item.CreatedAt, &item.ScopeType, &item.ProjectID, &item.TaskID, &item.OperatorID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Action != action || item.ResourceType != "project_member" || item.ResourceID != "audit-target" ||
|
||||
item.Status != "success" || item.ScopeType != ScopeProject || item.ProjectID != "audit-project" ||
|
||||
item.OperatorID != "audit-manager" || item.TaskID != "" || !strings.Contains(item.Message, "dns_admin") ||
|
||||
item.CreatedAt.Before(before) || item.CreatedAt.After(time.Now().UTC().Add(time.Second)) {
|
||||
t.Fatalf("成员审计范围、角色或身份错误:%+v", item)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
run("no_success_on_rejection", func(t *testing.T, f memberChangeFixture) {
|
||||
for _, actor := range []string{"audit-target", "audit-disabled", "missing"} {
|
||||
if err := f.repo.GrantProjectRole(t.Context(), actor, "audit-project", "audit-target", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("无权限的授予未拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
|
||||
t.Fatal("重复授予意外成功")
|
||||
}
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("不存在的角色撤销未拒绝:%v", err)
|
||||
}
|
||||
assertMemberAudit(t, f, 1, 1)
|
||||
})
|
||||
}
|
||||
|
||||
func memberChangeFailureCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
|
||||
t.Helper()
|
||||
run("grant_audit_failure", func(t *testing.T, f memberChangeFixture) {
|
||||
failMemberAudit(t, f)
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
|
||||
t.Fatal("审计失败仍返回授予成功")
|
||||
}
|
||||
assertMemberAudit(t, f, 0, 0)
|
||||
})
|
||||
run("revoke_audit_failure", func(t *testing.T, f memberChangeFixture) {
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failMemberAudit(t, f)
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
|
||||
t.Fatal("审计失败仍返回撤销成功")
|
||||
}
|
||||
assertMemberAudit(t, f, 1, 1)
|
||||
})
|
||||
run("self_revoke_and_recovery", func(t *testing.T, f memberChangeFixture) {
|
||||
restore := failMemberAudit(t, f)
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err == nil {
|
||||
t.Fatal("审计失败仍撤销了自己的角色")
|
||||
}
|
||||
if err := f.repo.AuthorizeProject(t.Context(), "audit-manager", "audit-project", domain.PermissionMembersManage); err != nil {
|
||||
t.Fatalf("审计失败没有恢复原权限:%v", err)
|
||||
}
|
||||
restore()
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("自我撤权后仍可恢复:%v", err)
|
||||
}
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMemberAudit(t, f, 2, 0)
|
||||
})
|
||||
run("audit_storage_fail_closed", func(t *testing.T, f memberChangeFixture) {
|
||||
query := `CREATE TRIGGER ignore_member_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(IGNORE); END`
|
||||
if f.driver == "mysql" {
|
||||
query = "ALTER TABLE audit_logs ENGINE=MyISAM"
|
||||
}
|
||||
if _, err := f.db.ExecContext(t.Context(), query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
|
||||
t.Fatal("审计被忽略或不可回滚时仍接受了成员变更")
|
||||
}
|
||||
assertMemberAudit(t, f, 0, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func memberChangeHistoryCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
|
||||
t.Helper()
|
||||
run("concurrent_duplicate_changes", func(t *testing.T, f memberChangeFixture) {
|
||||
for _, change := range []func(context.Context, string, string, string, domain.ProjectRole) error{
|
||||
f.repo.GrantProjectRole, f.repo.RevokeProjectRole,
|
||||
} {
|
||||
var workers sync.WaitGroup
|
||||
results := make(chan error, 6)
|
||||
for range 6 {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
results <- change(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly)
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(results)
|
||||
succeeded := 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
if succeeded != 1 {
|
||||
t.Fatalf("并发重复变更成功次数错误:%d", succeeded)
|
||||
}
|
||||
}
|
||||
assertMemberAudit(t, f, 2, 0)
|
||||
})
|
||||
run("history_survives_deletion", func(t *testing.T, f memberChangeFixture) {
|
||||
for _, change := range []func(context.Context, string, string, string, domain.ProjectRole) error{
|
||||
f.repo.GrantProjectRole, f.repo.RevokeProjectRole,
|
||||
} {
|
||||
if err := change(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := f.repo.RevokeProjectRole(t.Context(), "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := NewSQLProjectRepository(f.db, f.driver).DeleteProject(t.Context(), "audit-project"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.db.ExecContext(t.Context(), "DELETE FROM users WHERE id IN ('audit-root', 'audit-target')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMemberAudit(t, f, 3, 0)
|
||||
var count int
|
||||
if err := f.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE scope_type='project'
|
||||
AND project_id='audit-project' AND operator_id='audit-root'`).Scan(&count); err != nil || count != 3 {
|
||||
t.Fatalf("历史项目或操作者快照丢失:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
run("canceled_context", func(t *testing.T, f memberChangeFixture) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err := f.repo.GrantProjectRole(ctx, "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
|
||||
t.Fatal("已取消的授予仍然成功")
|
||||
}
|
||||
if err := f.repo.RevokeProjectRole(ctx, "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err == nil {
|
||||
t.Fatal("已取消的撤销仍然成功")
|
||||
}
|
||||
assertMemberAudit(t, f, 0, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func failMemberAudit(t *testing.T, f memberChangeFixture) func() {
|
||||
t.Helper()
|
||||
executor := f.db
|
||||
drop := "DROP TRIGGER fail_project_member_audit"
|
||||
query := `CREATE TRIGGER fail_project_member_audit BEFORE INSERT ON audit_logs
|
||||
WHEN NEW.resource_type='project_member' BEGIN SELECT RAISE(ABORT, 'test-only-audit-failure'); END`
|
||||
if f.driver == "mysql" {
|
||||
// 故障触发器由隔离实例管理员创建,不提升业务账户权限或放宽二进制日志策略。
|
||||
var schema string
|
||||
if err := f.db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`"
|
||||
executor = f.admin
|
||||
query = `CREATE TRIGGER ` + schema + `.fail_project_member_audit BEFORE INSERT ON ` + schema + `.audit_logs FOR EACH ROW
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test-only-audit-failure'`
|
||||
drop = "DROP TRIGGER " + schema + ".fail_project_member_audit"
|
||||
}
|
||||
if _, err := executor.ExecContext(t.Context(), query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() {
|
||||
t.Helper()
|
||||
if _, err := executor.ExecContext(t.Context(), drop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMemberAudit(t *testing.T, f memberChangeFixture, auditCount, memberCount int) {
|
||||
t.Helper()
|
||||
var actual int
|
||||
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs").Scan(&actual); err != nil || actual != auditCount {
|
||||
t.Fatalf("审计数量错误,期望 %d,实际 %d:%v", auditCount, actual, err)
|
||||
}
|
||||
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='audit-target'").Scan(&actual); err != nil || actual != memberCount {
|
||||
t.Fatalf("成员数量错误,期望 %d,实际 %d:%v", memberCount, actual, err)
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
@ -90,50 +89,11 @@ const projectManagerPredicate = `EXISTS (SELECT 1 FROM users a WHERE a.id = ? AN
|
||||
AND manager.user_id = a.id AND manager.role = 'project_admin'))))`
|
||||
|
||||
func (r *SQLProjectMemberRepository) GrantProjectRole(ctx context.Context, actorID, projectID, userID string, role domain.ProjectRole) 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
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, Rebind(r.driver, `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), role, time.Now().UTC(), userID, projectID, actorID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("授予项目角色失败:%w", err)
|
||||
}
|
||||
return projectMemberWriteResult(result)
|
||||
return r.changeProjectRole(ctx, actorID, projectID, userID, role, true)
|
||||
}
|
||||
|
||||
func (r *SQLProjectMemberRepository) RevokeProjectRole(ctx context.Context, actorID, projectID, userID string, role domain.ProjectRole) 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
|
||||
}
|
||||
query := `DELETE FROM project_members WHERE project_id = ? AND user_id = ? AND role = ? AND ` + projectManagerPredicate
|
||||
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))`
|
||||
result, err := r.db.ExecContext(ctx, query, actorID, projectID, userID, role)
|
||||
if err != nil {
|
||||
return fmt.Errorf("撤销项目角色失败:%w", err)
|
||||
}
|
||||
return projectMemberWriteResult(result)
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, Rebind(r.driver, query), projectID, userID, role, actorID, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("撤销项目角色失败:%w", err)
|
||||
}
|
||||
return projectMemberWriteResult(result)
|
||||
return r.changeProjectRole(ctx, actorID, projectID, userID, role, false)
|
||||
}
|
||||
|
||||
func projectMemberWriteResult(result sql.Result) error {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user