diff --git a/backend/internal/repository/project_member_changes.go b/backend/internal/repository/project_member_changes.go new file mode 100644 index 0000000..084dfaf --- /dev/null +++ b/backend/internal/repository/project_member_changes.go @@ -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) +} diff --git a/backend/internal/repository/project_member_changes_test.go b/backend/internal/repository/project_member_changes_test.go new file mode 100644 index 0000000..d169b2a --- /dev/null +++ b/backend/internal/repository/project_member_changes_test.go @@ -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) + } +} diff --git a/backend/internal/repository/project_members.go b/backend/internal/repository/project_members.go index 8f97254..3f0b2d5 100644 --- a/backend/internal/repository/project_members.go +++ b/backend/internal/repository/project_members.go @@ -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 {