From b3909d83f0308d4e092cbaa8866131e2759732f0 Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 12:28:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E9=A1=B9=E7=9B=AE=E6=88=90?= =?UTF-8?q?=E5=91=98=E4=B8=8E=E5=AE=9E=E6=97=B6=E6=9D=83=E9=99=90=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E5=B9=B6=E5=90=8C=E6=AD=A5=E6=95=B4=E6=94=B9=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/cli/sqlite_upgrade_cli_test.go | 50 ++- .../internal/database/history_scope_test.go | 2 +- backend/internal/database/migrate.go | 16 +- .../migrations/mysql/0014_project_members.sql | 2 + .../postgres/0014_project_members.sql | 8 + .../sqlite/0014_project_members.sql | 8 + backend/internal/database/project_members.go | 112 +++++++ .../internal/database/project_members_test.go | 295 ++++++++++++++++++ backend/internal/domain/project_access.go | 82 +++++ .../internal/domain/project_access_test.go | 36 +++ .../internal/repository/project_members.go | 148 +++++++++ .../repository/project_members_test.go | 238 ++++++++++++++ doc/Scert开发任务清单.md | 2 + doc/Scert整改任务执行清单.md | 17 + doc/Scert整改合并优化清单.md | 11 +- doc/全面审查测试与优化记录.md | 2 + doc/基础整改任务规格.md | 2 + doc/构建版本与数据库支持矩阵.md | 61 ++++ doc/生产部署与运维说明.md | 4 + doc/项目资源关联模型.md | 39 ++- 20 files changed, 1130 insertions(+), 5 deletions(-) create mode 100644 backend/internal/database/migrations/mysql/0014_project_members.sql create mode 100644 backend/internal/database/migrations/postgres/0014_project_members.sql create mode 100644 backend/internal/database/migrations/sqlite/0014_project_members.sql create mode 100644 backend/internal/database/project_members.go create mode 100644 backend/internal/database/project_members_test.go create mode 100644 backend/internal/domain/project_access.go create mode 100644 backend/internal/domain/project_access_test.go create mode 100644 backend/internal/repository/project_members.go create mode 100644 backend/internal/repository/project_members_test.go diff --git a/backend/internal/cli/sqlite_upgrade_cli_test.go b/backend/internal/cli/sqlite_upgrade_cli_test.go index 1b44c20..c3e228b 100644 --- a/backend/internal/cli/sqlite_upgrade_cli_test.go +++ b/backend/internal/cli/sqlite_upgrade_cli_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "crypto/tls" + "errors" "os" "os/exec" "path/filepath" @@ -129,6 +130,7 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) { // 从旧备份恢复后再次走正式应用入口升级,不由测试直接调用 Migrate。 runSQLiteProductCLI(t, binary, fixture, "certificate", "list") assertSQLiteCLIUpgradeUsable(t, fixture) + assertSQLiteCLIMembersRestored(t, fixture, scenario.name == "upgraded_backup") if err := validateBackupManifest(scenario.archive); err != nil { t.Fatal(err) } @@ -136,6 +138,36 @@ func TestSQLiteBusinessUpgradeStandaloneCLI(t *testing.T) { } } +func assertSQLiteCLIMembersRestored(t *testing.T, fixture sqliteBusinessFixture, present bool) { + t.Helper() + db, err := database.Open(t.Context(), fixture.config.Database) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members").Scan(&count); err != nil { + t.Fatal(err) + } + want := 0 + if present { + want = 2 + } + if count != want { + t.Fatalf("恢复后的成员数量错误:%d,期望 %d", count, want) + } + members := repository.NewSQLProjectMemberRepository(db.DB) + for _, permission := range []domain.ProjectPermission{domain.PermissionCertificateRead, domain.PermissionDNSCredentialUse} { + err := members.AuthorizeProject(t.Context(), "backup-member", "backup-project", permission) + if (present && err != nil) || (!present && !errors.Is(err, repository.ErrProjectAccessDenied)) { + t.Fatalf("恢复后的成员权限错误:%s,%v", permission, err) + } + } + if err := members.AuthorizeProject(t.Context(), "backup-member", domain.DefaultProjectID, domain.PermissionCertificateRead); !errors.Is(err, repository.ErrProjectAccessDenied) { + t.Fatalf("恢复后出现跨项目访问:%v", err) + } +} + func createSQLiteProjectForBackup(t *testing.T, fixture sqliteBusinessFixture) { t.Helper() db, err := database.Open(t.Context(), fixture.config.Database) @@ -170,6 +202,22 @@ func createSQLiteProjectForBackup(t *testing.T, fixture sqliteBusinessFixture) { if err := servers.ReplaceDomainServers(t.Context(), resource.ID, []string{server.ID}); err != nil { t.Fatal(err) } + users := repository.NewSQLUserRepository(db.DB) + owner, err := users.FindByIdentifier(t.Context(), "upgrade-owner") + if err != nil { + t.Fatal(err) + } + member := repository.User{ID: "backup-member", Username: "backup-member", Email: "member@example.test", + PasswordHash: owner.PasswordHash, Role: repository.UserRoleUser, Status: repository.UserStatusActive} + if err := users.CreateUser(t.Context(), member); err != nil { + t.Fatal(err) + } + members := repository.NewSQLProjectMemberRepository(db.DB) + for _, role := range []domain.ProjectRole{domain.ProjectRoleReadOnly, domain.ProjectRoleDNSAdmin} { + if err := members.GrantProjectRole(t.Context(), owner.ID, item.ID, member.ID, role); err != nil { + t.Fatal(err) + } + } } func runSQLiteProductCLI(t *testing.T, binary string, fixture sqliteBusinessFixture, args ...string) { @@ -241,7 +289,7 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) { } defer db.Close() var version, bindings int - if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 13 { + if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 14 { t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err) } project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID) diff --git a/backend/internal/database/history_scope_test.go b/backend/internal/database/history_scope_test.go index a8333f2..ef5eed9 100644 --- a/backend/internal/database/history_scope_test.go +++ b/backend/internal/database/history_scope_test.go @@ -215,7 +215,7 @@ func TestHistoryScopeMySQLMigrationRetry(t *testing.T) { } } assertHistoryScope(t, db.DB) - if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 13 { + if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != latestMigrationVersion { t.Fatalf("重试版本错误:%d,%v", version, err) } }) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index 01bbed5..e08e230 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -15,6 +15,7 @@ import ( //go:embed migrations/sqlite/0011_resource_projects.sql migrations/mysql/0011_resource_projects.sql migrations/postgres/0011_resource_projects.sql //go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql //go:embed migrations/sqlite/0013_history_scope.sql migrations/mysql/0013_history_scope.sql migrations/postgres/0013_history_scope.sql +//go:embed migrations/sqlite/0014_project_members.sql migrations/mysql/0014_project_members.sql migrations/postgres/0014_project_members.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -39,7 +40,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 13 +const latestMigrationVersion = 14 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -79,6 +80,11 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return applySQLiteTaskMigration(ctx, db, statements, version) } if driver == "mysql" { + if version == 14 { + if err := installMySQLProjectMembers(ctx, db); err != nil { + return err + } + } for _, statement := range statements { skip, err := shouldSkipMySQLStatement(ctx, db, statement) if err != nil { @@ -129,6 +135,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return err } } + if driver == "sqlite" && version == 14 { + if err := installSQLiteProjectMemberGuards(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" @@ -206,6 +218,8 @@ func migrationName(version int) string { return "resource_replacement" case 13: return "history_scope" + case 14: + return "project_members" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0014_project_members.sql b/backend/internal/database/migrations/mysql/0014_project_members.sql new file mode 100644 index 0000000..4e25d15 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0014_project_members.sql @@ -0,0 +1,2 @@ +-- Go 迁移读取历史用户主键的实际字符集,建立兼容外键;不修改原用户表。 +SELECT 1; diff --git a/backend/internal/database/migrations/postgres/0014_project_members.sql b/backend/internal/database/migrations/postgres/0014_project_members.sql new file mode 100644 index 0000000..79bf7eb --- /dev/null +++ b/backend/internal/database/migrations/postgres/0014_project_members.sql @@ -0,0 +1,8 @@ +CREATE TABLE project_members ( + project_id VARCHAR(36) NOT NULL REFERENCES projects(id), + user_id VARCHAR(36) NOT NULL REFERENCES users(id), + role VARCHAR(32) NOT NULL CHECK (role IN ('project_admin', 'certificate_admin', 'deployment_admin', 'dns_admin', 'audit_viewer', 'read_only')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id, role) +); +CREATE INDEX idx_project_members_user ON project_members(user_id, project_id); diff --git a/backend/internal/database/migrations/sqlite/0014_project_members.sql b/backend/internal/database/migrations/sqlite/0014_project_members.sql new file mode 100644 index 0000000..3ffb91d --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0014_project_members.sql @@ -0,0 +1,8 @@ +CREATE TABLE project_members ( + project_id TEXT NOT NULL REFERENCES projects(id), + user_id TEXT NOT NULL REFERENCES users(id), + role TEXT NOT NULL CHECK (role IN ('project_admin', 'certificate_admin', 'deployment_admin', 'dns_admin', 'audit_viewer', 'read_only')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, user_id, role) +); +CREATE INDEX idx_project_members_user ON project_members(user_id, project_id); diff --git a/backend/internal/database/project_members.go b/backend/internal/database/project_members.go new file mode 100644 index 0000000..42bd5f7 --- /dev/null +++ b/backend/internal/database/project_members.go @@ -0,0 +1,112 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "regexp" +) + +var mysqlMetadataIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) + +func installMySQLProjectMembers(ctx context.Context, db *sql.DB) error { + var charset, collation, dataType string + var length int + if err := db.QueryRowContext(ctx, `SELECT CHARACTER_SET_NAME, COLLATION_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH + FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'users' AND column_name = 'id'`). + Scan(&charset, &collation, &dataType, &length); err != nil { + return fmt.Errorf("读取用户主键类型失败:%w", err) + } + if dataType != "varchar" || length != 36 || !mysqlMetadataIdentifier.MatchString(charset) || !mysqlMetadataIdentifier.MatchString(collation) { + return fmt.Errorf("用户主键类型不兼容,拒绝建立项目成员关系") + } + // MySQL 历史库的默认字符集可能改变,不能用当前默认值猜测用户主键类型。 + statement := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS project_members ( + project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, + user_id VARCHAR(36) CHARACTER SET %s COLLATE %s NOT NULL, + role ENUM('project_admin', 'certificate_admin', 'deployment_admin', 'dns_admin', 'audit_viewer', 'read_only') NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + PRIMARY KEY (project_id, user_id, role), + KEY idx_project_members_user (user_id, project_id), + CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) REFERENCES projects(id), + CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) REFERENCES users(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`, charset, collation) + if _, err := db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立项目成员关系失败:%w", err) + } + // 重试只接受完整的既有表;不能把同名残缺表当作迁移成功。 + var userCharset, userCollation, roleType string + if err := db.QueryRowContext(ctx, `SELECT CHARACTER_SET_NAME, COLLATION_NAME FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='project_members' AND column_name='user_id'`).Scan(&userCharset, &userCollation); err != nil { + return fmt.Errorf("检查成员用户字段失败:%w", err) + } + if err := db.QueryRowContext(ctx, `SELECT COLUMN_TYPE FROM information_schema.columns + WHERE table_schema=DATABASE() AND table_name='project_members' AND column_name='role'`).Scan(&roleType); err != nil { + return fmt.Errorf("检查成员角色字段失败:%w", err) + } + var references, primaryColumns int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE table_schema=DATABASE() AND table_name='project_members' AND referenced_table_schema=DATABASE() + AND ((column_name='project_id' AND referenced_table_name='projects' AND referenced_column_name='id') + OR (column_name='user_id' AND referenced_table_name='users' AND referenced_column_name='id'))`).Scan(&references); err != nil { + return fmt.Errorf("检查成员外键失败:%w", err) + } + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE table_schema=DATABASE() AND table_name='project_members' AND constraint_name='PRIMARY' + AND ((ordinal_position=1 AND column_name='project_id') OR (ordinal_position=2 AND column_name='user_id') + OR (ordinal_position=3 AND column_name='role'))`).Scan(&primaryColumns); err != nil { + return fmt.Errorf("检查成员主键失败:%w", err) + } + if userCharset != charset || userCollation != collation || references != 2 || primaryColumns != 3 || + roleType != "enum('project_admin','certificate_admin','deployment_admin','dns_admin','audit_viewer','read_only')" { + return fmt.Errorf("既有项目成员表结构不兼容,拒绝登记迁移版本") + } + return nil +} + +func installSQLiteProjectMemberGuards(ctx context.Context, tx *sql.Tx) error { + // 与既有资源约束一致,不依赖连接级外键和递归触发器开关。 + for _, event := range []string{"INSERT", "UPDATE"} { + statement := fmt.Sprintf(`CREATE TRIGGER scert_members_reference_%s BEFORE %s ON project_members + WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id) + OR NOT EXISTS (SELECT 1 FROM users WHERE id = NEW.user_id) + BEGIN SELECT RAISE(ABORT, '项目或用户不存在'); END`, event, event) + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立成员引用检查失败:%w", err) + } + } + for _, parent := range []struct{ table, column string }{{"projects", "project_id"}, {"users", "user_id"}} { + // 用户名、邮箱等唯一键替换也可能隐式删除另一个账户;成员仍在时禁止该替换。 + conflict := "p.id = NEW.id OR p.rowid = NEW.rowid" + // 未显式指定行号时,插入前 NEW.rowid 可为 -1;不能误拒绝正常新增。 + insertConflict := "p.id = NEW.id OR (NEW.rowid != -1 AND p.rowid = NEW.rowid)" + if parent.table == "users" { + conflict += " OR p.username = NEW.username OR (NEW.email != '' AND p.email = NEW.email)" + insertConflict += " OR p.username = NEW.username OR (NEW.email != '' AND p.email = NEW.email)" + } + statements := []string{ + fmt.Sprintf(`CREATE TRIGGER scert_members_%s_delete BEFORE DELETE ON %s + WHEN EXISTS (SELECT 1 FROM project_members WHERE %s = OLD.id) + BEGIN SELECT RAISE(ABORT, '请先撤销项目成员关系'); END`, parent.table, parent.table, parent.column), + fmt.Sprintf(`CREATE TRIGGER scert_members_%s_insert BEFORE INSERT ON %s + WHEN EXISTS (SELECT 1 FROM %s p JOIN project_members m ON m.%s = p.id WHERE %s) + BEGIN SELECT RAISE(ABORT, '不能替换已有项目成员关联的记录'); END`, parent.table, parent.table, parent.table, parent.column, insertConflict), + fmt.Sprintf(`CREATE TRIGGER scert_members_%s_update BEFORE UPDATE ON %s + WHEN (NEW.id != OLD.id AND EXISTS (SELECT 1 FROM project_members WHERE %s = OLD.id)) + OR EXISTS (SELECT 1 FROM %s p JOIN project_members m ON m.%s = p.id WHERE p.id != OLD.id AND (%s)) + BEGIN SELECT RAISE(ABORT, '不能改变项目成员关联的记录标识'); END`, parent.table, parent.table, parent.column, parent.table, parent.column, conflict), + fmt.Sprintf(`CREATE TRIGGER scert_members_%s_after_insert AFTER INSERT ON %s + WHEN EXISTS (SELECT 1 FROM project_members m LEFT JOIN %s p ON p.id = m.%s WHERE p.id IS NULL) + BEGIN SELECT RAISE(ABORT, '替换操作破坏项目成员关系'); END`, parent.table, parent.table, parent.table, parent.column), + fmt.Sprintf(`CREATE TRIGGER scert_members_%s_after_update AFTER UPDATE ON %s + WHEN EXISTS (SELECT 1 FROM project_members m LEFT JOIN %s p ON p.id = m.%s WHERE p.id IS NULL) + BEGIN SELECT RAISE(ABORT, '替换操作破坏项目成员关系'); END`, parent.table, parent.table, parent.table, parent.column), + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立成员父记录保护失败:%w", err) + } + } + } + return nil +} diff --git a/backend/internal/database/project_members_test.go b/backend/internal/database/project_members_test.go new file mode 100644 index 0000000..6eb44c3 --- /dev/null +++ b/backend/internal/database/project_members_test.go @@ -0,0 +1,295 @@ +package database + +import ( + "database/sql" + "fmt" + "strings" + "testing" +) + +func prepareProjectMemberMigration(t *testing.T, db *sql.DB, driver string) { + t.Helper() + if _, err := db.ExecContext(t.Context(), migrationTableSQL(driver)); err != nil { + t.Fatal(err) + } + for version := 1; version <= 13; version++ { + if err := applyMigration(t.Context(), db, driver, version); err != nil { + t.Fatal(err) + } + } + for _, query := range []string{ + `INSERT INTO projects (id,name,description) VALUES ('member-project','成员项目','保留资料'),('other-project','其他项目','')`, + `INSERT INTO users (id,username,email,password_hash,role,status) VALUES + ('member-user','member-user','member@example.invalid','test-only','user','active'), + ('other-user','other-user','other@example.invalid','test-only','admin','active')`, + `INSERT INTO tasks (id,domain_id,task_type,status,message) VALUES ('historical-task','deleted-domain','deploy','success','保留历史')`, + } { + if _, err := db.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } +} + +func assertProjectMemberMigration(t *testing.T, db *sql.DB) { + t.Helper() + for _, tc := range []struct{ query, want string }{ + {`SELECT description FROM projects WHERE id='member-project'`, "保留资料"}, + {`SELECT message FROM tasks WHERE id='historical-task'`, "保留历史"}, + {`SELECT role FROM users WHERE id='other-user'`, "admin"}, + } { + var actual string + if err := db.QueryRowContext(t.Context(), tc.query).Scan(&actual); err != nil || actual != tc.want { + t.Fatalf("迁移改变历史数据:%q,%v", actual, err) + } + } +} + +func TestSQLiteProjectMembersMigration(t *testing.T) { + for _, foreignKeys := range []int{0, 1} { + for _, recursive := range []int{0, 1} { + t.Run(fmt.Sprintf("foreign_keys_%d_recursive_%d", foreignKeys, recursive), func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + prepareProjectMemberMigration(t, db.DB, "sqlite") + for _, query := range []string{fmt.Sprintf("PRAGMA foreign_keys=%d", foreignKeys), fmt.Sprintf("PRAGMA recursive_triggers=%d", recursive)} { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members").Scan(&count); err != nil || count != 0 { + t.Fatalf("迁移自动授权:%d,%v", count, err) + } + if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO project_members(project_id,user_id,role) VALUES ('member-project','member-user','read_only')`); err != nil { + t.Fatal(err) + } + for _, query := range []string{ + `DELETE FROM projects WHERE id='member-project'`, + `DELETE FROM users WHERE id='member-user'`, + `UPDATE projects SET id='renamed' WHERE id='member-project'`, + `UPDATE users SET id='renamed' WHERE id='member-user'`, + `INSERT OR REPLACE INTO projects(id,name,description) VALUES ('member-project','替换','')`, + `INSERT OR REPLACE INTO projects(rowid,id,name,description) SELECT rowid,'replacement','替换','' FROM projects WHERE id='member-project'`, + `UPDATE OR REPLACE projects SET id='member-project' WHERE id='other-project'`, + `UPDATE OR REPLACE projects SET rowid=(SELECT rowid FROM projects WHERE id='member-project') WHERE id='other-project'`, + `INSERT OR REPLACE INTO users(id,username,email,password_hash,role) VALUES ('replacement','member-user','new@example.invalid','test-only','user')`, + `INSERT OR REPLACE INTO users(id,username,email,password_hash,role) VALUES ('replacement','new-user','member@example.invalid','test-only','user')`, + `INSERT OR REPLACE INTO users(id,username,email,password_hash,role) VALUES ('member-user','new-user','new@example.invalid','test-only','user')`, + `INSERT OR REPLACE INTO users(rowid,id,username,email,password_hash,role) SELECT rowid,'replacement','new-user','new@example.invalid','test-only','user' FROM users WHERE id='member-user'`, + `UPDATE OR REPLACE users SET id='member-user' WHERE id='other-user'`, + `UPDATE OR REPLACE users SET username='member-user' WHERE id='other-user'`, + `UPDATE OR REPLACE users SET email='member@example.invalid' WHERE id='other-user'`, + `UPDATE OR REPLACE users SET rowid=(SELECT rowid FROM users WHERE id='member-user') WHERE id='other-user'`, + `UPDATE project_members SET project_id='missing'`, + `UPDATE project_members SET user_id='missing'`, + `UPDATE project_members SET role='super_admin'`, + `INSERT INTO project_members(project_id,user_id,role) VALUES ('missing','member-user','read_only')`, + `INSERT INTO project_members(project_id,user_id,role) VALUES ('member-project','missing','read_only')`, + } { + t.Run(fmt.Sprintf("reject_%02d", count), func(t *testing.T) { + if _, err := db.DB.ExecContext(t.Context(), query); err == nil { + t.Fatalf("危险写入未拒绝:%s", query) + } + assertProjectMemberMigration(t, db.DB) + }) + count++ + } + for _, query := range []string{ + `UPDATE users SET username='renamed-user', email='renamed@example.invalid',status='disabled' WHERE id='member-user'`, + `UPDATE projects SET name='更名' WHERE id='member-project'`, + `UPDATE project_members SET role='audit_viewer'`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + var role string + if err := db.DB.QueryRowContext(t.Context(), "SELECT role FROM project_members").Scan(&role); err != nil || role != "audit_viewer" { + t.Fatalf("重试改变角色:%q,%v", role, err) + } + for _, query := range []string{`DELETE FROM project_members`, `DELETE FROM users WHERE id='member-user'`, `DELETE FROM projects WHERE id='member-project'`} { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + }) + } + } +} + +func TestSQLiteProjectMembersMigrationFailure(t *testing.T) { + for _, fault := range []string{"version", "trigger_collision"} { + t.Run(fault, func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + prepareProjectMemberMigration(t, db.DB, "sqlite") + query := `CREATE TRIGGER fail_member_version BEFORE INSERT ON schema_migrations WHEN NEW.version=14 BEGIN SELECT RAISE(ABORT,'test-only'); END` + drop := "DROP TRIGGER fail_member_version" + if fault == "trigger_collision" { + query = `CREATE TRIGGER scert_members_users_delete BEFORE DELETE ON users BEGIN SELECT 1; END` + drop = "DROP TRIGGER scert_members_users_delete" + } + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err == nil { + t.Fatal("故障未阻断迁移") + } + var count int + if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM sqlite_master WHERE name='project_members'").Scan(&count); err != nil || count != 0 { + t.Fatalf("成员表未回滚:%d,%v", count, err) + } + if version, err := currentMigrationVersion(t.Context(), db.DB); err != nil || version != 13 { + t.Fatalf("错误登记版本:%d,%v", version, err) + } + assertProjectMemberMigration(t, db.DB) + if _, err := db.DB.ExecContext(t.Context(), drop); err != nil { + t.Fatal(err) + } + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + assertProjectMemberMigration(t, db.DB) + }) + } +} + +func TestSQLiteProjectMembersReplacementEdges(t *testing.T) { + for _, scenario := range []string{"negative_rowid", "empty_email", "custom_unique_update"} { + t.Run(scenario, func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + prepareProjectMemberMigration(t, db.DB, "sqlite") + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + for _, query := range []string{"PRAGMA foreign_keys=0", "PRAGMA recursive_triggers=0", + `INSERT INTO project_members(project_id,user_id,role) VALUES ('member-project','member-user','read_only')`} { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + if scenario == "negative_rowid" { + for _, query := range []string{ + `UPDATE users SET rowid=-1 WHERE id='member-user'`, + `UPDATE projects SET rowid=-1 WHERE id='member-project'`, + `INSERT INTO users(id,username,email,password_hash,role) VALUES ('new-user','new-user','new@example.invalid','test-only','user')`, + `INSERT INTO projects(id,name,description) VALUES ('new-project','正常新增','')`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatalf("正常写入被错误阻止:%s,%v", query, err) + } + } + for _, query := range []string{ + `INSERT OR REPLACE INTO users(rowid,id,username,email,password_hash,role) VALUES (-1,'replacement','replacement','replacement@example.invalid','test-only','user')`, + `INSERT OR REPLACE INTO projects(rowid,id,name,description) VALUES (-1,'replacement','替换','')`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err == nil { + t.Fatalf("负行号替换未拒绝:%s", query) + } + } + } else if scenario == "empty_email" { + // 既有邮箱索引只约束非空值;有项目角色的无邮箱账户不能阻止新增其他无邮箱账户。 + for _, query := range []string{ + `UPDATE users SET email='' WHERE id='member-user'`, + `INSERT INTO users(id,username,email,password_hash,role) VALUES ('new-user','new-user','','test-only','user')`, + `UPDATE users SET email='' WHERE id='other-user'`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatalf("正常无邮箱账户写入被拒绝:%s,%v", query, err) + } + } + } else { + for _, query := range []string{ + `ALTER TABLE users ADD COLUMN extra_tag TEXT`, + `CREATE UNIQUE INDEX idx_users_extra_tag ON users(extra_tag)`, + `UPDATE users SET extra_tag='held' WHERE id='member-user'`, + } { + if _, err := db.DB.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + } + if _, err := db.DB.ExecContext(t.Context(), `UPDATE OR REPLACE users SET extra_tag='held' WHERE id='other-user'`); err == nil { + t.Fatal("扩展唯一索引替换绕过了项目成员引用保护") + } + } + assertProjectMemberMigration(t, db.DB) + var username string + if err := db.DB.QueryRowContext(t.Context(), "SELECT username FROM users WHERE id='member-user'").Scan(&username); err != nil || username != "member-user" { + t.Fatalf("关联用户被替换或丢失:%q,%v", username, err) + } + }) + } +} + +func TestMySQLRealProjectMembersMigrationRetry(t *testing.T) { + fixture := startRealMySQL(t) + db := fixture.database(t) + ctx := t.Context() + prepareProjectMemberMigration(t, db.DB, "mysql") + var schema string + if err := db.DB.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil { + t.Fatal(err) + } + schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`" + // 特意改变当前库默认字符集,成员外键仍必须匹配历史 users.id 的实际类型。 + if _, err := fixture.admin.ExecContext(ctx, "ALTER DATABASE "+schema+" CHARACTER SET latin1 COLLATE latin1_bin"); err != nil { + t.Fatal(err) + } + if _, err := fixture.admin.ExecContext(ctx, `CREATE TRIGGER `+schema+`.fail_member_version BEFORE INSERT ON `+schema+`.schema_migrations + FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test-only'`); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, db.DB, "mysql"); err == nil { + t.Fatal("版本登记失败未阻断迁移") + } + if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 13 { + t.Fatalf("错误登记版本:%d,%v", version, err) + } + if _, err := db.DB.ExecContext(ctx, `INSERT INTO project_members(project_id,user_id,role) VALUES ('member-project','member-user','dns_admin')`); err != nil { + t.Fatal(err) + } + if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_member_version"); err != nil { + t.Fatal(err) + } + // 残缺同名表不能被 IF NOT EXISTS 静默接受;恢复约束后才允许重试。 + if _, err := db.DB.ExecContext(ctx, "ALTER TABLE project_members DROP FOREIGN KEY fk_project_members_user"); err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, db.DB, "mysql"); err == nil { + t.Fatal("残缺成员表被当作迁移成功") + } + if version, err := currentMigrationVersion(ctx, db.DB); err != nil || version != 13 { + t.Fatalf("残缺结构错误登记版本:%d,%v", version, err) + } + if _, err := db.DB.ExecContext(ctx, "ALTER TABLE project_members ADD CONSTRAINT fk_project_members_user FOREIGN KEY (user_id) REFERENCES users(id)"); err != nil { + t.Fatal(err) + } + for round := 0; round < 2; round++ { + if err := Migrate(ctx, db.DB, "mysql"); err != nil { + t.Fatal(err) + } + } + var role string + if err := db.DB.QueryRowContext(ctx, "SELECT role FROM project_members").Scan(&role); err != nil || role != "dns_admin" { + t.Fatalf("重试覆盖成员:%q,%v", role, err) + } + assertProjectMemberMigration(t, db.DB) +} diff --git a/backend/internal/domain/project_access.go b/backend/internal/domain/project_access.go new file mode 100644 index 0000000..af2deb4 --- /dev/null +++ b/backend/internal/domain/project_access.go @@ -0,0 +1,82 @@ +package domain + +// ProjectRole 是项目内的能力集合,与登录角色和用户分组相互独立。 +type ProjectRole string + +const ( + ProjectRoleAdmin ProjectRole = "project_admin" + ProjectRoleCertificateAdmin ProjectRole = "certificate_admin" + ProjectRoleDeploymentAdmin ProjectRole = "deployment_admin" + ProjectRoleDNSAdmin ProjectRole = "dns_admin" + ProjectRoleAuditViewer ProjectRole = "audit_viewer" + ProjectRoleReadOnly ProjectRole = "read_only" +) + +type ProjectPermission string + +const ( + PermissionProjectRead ProjectPermission = "project.read" + PermissionProjectManage ProjectPermission = "project.manage" + PermissionMembersManage ProjectPermission = "members.manage" + PermissionCertificateRead ProjectPermission = "certificate.read" + PermissionCertificateManage ProjectPermission = "certificate.manage" + PermissionPrivateKeyRead ProjectPermission = "certificate.private_key.read" + PermissionServerRead ProjectPermission = "server.read" + PermissionServerManage ProjectPermission = "server.manage" + PermissionDeploymentRead ProjectPermission = "deployment.read" + PermissionDeploymentExecute ProjectPermission = "deployment.execute" + PermissionDeploymentRollback ProjectPermission = "deployment.rollback" + PermissionDNSRead ProjectPermission = "dns.read" + PermissionDNSManage ProjectPermission = "dns.manage" + PermissionDNSCredentialUse ProjectPermission = "dns.credential.use" + PermissionAuditRead ProjectPermission = "audit.read" + PermissionTaskRead ProjectPermission = "task.read" + PermissionTaskCancel ProjectPermission = "task.cancel" +) + +func (r ProjectRole) Valid() bool { + switch r { + case ProjectRoleAdmin, ProjectRoleCertificateAdmin, ProjectRoleDeploymentAdmin, + ProjectRoleDNSAdmin, ProjectRoleAuditViewer, ProjectRoleReadOnly: + return true + } + return false +} + +func (p ProjectPermission) Valid() bool { + switch p { + case PermissionProjectRead, PermissionProjectManage, PermissionMembersManage, + PermissionCertificateRead, PermissionCertificateManage, PermissionPrivateKeyRead, + PermissionServerRead, PermissionServerManage, PermissionDeploymentRead, + PermissionDeploymentExecute, PermissionDeploymentRollback, PermissionDNSRead, + PermissionDNSManage, PermissionDNSCredentialUse, PermissionAuditRead, + PermissionTaskRead, PermissionTaskCancel: + return true + } + return false +} + +// Allows 只计算项目角色能力;调用方还必须核对实时账户状态和资源归属。 +func (r ProjectRole) Allows(p ProjectPermission) bool { + if !r.Valid() || !p.Valid() { + return false + } + if r == ProjectRoleAdmin || p == PermissionProjectRead { + return true + } + switch r { + case ProjectRoleCertificateAdmin: + return p == PermissionCertificateRead || p == PermissionCertificateManage || p == PermissionPrivateKeyRead + case ProjectRoleDeploymentAdmin: + return p == PermissionCertificateRead || p == PermissionServerRead || p == PermissionServerManage || + p == PermissionDeploymentRead || p == PermissionDeploymentExecute || p == PermissionDeploymentRollback + case ProjectRoleDNSAdmin: + return p == PermissionDNSRead || p == PermissionDNSManage || p == PermissionDNSCredentialUse + case ProjectRoleAuditViewer: + return p == PermissionAuditRead || p == PermissionTaskRead + case ProjectRoleReadOnly: + return p == PermissionCertificateRead || p == PermissionServerRead || + p == PermissionDeploymentRead || p == PermissionDNSRead || p == PermissionTaskRead + } + return false +} diff --git a/backend/internal/domain/project_access_test.go b/backend/internal/domain/project_access_test.go new file mode 100644 index 0000000..29c418b --- /dev/null +++ b/backend/internal/domain/project_access_test.go @@ -0,0 +1,36 @@ +package domain + +import "testing" + +func TestProjectRolePermissionMatrix(t *testing.T) { + permissions := []ProjectPermission{PermissionProjectRead, PermissionProjectManage, PermissionMembersManage, + PermissionCertificateRead, PermissionCertificateManage, PermissionPrivateKeyRead, PermissionServerRead, + PermissionServerManage, PermissionDeploymentRead, PermissionDeploymentExecute, PermissionDeploymentRollback, + PermissionDNSRead, PermissionDNSManage, PermissionDNSCredentialUse, PermissionAuditRead, PermissionTaskRead, + PermissionTaskCancel, "", "system.manage", "certificate.private_key.export"} + cases := []struct { + role ProjectRole + allowed []ProjectPermission + }{ + {ProjectRoleAdmin, permissions[:17]}, + {ProjectRoleCertificateAdmin, []ProjectPermission{PermissionProjectRead, PermissionCertificateRead, PermissionCertificateManage, PermissionPrivateKeyRead}}, + {ProjectRoleDeploymentAdmin, []ProjectPermission{PermissionProjectRead, PermissionCertificateRead, PermissionServerRead, PermissionServerManage, PermissionDeploymentRead, PermissionDeploymentExecute, PermissionDeploymentRollback}}, + {ProjectRoleDNSAdmin, []ProjectPermission{PermissionProjectRead, PermissionDNSRead, PermissionDNSManage, PermissionDNSCredentialUse}}, + {ProjectRoleAuditViewer, []ProjectPermission{PermissionProjectRead, PermissionAuditRead, PermissionTaskRead}}, + {ProjectRoleReadOnly, []ProjectPermission{PermissionProjectRead, PermissionCertificateRead, PermissionServerRead, PermissionDeploymentRead, PermissionDNSRead, PermissionTaskRead}}, + {"", nil}, {"super_admin", nil}, {"PROJECT_ADMIN", nil}, + } + for _, tc := range cases { + t.Run(string(tc.role), func(t *testing.T) { + for _, p := range permissions { + want := false + for _, granted := range tc.allowed { + want = want || granted == p + } + if got := tc.role.Allows(p); got != want { + t.Errorf("角色 %q 操作 %q 判定为 %t,期望 %t", tc.role, p, got, want) + } + } + }) + } +} diff --git a/backend/internal/repository/project_members.go b/backend/internal/repository/project_members.go new file mode 100644 index 0000000..8f97254 --- /dev/null +++ b/backend/internal/repository/project_members.go @@ -0,0 +1,148 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" +) + +var ErrProjectAccessDenied = errors.New("无权访问项目或执行此操作") + +type ProjectAuthorizer interface { + AuthorizeProject(context.Context, string, string, domain.ProjectPermission) error +} + +type SQLProjectMemberRepository struct { + db *sql.DB + driver string +} + +var _ ProjectAuthorizer = (*SQLProjectMemberRepository)(nil) + +func NewSQLProjectMemberRepository(db *sql.DB, driver ...string) *SQLProjectMemberRepository { + return &SQLProjectMemberRepository{db: db, driver: repositoryDriver(driver)} +} + +func (r *SQLProjectMemberRepository) ensureDB() error { + if r == nil || r.db == nil { + return errors.New("数据库连接不能为空") + } + return nil +} + +// AuthorizeProject 每次从数据库读取账户、项目和成员关系,不信任请求中的角色或用户分组。 +// 这里只判定指定项目,业务入口仍须从真实资源取得项目,并在执行副作用前重新判权。 +func (r *SQLProjectMemberRepository) AuthorizeProject(ctx context.Context, userID, projectID string, permission domain.ProjectPermission) error { + if err := r.ensureDB(); err != nil { + return err + } + if !permission.Valid() || strings.TrimSpace(userID) == "" || strings.TrimSpace(projectID) == "" { + return ErrProjectAccessDenied + } + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, `SELECT u.role, u.status, COALESCE(m.role, '') + FROM users u JOIN projects p ON p.id = ? + LEFT JOIN project_members m ON m.project_id = p.id AND m.user_id = u.id + WHERE u.id = ?`), strings.TrimSpace(projectID), strings.TrimSpace(userID)) + if err != nil { + return fmt.Errorf("读取项目权限失败:%w", err) + } + defer rows.Close() + allowed := false + for rows.Next() { + var userRole, status string + var role domain.ProjectRole + if err := rows.Scan(&userRole, &status, &role); err != nil { + return fmt.Errorf("读取项目成员失败:%w", err) + } + if status != UserStatusActive { + continue + } + switch userRole { + case UserRoleSuperAdmin: + allowed = true + case UserRoleAdmin, UserRoleUser: + // 普通账户即使具有项目管理角色,也不能直接读取完整证书私钥。 + if permission == domain.PermissionPrivateKeyRead && userRole == UserRoleUser { + continue + } + allowed = allowed || role.Allows(permission) + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("读取项目权限结果失败:%w", err) + } + if !allowed { + return ErrProjectAccessDenied + } + return nil +} + +// 授权与写入在同一条语句内判定,避免先检查再写入之间撤权导致越权。 +// 系统管理员必须是当前有效超级管理员;普通管理员不会因用户组或全局角色自动获得项目权限。 +const projectManagerPredicate = `EXISTS (SELECT 1 FROM users a WHERE a.id = ? AND a.status = 'active' + AND (a.role = 'super_admin' OR (a.role IN ('admin', 'user') AND EXISTS + (SELECT 1 FROM project_members manager WHERE manager.project_id = ? + 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) +} + +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) +} + +func projectMemberWriteResult(result sql.Result) error { + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取项目成员变更结果失败:%w", err) + } + if count != 1 { + return ErrProjectAccessDenied + } + return nil +} diff --git a/backend/internal/repository/project_members_test.go b/backend/internal/repository/project_members_test.go new file mode 100644 index 0000000..eb28809 --- /dev/null +++ b/backend/internal/repository/project_members_test.go @@ -0,0 +1,238 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + + "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" +) + +func TestProjectMembersSQLite(t *testing.T) { + cfg := config.Default(t.TempDir()).Database + cfg.DSN = filepath.Join(t.TempDir(), "members.db") + 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) + } + checkProjectMembers(t, db.DB, cfg.Driver) +} + +func TestProjectMembersMySQLReal(t *testing.T) { + server := mysqltest.New(t) + cfg := server.Database(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) + } + checkProjectMembers(t, db.DB, cfg.Driver) +} + +func checkProjectMembers(t *testing.T, db *sql.DB, driver string) { + t.Helper() + ctx := t.Context() + repo := NewSQLProjectMemberRepository(db, driver) + projects := NewSQLProjectRepository(db, driver) + for _, id := range []string{"project-a", "project-b"} { + if err := projects.CreateProject(ctx, domain.Project{ID: id, Name: id}); err != nil { + t.Fatal(err) + } + } + for _, u := range []struct{ id, role, status string }{ + {"root-test", UserRoleSuperAdmin, UserStatusActive}, + {"manager", UserRoleUser, UserStatusActive}, + {"viewer", UserRoleUser, UserStatusActive}, + {"global-admin", UserRoleAdmin, UserStatusActive}, + {"outsider", UserRoleUser, UserStatusActive}, + {"disabled", UserRoleUser, UserStatusDisabled}, + } { + _, err := db.ExecContext(ctx, Rebind(driver, `INSERT INTO users (id, username, email, password_hash, role, status, groups_json) + VALUES (?, ?, ?, 'test-only-no-login', ?, ?, '["same-group"]')`), u.id, u.id, u.id+"@example.invalid", u.role, u.status) + if err != nil { + t.Fatal(err) + } + } + grant := func(actor, project, user string, role domain.ProjectRole) { + t.Helper() + if err := repo.GrantProjectRole(ctx, actor, project, user, role); err != nil { + t.Fatal(err) + } + } + check := func(user, project string, permission domain.ProjectPermission, allowed bool) { + t.Helper() + err := repo.AuthorizeProject(ctx, user, project, permission) + if allowed && err != nil { + t.Fatalf("预期允许 %s/%s/%s:%v", user, project, permission, err) + } + if !allowed && !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("预期拒绝 %s/%s/%s:%v", user, project, permission, err) + } + } + t.Run("no_implicit_membership", func(t *testing.T) { + var count int + if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM project_members").Scan(&count); err != nil || count != 0 { + t.Fatalf("迁移自动授予了权限:%d,%v", count, err) + } + for _, user := range []string{"manager", "global-admin", "outsider", "missing", "disabled"} { + check(user, "default", domain.PermissionProjectRead, false) + check(user, "project-a", domain.PermissionProjectRead, false) + } + check("root-test", "project-a", domain.PermissionProjectManage, true) + check("root-test", "missing", domain.PermissionProjectRead, false) + check("root-test", "project-a", "unknown", false) + }) + grant("root-test", "project-a", "manager", domain.ProjectRoleAdmin) + t.Run("project_admin_and_private_key_boundary", func(t *testing.T) { + check("manager", "project-a", domain.PermissionMembersManage, true) + check("manager", "project-a", domain.PermissionPrivateKeyRead, false) + check("manager", "project-b", domain.PermissionProjectRead, false) + grant("manager", "project-a", "viewer", domain.ProjectRoleReadOnly) + check("viewer", "project-a", domain.PermissionCertificateRead, true) + check("viewer", "project-a", domain.PermissionCertificateManage, false) + check("outsider", "project-a", domain.PermissionCertificateRead, false) + }) + t.Run("unauthorized_grants_and_invalid_targets", func(t *testing.T) { + for _, tc := range []struct { + actor, project, user string + role domain.ProjectRole + }{ + {"viewer", "project-a", "viewer", domain.ProjectRoleAdmin}, + {"global-admin", "project-a", "viewer", domain.ProjectRoleAdmin}, + {"manager", "project-b", "viewer", domain.ProjectRoleAdmin}, + {"root-test", "missing", "viewer", domain.ProjectRoleAdmin}, + {"root-test", "project-a", "missing", domain.ProjectRoleAdmin}, + {"root-test", "project-a", "disabled", domain.ProjectRoleAdmin}, + {"root-test", "project-a", "viewer", "super_admin"}, + } { + if err := repo.GrantProjectRole(ctx, tc.actor, tc.project, tc.user, tc.role); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("非法授予未拒绝:%+v,%v", tc, err) + } + } + if err := repo.GrantProjectRole(ctx, "root-test", "project-a", "viewer", domain.ProjectRoleReadOnly); err == nil { + t.Fatal("重复角色未拒绝") + } + check("viewer", "project-a", domain.PermissionMembersManage, false) + }) + t.Run("multiple_roles_and_projects", func(t *testing.T) { + grant("manager", "project-a", "viewer", domain.ProjectRoleDNSAdmin) + grant("root-test", "project-b", "viewer", domain.ProjectRoleAuditViewer) + check("viewer", "project-a", domain.PermissionDNSCredentialUse, true) + check("viewer", "project-a", domain.PermissionCertificateRead, true) + check("viewer", "project-a", domain.PermissionAuditRead, false) + check("viewer", "project-b", domain.PermissionAuditRead, true) + check("viewer", "project-b", domain.PermissionDNSCredentialUse, false) + }) + t.Run("revocation_is_immediate_and_scoped", func(t *testing.T) { + if err := repo.RevokeProjectRole(ctx, "viewer", "project-a", "manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("越权撤销未拒绝:%v", err) + } + if err := repo.RevokeProjectRole(ctx, "manager", "project-b", "viewer", domain.ProjectRoleAuditViewer); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("跨项目撤销未拒绝:%v", err) + } + if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "viewer", domain.ProjectRoleDNSAdmin); err != nil { + t.Fatal(err) + } + check("viewer", "project-a", domain.PermissionDNSCredentialUse, false) + check("viewer", "project-a", domain.PermissionCertificateRead, true) + check("viewer", "project-b", domain.PermissionAuditRead, true) + if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "manager", domain.ProjectRoleAdmin); err != nil { + t.Fatal(err) + } + check("manager", "project-a", domain.PermissionMembersManage, false) + if err := repo.GrantProjectRole(ctx, "manager", "project-a", "manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("撤权后可自行恢复:%v", err) + } + grant("root-test", "project-a", "manager", domain.ProjectRoleAdmin) + }) + t.Run("disabled_and_demoted_accounts", func(t *testing.T) { + grant("root-test", "project-a", "global-admin", domain.ProjectRoleCertificateAdmin) + check("global-admin", "project-a", domain.PermissionPrivateKeyRead, true) + if _, err := db.ExecContext(ctx, "UPDATE users SET role = 'user' WHERE id = 'global-admin'"); err != nil { + t.Fatal(err) + } + check("global-admin", "project-a", domain.PermissionPrivateKeyRead, false) + check("global-admin", "project-a", domain.PermissionCertificateManage, true) + if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = 'manager'"); err != nil { + t.Fatal(err) + } + check("manager", "project-a", domain.PermissionProjectRead, false) + if err := repo.GrantProjectRole(ctx, "manager", "project-a", "outsider", domain.ProjectRoleReadOnly); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("禁用账户仍可授予:%v", err) + } + if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "viewer", domain.ProjectRoleReadOnly); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("禁用账户仍可撤销:%v", err) + } + if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'active' WHERE id = 'manager'"); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = 'root-test'"); err != nil { + t.Fatal(err) + } + check("root-test", "project-a", domain.PermissionMembersManage, false) + if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'active' WHERE id = 'root-test'"); err != nil { + t.Fatal(err) + } + }) + t.Run("parent_references", func(t *testing.T) { + if err := projects.DeleteProject(ctx, "project-b"); err == nil { + t.Fatal("删除了仍有成员的项目") + } + if _, err := db.ExecContext(ctx, "DELETE FROM users WHERE id = 'viewer'"); err == nil { + t.Fatal("删除了仍有项目角色的用户") + } + for _, query := range []string{ + "INSERT INTO project_members(project_id,user_id,role) VALUES ('missing','viewer','read_only')", + "INSERT INTO project_members(project_id,user_id,role) VALUES ('project-a','missing','read_only')", + "UPDATE project_members SET user_id='missing' WHERE user_id='viewer'", + "UPDATE project_members SET project_id='missing' WHERE user_id='viewer'", + } { + if _, err := db.ExecContext(ctx, query); err == nil { + t.Fatalf("无效引用未拒绝:%s", query) + } + } + if err := repo.RevokeProjectRole(ctx, "root-test", "project-b", "viewer", domain.ProjectRoleAuditViewer); err != nil { + t.Fatal(err) + } + if err := projects.DeleteProject(ctx, "project-b"); err != nil { + t.Fatal(err) + } + check("viewer", "project-b", domain.PermissionAuditRead, false) + }) + t.Run("canceled_context", func(t *testing.T) { + canceled, cancel := context.WithCancel(ctx) + cancel() + if err := repo.AuthorizeProject(canceled, "root-test", "project-a", domain.PermissionProjectRead); err == nil { + t.Fatal("取消请求意外授权") + } + if err := repo.GrantProjectRole(canceled, "root-test", "project-a", "outsider", domain.ProjectRoleReadOnly); err == nil { + t.Fatal("取消请求意外写入") + } + }) +} + +func TestProjectMembersNilDatabase(t *testing.T) { + for _, repo := range []*SQLProjectMemberRepository{nil, NewSQLProjectMemberRepository(nil)} { + if err := repo.AuthorizeProject(t.Context(), "user", "project", domain.PermissionProjectRead); err == nil { + t.Fatal("空连接未拒绝") + } + if err := repo.GrantProjectRole(t.Context(), "actor", "project", "user", domain.ProjectRoleReadOnly); err == nil { + t.Fatal("空连接未拒绝") + } + if err := repo.RevokeProjectRole(t.Context(), "actor", "project", "user", domain.ProjectRoleReadOnly); err == nil { + t.Fatal("空连接未拒绝") + } + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 88b9ba2..a6c3b08 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,7 @@ # Scert 开发任务清单 +> 当前批次(模式 14):项目成员多角色、实时判权、同语句授予/撤销及三库迁移已实现,SQLite/MySQL 双版本与独立 Windows 程序成员备份恢复通过。后续仍需成员管理 Web/API 与审计、全部资源入口和排队任务重判;本批基础模块不等于业务隔离完成。最新证据见执行清单首节与支持矩阵第二十一节。原始 44 项不缩减,P0-05/P0-06/P0-07 继续 `[~]`;下方为历史记录。 + > 当前批次(模式 13):任务/审计范围快照、历史保留与回填、实际任务关联和操作者来源已实现;新增尝试拒绝缺失资源、项目变更和未知父任务。详细状态与测试以《Scert整改任务执行清单》首节和支持矩阵第二十节为准。其他资源、成员授权和生产验收继续实施,P0-05/P0-06/P0-07 保持 `[~]`,全部 44 项不缩减。下方各段保留历史证据,不表示最新状态。 > 最新执行状态:`7284958` 已交付域名/服务器归属、绑定约束与三库模式 11;`92c844f` 新增模式 12 修复 SQLite 替换目标行绕过引用保护,完整后端、三次竞态、MySQL 双版本及 Windows 独立程序回归均已通过。下方“归属仍未实现”只描述项目根实体历史批次。任务、审计等剩余资源与项目成员权限仍待接入,P0-06/P0-07 继续 `[~]`;全部 44 项范围不变。精确状态见《Scert整改任务执行清单》,证据及条件跳过见支持矩阵第十九节。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 4bdbde3..f9ff6c1 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,22 @@ # Scert 整改任务执行清单 +## 2026 年 9 月 28 日:项目成员与实时判权(已完成子项待复审) + +修改前检查点 `4f1900d` 已推送。需求沿用 P0-06/P0-07:项目成员独立于用户分组,支持六类项目角色;普通管理员不自动获得所有项目权限;普通用户不能读取完整私钥。系统级权限仍由有效超级管理员承担。 + +- [x] 核对原始范围和当前模型,确认不向旧用户或默认项目自动授予成员权限。 +- [x] 第 14 号迁移、成员授予/撤销及实时判权实现;多项目、多角色、禁用账户、未知操作默认拒绝。仅有效超级管理员拥有系统级跨项目能力,普通用户仍不能读取完整私钥。 +- [x] 两项目隔离、撤权和降权、父记录引用、迁移失败重试通过;MySQL 5.7/8.0 各 242 个叶子通过、0 失败/跳过(命令同时包含 SQLite 用例)。 +- [x] 复现并修复本批 SQLite 负行号误拒绝、扩展唯一索引替换可删除关联账户、空邮箱被误当唯一冲突三个边界;保留失败证据 `replacement-before.jsonl` 和 `empty-email-before.jsonl`,最后修复后重新执行全部本批验证。 +- [x] 最终完整后端 1623 个叶子通过、0 失败、51 项条件跳过;Windows 独立产品程序包含历史备份升级与新增成员多角色的备份恢复,恢复后重新验证授权和跨项目拒绝。 +- [x] 三次竞态合计 633 个叶子通过、0 失败、18 项条件跳过;`go vet ./...` 和 `go mod verify` 退出码为 0。最终证据为 `*-verified.jsonl`,原 `*-final.jsonl` 只作最后空邮箱修复前的中间证据,详见支持矩阵第二十一节。 +- [ ] 接入所有业务资源入口、资源真实项目解析、排队任务执行/取消/重试重判;当前基础模块不代表 Web/API 已隔离。 +- [x] 同步总清单、执行清单、模型、基础规格、开发清单、审查记录、支持矩阵和运维边界;本批代码与文档同批提交。P0-05/P0-06/P0-07 总项继续保持 `[~]`。 + +成员管理 Web/API、角色变更审计和所有业务调用链仍未交付;不开放不完整的项目管理入口。删除有成员的项目或账户必须先显式撤销关系,不能用删除身份再重建的方式继承旧权限。已有历史用户与用户分组不自动回填项目角色。 + +本批仅操作隔离测试库;不清理生产业务数据,不创建辅助验收脚本,不将新表或单项测试代替完整生产验收。 + ## 2026 年 9 月 28 日:任务/审计范围快照(本批实现与回归完成,等待复审) 修改前检查点 `5237e8d` 已推送。当前批次接续模式 12,不改写旧迁移,不删除历史任务,不给已删除资源伪造默认项目,不伪造操作者。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index afcf139..15ba235 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,6 +1,15 @@ # Scert 整改、合并与优化清单 -## 当前进度(2026 年 9 月 28 日,任务/审计关联批次) +## 当前进度(2026 年 9 月 28 日,项目成员与实时判权批次) + +- [x] 第 14 号迁移及六类项目角色已实现;授予/撤销在单条数据库语句内核对操作者,判权每次读取实际账户状态和当前成员关系。用户分组、普通管理员身份及默认项目均不隐式授予权限。 +- [x] 两项目、多角色、撤权、账户停用和降权、普通账户私钥限制、成员引用及 SQLite 替换边界通过专项回归;MySQL 双版本与 Windows 独立程序升级/成员备份恢复通过。详细数字和证据见支持矩阵第二十一节。 +- [x] 空邮箱误拦截修复后重新验证:完整后端 1623 个叶子通过、51 项条件跳过;三次竞态共 633 个叶子通过、18 项条件跳过;MySQL 双版本专项各 242 个叶子通过(包含 SQLite 用例),静态检查和模块校验通过。最新证据为 `*-verified.jsonl`,没有执行生产部署或清库。 +- [ ] 成员管理界面/接口、变更审计、全部业务入口资源判权和排队任务重新判权尚未接入;剩余资源关联、PostgreSQL 当前实库、Linux 原生与生产门禁继续实施。 + +本批交付的是成员与判权基础,不等于 Web/API 已完成项目隔离。P0-05、P0-06、P0-07 仍为 `[~]`,原始 44 项与验收标准保持不变。下方为历史批次证据。 + +### 历史进度:任务/审计关联批次 - [x] 已提交:域名、服务器项目归属及同项目绑定约束(模式 11);SQLite 替换操作引用保护(模式 12)。已验证范围见执行清单和支持矩阵第十九节。 - [x] P0-06 本批子项:模式 13、任务/审计项目/系统/未知范围快照、任务与操作者关联、历史保留均已实现并通过本批回归,等待复审。登录错记身份及会话失效后漏记审计也已复现修复。 diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index fdadfd1..7faa576 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -1,5 +1,7 @@ # 全面审查、测试与优化记录 +> 2026-09-28 项目成员批次:模式 14 已增加六类项目角色、实时判权和原子变更。复现并修复 SQLite 负行号误拒绝、扩展唯一索引替换绕过、空邮箱误当唯一冲突三个边界,保留失败日志;最后修复后完整后端 1623 个叶子通过、51 项条件跳过,三次竞态共 633 个叶子通过,MySQL 双版本各 242 个叶子通过且无跳过(含 SQLite 用例),Windows 独立成员备份恢复通过。最新证据为支持矩阵第二十一节的 `*-verified.jsonl`;旧 `*-final.jsonl` 不代表最后修复后的代码。本批未运行浏览器或生产环境;项目 Web/API 与任务重新判权仍未接入,不宣称完成 P0-07。下方模式 13 为上一批历史记录。 + > 当前批次:模式 13 任务/审计关联及身份来源复核。审计新增身份初版存在新登录漏记、携带旧 Cookie 时错记、更新自身账户后会话失效导致漏记的问题,已用正式回归复现并修复;不缓存权限,不恢复失效会话。首轮测试夹具问题与过严断言也分别保留原日志。本批最终证据及限制见支持矩阵第二十节,不能复用下方模式 12 的数字替代本批验证。 > 最新复核:资源归属实现已提交为 `7284958`,上一批最终日志重新统计为后端 1432 个叶子通过、47 项条件跳过。本轮正式用例复现 SQLite 在关闭递归触发器时的 20 个替换绕过失败,新增第 12 号迁移修复,包含主键/隐藏行号及项目替换。失败证据保留于 `artifacts/resource-replacement-20260928/before.jsonl`,回归、干净构建和数据库专项最终以支持矩阵第十九节为准。下方根实体批次为历史记录;不宣称项目授权或生产验收完成。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index a96ae42..feb711b 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -1,5 +1,7 @@ # 基础整改任务规格 +> 本批进度(模式 14):P0-07 已实现项目成员与六类角色、按实时账户状态和关系判权、单语句授权变更;P0-06 增加成员引用约束。用户分组不隐式授予权限,普通用户不能读取完整私钥。成员 Web/API、审计和全部资源/任务授权链仍未接入;P0-05/P0-06/P0-07 保持 `[~]`。证据与边界见执行清单首节、模型第九节和支持矩阵第二十一节;下方为历史批次。 + > 本批进度(模式 13):P0-06 已增加任务/审计的项目、系统和未知范围快照及任务关联;HTTP 身份、后台任务、历史删除资源与孤儿尝试按实际来源处理。范围保存与当前资源校验不是成员授权,P0-05/P0-06/P0-07 均保持 `[~]`。本批证据见执行清单首节及支持矩阵第二十节;下方模式 10~12 说明为历史批次,不覆盖本批结果。 > 当前进度:域名/服务器归属、三库第 11 号迁移及绑定约束已随 `7284958` 提交;第 12 号迁移已随 `92c844f` 修复 SQLite 替换绕过并通过本批回归,详细状态见执行清单和支持矩阵第十九节。下方根实体批次与 46 项条件跳过为历史数据;资源批次全量是 47 项条件跳过,不能混用。任务、审计、其他资源及成员授权仍未完成,P0-05/P0-06/P0-07 总项均保持 `[~]`。 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index b0ce0f8..2e43dd0 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -852,3 +852,64 @@ go test -json -count=1 -timeout=8m -run 'HistoryScope|ResourceProject|SQLProject - [ ] 历史大数据回填和查询性能准入。 本批子项为 `[x]` 等待复审;P0-05、P0-06、P0-07 总项保持 `[~]`,全部 44 项目标继续执行,不提供生产放行结论。 + +## 二十一、项目成员与实时判权基础(2026 年 9 月 28 日) + +### 21.1 范围与修改前状态 + +修改前检查点 `4f1900d` 已推送;新增模式 14 及项目成员、六类角色、实时权限判定和同语句授权变更。成员独立于用户分组;旧用户不自动获得默认项目权限,普通管理员必须具备显式项目角色,普通账户不得读取完整私钥。详细能力矩阵见《项目资源关联模型.md》第九节。 + +本批不开放新的项目管理路由,不声称全部业务已接入授权。测试使用正式 Go 测试、既有隔离 MySQL 夹具和独立产品程序;没有创建辅助验收脚本,没有清理生产数据库或操作远端服务。 + +### 21.2 最终证据 + +日志位于 `artifacts/project-members-20260928/`,构建产物和隔离实例均不入 Git。叶子数排除父测试,三次竞态按实际三轮累计;测试级条件跳过与无测试文件的包事件分开统计。 + +| 验证 | 最终日志/结果 | 结论与边界 | +| --- | --- | --- | +| 角色、成员、迁移专项 | `targeted-verified.jsonl`:115 个叶子通过,含父项 125 条,0 失败、2 项条件跳过 | SQLite 与角色/仓储通过;此命令未配置外部 MySQL,不将跳过当实库通过 | +| 完整后端 | `backend-verified.jsonl`:1623 个叶子通过,含父项 1767 条,0 失败、51 项条件跳过,28 个测试包通过 | 最新模式 14、SQLite 历史升级及 Windows 独立产品程序均在本次运行内;外部环境条件跳过仍未验收 | +| 三次竞态 | `race-verified.jsonl`:633 个叶子通过,含父项 687 条,0 失败、18 项条件跳过 | 覆盖成员、角色、历史范围、资源项目、用户管理与登录;不是全部后端三次竞态 | +| MySQL 5.7 | `mysql57-verified.jsonl`:242 个叶子通过,含父项 262 条,0 失败、0 跳过 | 既有 Go 夹具的回环隔离实例及严格 TLS;命令包含 SQLite 用例,不称 242 个纯 MySQL 用例 | +| MySQL 8.0 | `mysql80-verified.jsonl`:同上 | 相同专项独立执行;两版验证均在最后空邮箱修复后进行,包含迁移完整性检查 | +| Windows 独立产品程序 | 完整日志中的 `historical_backup`、`upgraded_backup` 均通过 | 历史模式经正式入口升级至 14;新备份含成员两个项目角色,恢复后重新验证允许与跨项目拒绝;程序来自提交前工作区,不称干净完整发行包 | +| 静态与依赖 | `go vet ./...`、`go mod verify` 退出码均为 0 | 在最终竞态之后顺序执行,依赖输出 `all modules verified` | + +SQLite 成员引用检查覆盖四种外键/递归触发器开关组合,包含主键、行号、用户名、邮箱、扩展唯一索引替换、负行号正常新增及多个空邮箱账户正常新增/更新。迁移版本登记或触发器冲突故障完整回滚,重试不改历史数据。MySQL 专项覆盖数据库默认字符集改变、版本登记失败后的 DDL 保留及重试、外键缺失时拒绝登记和修复后重试;不删除或重建业务用户表。 + +### 21.3 保留的初版失败证据 + +- `backend-initial.jsonl`:独立 CLI 测试断言仍固定为模式 13,实际程序已升级为 14,因此失败;已更新断言,并补充成员多角色备份恢复和恢复后的重新判权,不仅修改版本数字。 +- `replacement-before.jsonl`:两个失败叶子分别复现负行号关联记录导致正常新增被拒绝,以及用户扩展唯一索引的 `UPDATE OR REPLACE` 绕过成员引用保护。已修改插入前行号判定,并补充更新后引用检查;修复后的专项、双版本 MySQL、完整后端和竞态全部重新执行。 +- `empty-email-before.jsonl`:正式回归复现成员保护误把空邮箱当作唯一冲突,导致其他空邮箱账户正常新增被拒绝;修复遵循 `users(email) WHERE email <> ''` 的既有唯一性规则,并覆盖更新路径。 +- `initial.jsonl`、`targeted.jsonl`、`mysql57.jsonl`、`mysql80-review.jsonl`、`backend-review.jsonl` 以及全部 `*-final.jsonl` 均为中间验证;后者早于最后空邮箱修复,不替代当前 `*-verified.jsonl`。保留所有历史及失败证据。 + +### 21.4 实际命令 + +在 `backend/` 执行;当前 Windows PowerShell 的重定向 JSON 日志按 Unicode 编码读取。 + +```powershell +go build -o ..\build\review-scert-members-windows-amd64.exe ./cmd/scert +$env:SCERT_SQLITE_TEST_CLI = 'D:\Desktop\Scert\build\review-scert-members-windows-amd64.exe' +go test -json -count=1 -timeout=10m ./... +go test -json -count=1 -timeout=5m -run 'ProjectMembers|ProjectRolePermission|MigrationCatalog' ./internal/domain ./internal/database ./internal/repository +go test -race -json -count=3 -timeout=6m -run 'ProjectMembers|ProjectRolePermission|HistoryScope|ResourceProject|UserManagement|AuthLogin' ./internal/domain ./internal/database ./internal/repository ./internal/httpapi +go vet ./... +go mod verify +``` + +MySQL 分别设置 `SCERT_MYSQL_TEST_BIN` 为 `E:\DB\5.7\bin`、`E:\DB\8.0\bin`,`SCERT_MYSQL_TEST_ROOT` 为 `D:\Desktop\Scert\artifacts\project-members-20260928\mysql-isolated`: + +```powershell +go test -json -count=1 -timeout=8m -run 'ProjectMembers|HistoryScope|ResourceProject|SQLProjectRepositoryMySQLReal|MySQLRealProjectMigrationRetry' ./internal/repository ./internal/database +``` + +### 21.5 未完成项及后续依赖 + +- [ ] 成员管理 Web/API、界面、分页与成员变更审计;当前尚无完整对外项目管理入口。 +- [ ] 所有业务资源根据真实记录解析项目的读写授权、私钥/凭据入口、任务提交/执行/取消/重试和排队期间撤权后的重新判权;不能把仓储判权基础当成完整业务隔离。 +- [ ] 剩余核心资源项目关系、跨项目 Web/API 列表/详情/导出/删除/回滚越权矩阵。 +- [ ] PostgreSQL 当前实库、完整三库恢复、Linux 原生、干净完整发行包、Windows Service/systemd 重启与原定 12 层生产门禁。 +- [ ] 成员与资源规模扩大后的查询/触发器性能准入。 + +本批子项 `[x]` 等待复审;P0-05、P0-06、P0-07 总项仍为 `[~]`。原始 44 项及最终生产验收范围保持不变。 diff --git a/doc/生产部署与运维说明.md b/doc/生产部署与运维说明.md index 1f982b0..3918de0 100644 --- a/doc/生产部署与运维说明.md +++ b/doc/生产部署与运维说明.md @@ -1,5 +1,9 @@ # Scert 生产部署与运维说明 +> 最新候选模式为 14:新增项目成员多角色关系与实时判权基础,不为历史账户自动授予默认项目权限,也不将用户分组转为项目权限。必须使用支持模式 14 的程序打开升级后的库;下方模式 13 及更早提示仅为历史批次。不能删除迁移记录强行降级。 + +> 成员关系引用的项目和账户不能直接删除或替换,必须先显式撤销关系。MySQL 成员外键匹配历史用户主键字符集,版本登记失败可重试,残缺同名表必须拒绝;SQLite 触发器冲突时回滚,不覆盖旧触发器。成员管理接口、界面和业务授权尚未全部接入;此候选不是生产放行版本。本批隔离验证、备份恢复和剩余缺口见支持矩阵第二十一节,未执行生产清库或部署。 + > 最新候选模式为 13:新增任务/审计范围快照与历史回填。必须使用支持模式 13 的程序打开已升级数据库,不能删除迁移记录降级。SQLite/PostgreSQL 在事务中回填;MySQL 可能保留部分 DDL,修复失败原因后使用同版迁移重试。回填会执行原有更新触发器,禁止绕过触发器或删除历史记录强行升级。未知归属和操作者保持未知,不自动补成默认项目或管理员。 > 本批只做隔离验证,不执行生产升级或清库。程序备份恢复、双版本 MySQL 及未覆盖项见支持矩阵第二十节;PostgreSQL 当前实库、Linux 原生、干净完整发行包、服务重启及生产门禁仍未完成。下方模式 10~12 提示为历史记录,不是当前程序版本,也不是生产放行许可。 diff --git a/doc/项目资源关联模型.md b/doc/项目资源关联模型.md index bc74e4e..f213cba 100644 --- a/doc/项目资源关联模型.md +++ b/doc/项目资源关联模型.md @@ -1,5 +1,7 @@ # 项目资源关联模型 +> 当前模式为 14:已新增项目成员多角色关系、实时判权及原子授予/撤销,见第九节。成员不是用户分组,不向历史账户自动授予默认项目权限。该模块尚未接入完整 Web/API 与任务执行链,不能据此宣称资源隔离完成。第七、八节及下方模式 13 提示是上一批历史记录;本批证据见支持矩阵第二十一节。 + > 当前实现更新:第 11、12 号迁移已提交;本批新增第 13 号迁移和任务/审计范围快照,见第七、八节。第五节是项目根实体历史批次,不代表最新资源状态。本批测试及未覆盖范围以执行清单首节、支持矩阵第二十节为准;项目成员授权仍未完成。 > 本批最终验证:SQLite、MySQL 5.7/8.0 项目专项、3 次竞态检测、完整后端及独立 Windows CLI 备份恢复已通过;第五节保留测试启动时状态,最终以执行清单和支持矩阵第十八节为准。P0-06/P0-07 仍为 `[~]`。 @@ -21,7 +23,7 @@ | 实体 | 当前状态 | 后续关系与交付要求 | |---|---|---| | `projects` | 已新增模型、三库第 10 号迁移和 SQL 仓储 | 作为项目根实体,统一资源引用;尚无对外接口 | -| `users`、`roles` | 既有用户和角色实现;项目成员与角色分配待补 | 用户经项目成员关系获得权限,不能按用户组名称推导项目权限 | +| `users`、`project_members` | 模式 14 已增加独立项目成员、多角色、实时判权和原子授予/撤销 | 尚待成员管理接口、界面、审计与全部业务授权接入;不能按用户组名称推导项目权限 | | `domains`、`servers` | 第 11 号迁移已增加项目、所有者、创建人和更新人 | 历史数据回填默认项目,未知身份留空;第 12 号迁移补强 SQLite 替换保护;成员授权仍待完成 | | `domain_servers` | 已保存项目并校验双方一致性 | 事务失败保持原绑定;三库引用约束已实现,PostgreSQL 当前实库待验 | | `tasks`、`task_attempts` | 模式 13 任务保存范围快照,尝试读取父任务范围;历史孤儿返回未知 | 已校验新任务/尝试与当前资源一致;项目成员授权、撤权重判待补 | @@ -96,3 +98,38 @@ SQLite 与 PostgreSQL 在迁移事务内回填,失败回滚;MySQL DDL 可能 | `GET /api/v1/audit-logs` | `scope_type`、`project_id`、`task_id`、`operator_id` | 审计事件范围、明确关联的任务和本次已验证操作者;不可确认的标识为空 | `scope_type` 为 `project`、`system` 或 `unknown`;这些字段仅表达归属,不是访问令牌。登录审计使用本次认证成功的用户,失败不把请求声明或旧 Cookie 当成已验证身份;更新自身账户、改密导致会话失效后仍保存实际操作者,但不恢复旧会话。既有权限限制继续生效,新增项目成员权限尚未交付。 + +## 九、第 14 号迁移:成员、角色与实时判权基础 + +`project_members` 以 `(project_id, user_id, role)` 为主键,记录授予时间;允许一个用户在多个项目内拥有多个角色,不改变现有用户分组数量规则。三库迁移不自动创建成员,不推测历史授权。项目和用户被成员引用时禁止删除或改标识,必须先撤销成员关系;历史任务/审计仍沿用模式 13 的独立快照,不级联删除。 + +### 9.1 能力矩阵 + +所有有效项目角色都可读取其项目基本资料,其他能力如下;未列出的操作默认拒绝。 + +| 项目角色 | 授予的项目能力 | +| --- | --- | +| 项目管理员 `project_admin` | 本模块定义的全部项目能力,包括成员管理、任务取消;仍受普通账户不能读取完整私钥的上限约束 | +| 证书管理员 `certificate_admin` | 证书读取、管理;完整私钥读取还要求登录账户为普通管理员或超级管理员 | +| 部署管理员 `deployment_admin` | 证书元数据读取、服务器读取/管理、部署读取/执行/回滚;不授予证书私钥读取 | +| DNS 管理员 `dns_admin` | DNS 元数据读取、管理、凭据使用;凭据使用不等于导出明文密钥 | +| 审计查看者 `audit_viewer` | 审计及任务读取 | +| 只读用户 `read_only` | 证书、服务器、部署、DNS 元数据及任务读取;不授予审计、凭据使用或任何写操作 | + +登录角色 `super_admin` 不是可授予的项目角色。只有当前状态为 `active` 的真实超级管理员可跨项目执行已定义操作;不存在的项目和未知操作即使对超级管理员也拒绝。普通管理员不因登录角色或同组身份获得项目能力。 + +### 9.2 仓储与并发边界 + +- `AuthorizeProject` 每次查询实际用户状态、实际项目及当前成员,不缓存会话中的角色或权限。撤销成员、停用账户、普通管理员降为普通用户后,下次判权立即采用新状态。 +- `GrantProjectRole`、`RevokeProjectRole` 仅允许有效超级管理员或目标项目的项目管理员;授权条件与成员写入在同一条 SQL 中执行,防止应用层先查后写的间隙。MySQL 撤销使用自连接,不依赖不兼容的同表删除子查询。 +- 未知角色、无效目标、禁用操作者及越权变更拒绝;重复授予不会产生重复关系。项目管理员可撤销自己的角色,之后不能自行恢复;有效超级管理员保留恢复能力。 +- 判权结果不是长期许可。业务入口仍须从实际资源解析项目,并在执行副作用前重新判权;任务提交、排队、取消、重试及撤权竞态的完整接入尚未完成。 +- 成员管理接口、界面、分页、变更审计尚未交付。当前没有新开放项目 HTTP 路由;仓储方法不能直接作为无需会话验证的外部入口。 + +### 9.3 迁移与恢复边界 + +SQLite 使用外键和额外触发器保护关联,不依赖外键/递归触发器开关。插入前未指定行号时的 `-1` 不误拒绝正常新增,插入及更新后继续检查引用;扩展唯一索引造成的隐式删除也应回滚。邮箱冲突遵循既有非空邮箱唯一索引,多个空邮箱账户的正常新增和更新不受阻。触发器重名或版本登记失败,表与新触发器随事务回滚;不删除旧触发器强行通过。 + +MySQL 从历史 `users.id` 读取实际字符集和排序规则建立成员外键,不采用可能已变更的库默认值;DDL 后版本登记失败可重试,保留已有成员。重试检查用户字段、角色枚举、主键和双亲外键,残缺同名表拒绝登记版本,不自行删除或修补业务表。PostgreSQL 使用事务迁移与外键;本批未完成其原生实库验证。 + +Windows 独立产品程序已验证升级前的历史备份以及含两个项目角色的新备份恢复;恢复后重新走正式入口并核对成员授权及跨项目拒绝。此证据不是完整发行包、真实服务重启、三库全部恢复或生产验收。