From 3f6d5b86b51f194ad60aa39b7105add87fd99a78 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 27 Sep 2026 21:46:54 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90MySQL=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=99=A8=E5=A4=87=E4=BB=BD=E5=8F=8A=E6=9C=89=E6=95=88=E6=9D=83?= =?UTF-8?q?=E9=99=90=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/cli/mysql_backup.go | 9 +- backend/internal/cli/mysql_privileges.go | 177 +++++++++++++ backend/internal/cli/mysql_privileges_test.go | 24 ++ .../cli/mysql_programs_integration_test.go | 10 +- backend/internal/cli/mysql_triggers.go | 118 +++++++++ .../cli/mysql_triggers_integration_test.go | 246 ++++++++++++++++++ .../internal/testsupport/mysqltest/server.go | 2 +- 7 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 backend/internal/cli/mysql_privileges.go create mode 100644 backend/internal/cli/mysql_privileges_test.go create mode 100644 backend/internal/cli/mysql_triggers.go create mode 100644 backend/internal/cli/mysql_triggers_integration_test.go diff --git a/backend/internal/cli/mysql_backup.go b/backend/internal/cli/mysql_backup.go index 4b6a7be..b445957 100644 --- a/backend/internal/cli/mysql_backup.go +++ b/backend/internal/cli/mysql_backup.go @@ -15,7 +15,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/database" ) -// backupMySQL 将 MySQL 的表结构和数据导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。 +// backupMySQL 将 MySQL 的基础表、数据和触发器导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。 func backupMySQL(ctx context.Context, cfg config.Config, output string) error { resolved, err := resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config) if err != nil { @@ -85,12 +85,19 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output if err := rows.Close(); err != nil { return fmt.Errorf("关闭 MySQL 表列表失败:%w", err) } + if err := requireMySQLTriggerVisibility(ctx, tx, tableNames); err != nil { + return err + } for _, tableName := range tableNames { if err := dumpMySQLTable(ctx, tx, file, tableName); err != nil { return err } } + // 必须先恢复所有基础数据,再创建触发器,避免导入时重放业务副作用。 + if err := dumpMySQLTriggers(ctx, tx, file); err != nil { + return err + } if _, err := fmt.Fprint(file, "SET FOREIGN_KEY_CHECKS=1;\n"); err != nil { return fmt.Errorf("写入 MySQL 备份尾失败:%w", err) } diff --git a/backend/internal/cli/mysql_privileges.go b/backend/internal/cli/mysql_privileges.go new file mode 100644 index 0000000..0baed27 --- /dev/null +++ b/backend/internal/cli/mysql_privileges.go @@ -0,0 +1,177 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" +) + +var mysqlPrivilegeGrant = regexp.MustCompile("^(GRANT|REVOKE) (.+?) ON (" + "`(?:``|[^`])*`" + "|\\*)\\.(" + "`(?:``|[^`])*`" + "|\\*) (?:TO|FROM) ") + +// 元数据会按权限静默过滤;必须先证明每张可见基础表的 TRIGGER 权限,不能把空列表当成无触发器。 +func requireMySQLTriggerVisibility(ctx context.Context, tx *sql.Tx, tables []string) error { + if len(tables) == 0 { + return nil + } + var name, version string + var lowerCaseNames int + if err := tx.QueryRowContext(ctx, "SELECT DATABASE(), VERSION(), @@lower_case_table_names").Scan(&name, &version, &lowerCaseNames); err != nil { + return err + } + normalize := func(value string) string { + if lowerCaseNames != 0 { + return strings.ToLower(value) + } + return value + } + name = normalize(name) + query := "SHOW GRANTS FOR CURRENT_USER" + if strings.HasPrefix(version, "8.0.") { + rows, err := tx.QueryContext(ctx, "SELECT ROLE_NAME, ROLE_HOST FROM information_schema.ENABLED_ROLES ORDER BY ROLE_NAME, ROLE_HOST") + if err != nil { + return fmt.Errorf("读取 MySQL 当前角色失败:%w", err) + } + var roles []string + for rows.Next() { + var role, host string + if err := rows.Scan(&role, &host); err != nil { + _ = rows.Close() + return err + } + roles = append(roles, quoteMySQLIdentifier(role)+"@"+quoteMySQLIdentifier(host)) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + if len(roles) > 0 { + query += " USING " + strings.Join(roles, ", ") + } + } + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return fmt.Errorf("读取 MySQL 有效权限失败:%w", err) + } + var grants []string + for rows.Next() { + var grant string + if err := rows.Scan(&grant); err != nil { + _ = rows.Close() + return err + } + grants = append(grants, grant) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + // 5.7 返回空列表;8.0 部分撤销模式下,数据库授权中的百分号和下划线为字面量。 + partialRevokes := false + rows, err = tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'") + if err != nil { + return err + } + for rows.Next() { + var variable, value string + if err := rows.Scan(&variable, &value); err != nil { + _ = rows.Close() + return err + } + partialRevokes = strings.EqualFold(value, "ON") + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + global, revoked := false, false + exactSeen, exactAllowed := false, false + wildcardSeen, wildcardAllowed := false, true + covered := make(map[string]bool) + for _, grant := range grants { + match := mysqlPrivilegeGrant.FindStringSubmatch(grant) + if len(match) == 0 { + continue + } + hasTrigger := mysqlHasTriggerPrivilege(match[2]) + if match[3] == "*" && match[4] == "*" { + global = global || (match[1] == "GRANT" && hasTrigger) + continue + } + databaseName := normalize(unquoteMySQLGrantIdentifier(match[3])) + matches := name == databaseName + if match[4] == "*" && !partialRevokes && match[1] == "GRANT" { + if err := tx.QueryRowContext(ctx, "SELECT BINARY ? LIKE BINARY ?", name, databaseName).Scan(&matches); err != nil { + return err + } + } + if !matches { + continue + } + if match[1] == "REVOKE" { + revoked = revoked || hasTrigger + } else if match[4] == "*" { + if partialRevokes || !mysqlGrantHasWildcard(databaseName) { + exactSeen = true + exactAllowed = exactAllowed || hasTrigger + } else { + wildcardSeen = true + wildcardAllowed = wildcardAllowed && hasTrigger + } + } else if hasTrigger { + covered[normalize(unquoteMySQLGrantIdentifier(match[4]))] = true + } + } + // 精确库授权优先。多个匹配通配授权可能按内部顺序遮蔽,不能简单合并为更大权限。 + // 不能证明所有候选都包含 TRIGGER 时保守失败,管理员可提供明确的精确库或表级授权。 + schema := exactAllowed + if !exactSeen { + schema = wildcardSeen && wildcardAllowed + } + for _, table := range tables { + if !((global && !revoked) || schema || covered[normalize(table)]) { + return fmt.Errorf("MySQL 备份无法确认表 %s 的 TRIGGER 可见权限,不能把隐藏的触发器当成不存在", table) + } + } + return nil +} + +func mysqlGrantHasWildcard(value string) bool { + escaped := false + for _, char := range value { + if escaped { + escaped = false + } else if char == rune(92) { + escaped = true + } else if char == '%' || char == '_' { + return true + } + } + return false +} + +func mysqlHasTriggerPrivilege(privileges string) bool { + for _, privilege := range strings.Split(privileges, ",") { + if value := strings.TrimSpace(privilege); value == "TRIGGER" || value == "ALL PRIVILEGES" { + return true + } + } + return false +} + +func unquoteMySQLGrantIdentifier(value string) string { + if len(value) >= 2 && value[0] == '`' && value[len(value)-1] == '`' { + return strings.ReplaceAll(value[1:len(value)-1], "``", "`") + } + return value +} diff --git a/backend/internal/cli/mysql_privileges_test.go b/backend/internal/cli/mysql_privileges_test.go new file mode 100644 index 0000000..3d74c62 --- /dev/null +++ b/backend/internal/cli/mysql_privileges_test.go @@ -0,0 +1,24 @@ +package cli + +import "testing" + +func TestMySQLTriggerGrantParsing(t *testing.T) { + for _, item := range []struct { + grant, schema, table string + trigger bool + }{ + {"GRANT SELECT, TRIGGER ON `sample`.* TO 'account'@'localhost'", "sample", "*", true}, + {"GRANT ALL PRIVILEGES ON *.* TO 'account'@'localhost'", "*", "*", true}, + {"GRANT TRIGGER ON `sam``ple`.`ta``ble``` TO 'account'@'localhost'", "sam`ple", "ta`ble`", true}, + {"REVOKE TRIGGER ON `sample`.* FROM 'account'@'localhost'", "sample", "*", true}, + {"GRANT SELECT (`TRIGGER`) ON `sample`.`items` TO 'account'@'localhost'", "sample", "items", false}, + } { + match := mysqlPrivilegeGrant.FindStringSubmatch(item.grant) + if len(match) != 5 || unquoteMySQLGrantIdentifier(match[3]) != item.schema || unquoteMySQLGrantIdentifier(match[4]) != item.table || mysqlHasTriggerPrivilege(match[2]) != item.trigger { + t.Fatalf("授权语句解析错误:%s", item.grant) + } + } + if match := mysqlPrivilegeGrant.FindStringSubmatch("GRANT `TRIGGER`@`%` TO `account`@`localhost`"); match != nil { + t.Fatal("不能把角色名称当成直接授予的对象权限") + } +} diff --git a/backend/internal/cli/mysql_programs_integration_test.go b/backend/internal/cli/mysql_programs_integration_test.go index 27438d5..e5bf8de 100644 --- a/backend/internal/cli/mysql_programs_integration_test.go +++ b/backend/internal/cli/mysql_programs_integration_test.go @@ -119,7 +119,15 @@ func TestMySQLRealOfficialStoredPrograms(t *testing.T) { } t.Run("insufficient_privileges", func(t *testing.T) { target := server.Database(t) - err := restoreMySQL(t.Context(), target, output) + // 通用夹具现有 TRIGGER 可见权限;本失败场景显式撤销,继续覆盖无对象管理权限的恢复。 + limited, err := mysql.ParseDSN(target.DSN) + if err != nil { + t.Fatal(err) + } + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE TRIGGER ON "+quoteMySQLIdentifier(limited.DBName)+".* FROM "+mysqlStringLiteral(limited.User)+"@'127.0.0.1'"); err != nil { + t.Fatal(err) + } + err = restoreMySQL(t.Context(), target, output) var serverError *mysql.MySQLError if !errors.As(err, &serverError) || (serverError.Number != 1142 && serverError.Number != 1227 && serverError.Number != 1419) { t.Fatalf("权限不足时必须返回原始数据库错误,不能忽略或提升权限:%v", err) diff --git a/backend/internal/cli/mysql_triggers.go b/backend/internal/cli/mysql_triggers.go new file mode 100644 index 0000000..30db9ff --- /dev/null +++ b/backend/internal/cli/mysql_triggers.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "database/sql" + "fmt" + "io" + "strings" +) + +func dumpMySQLTriggers(ctx context.Context, tx *sql.Tx, output io.Writer) error { + // 名称排序不能代表执行顺序;同一表、时机和事件按服务器的 ACTION_ORDER 导出。 + rows, err := tx.QueryContext(ctx, `SELECT TRIGGER_NAME FROM information_schema.TRIGGERS + WHERE TRIGGER_SCHEMA = DATABASE() + ORDER BY EVENT_OBJECT_TABLE, ACTION_TIMING, EVENT_MANIPULATION, ACTION_ORDER`) + if err != nil { + return fmt.Errorf("读取 MySQL 触发器列表失败:%w", err) + } + var names []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + _ = rows.Close() + return fmt.Errorf("读取 MySQL 触发器名称失败:%w", err) + } + names = append(names, name) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("遍历 MySQL 触发器失败:%w", err) + } + if err := rows.Close(); err != nil { + return err + } + if len(names) == 0 { + return nil + } + var databaseCollation string + if err := tx.QueryRowContext(ctx, "SELECT @@collation_database").Scan(&databaseCollation); err != nil { + return fmt.Errorf("读取 MySQL 数据库排序规则失败:%w", err) + } + if _, err := fmt.Fprintln(output, `SET @scert_trigger_sql_mode = @@SESSION.sql_mode; +SET @scert_trigger_charset = @@SESSION.character_set_client; +SET @scert_trigger_collation = @@SESSION.collation_connection;`); err != nil { + return err + } + for _, name := range names { + definition, err := readMySQLTrigger(ctx, tx, name) + if err != nil { + return err + } + // 文件中的创建语句使用原客户端字符集,恢复时才能同时保留正文和创建元数据。 + var encoded, roundTrip []byte + charset := quoteMySQLIdentifier(definition["character_set_client"]) + query := "SELECT CAST(CONVERT(? USING " + charset + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + charset + ") USING utf8mb4) AS BINARY)" + original := definition["SQL Original Statement"] + if err := tx.QueryRowContext(ctx, query, original, original).Scan(&encoded, &roundTrip); err != nil { + return fmt.Errorf("转换 MySQL 触发器字符集失败(%s):%w", name, err) + } + if string(roundTrip) != original { + return fmt.Errorf("MySQL 触发器无法无损转换到创建字符集(%s)", name) + } + delimiter := ";;" + for strings.Contains(string(encoded), delimiter) { + delimiter += ";" + } + _, err = fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = %s;\nSET SESSION character_set_client = %s;\nSET SESSION collation_connection = %s;\nDELIMITER %s\n%s%s\nDELIMITER ;\n", + quoteMySQLIdentifier(definition["Database Collation"]), mysqlStringLiteral(definition["sql_mode"]), + mysqlStringLiteral(definition["character_set_client"]), mysqlStringLiteral(definition["collation_connection"]), delimiter, encoded, delimiter) + if err != nil { + return fmt.Errorf("写入 MySQL 触发器失败(%s):%w", name, err) + } + } + _, err = fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = @scert_trigger_sql_mode;\nSET SESSION character_set_client = @scert_trigger_charset;\nSET SESSION collation_connection = @scert_trigger_collation;\n\n", quoteMySQLIdentifier(databaseCollation)) + return err +} + +func readMySQLTrigger(ctx context.Context, tx *sql.Tx, name string) (map[string]string, error) { + rows, err := tx.QueryContext(ctx, "SHOW CREATE TRIGGER "+quoteMySQLIdentifier(name)) + if err != nil { + return nil, fmt.Errorf("读取 MySQL 触发器定义失败(%s):%w", name, err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + return nil, err + } + values := make([]sql.NullString, len(columns)) + pointers := make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if !rows.Next() { + if err := rows.Err(); err != nil { + return nil, err + } + return nil, fmt.Errorf("MySQL 触发器定义不可见(%s)", name) + } + if err := rows.Scan(pointers...); err != nil { + return nil, err + } + definition := make(map[string]string, len(columns)) + for i, column := range columns { + if values[i].Valid { + definition[column] = values[i].String + } + } + for _, column := range []string{"sql_mode", "SQL Original Statement", "character_set_client", "collation_connection", "Database Collation"} { + value, exists := definition[column] + if !exists || (value == "" && column != "sql_mode") { + return nil, fmt.Errorf("MySQL 触发器定义字段不完整(%s):%s", name, column) + } + } + if rows.Next() { + return nil, fmt.Errorf("MySQL 触发器定义返回了多行(%s)", name) + } + return definition, rows.Err() +} diff --git a/backend/internal/cli/mysql_triggers_integration_test.go b/backend/internal/cli/mysql_triggers_integration_test.go new file mode 100644 index 0000000..81a0233 --- /dev/null +++ b/backend/internal/cli/mysql_triggers_integration_test.go @@ -0,0 +1,246 @@ +package cli + +import ( + "database/sql" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/go-sql-driver/mysql" + + "git.pcux.cn/xuans/Scert/backend/internal/database" + "git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest" +) + +func TestMySQLRealNativeTriggers(t *testing.T) { + server := mysqltest.New(t) + source := server.Database(t) + parsed, err := mysql.ParseDSN(source.DSN) + if err != nil { + t.Fatal(err) + } + grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName) + // 仅给独立实例的专用账户补足启用二进制日志时的触发器创建权限。 + if server.IsMySQL8() { + if _, err := server.Admin().ExecContext(t.Context(), "GRANT SUPER ON *.* TO "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil { + t.Fatal(err) + } + } + db, err := database.Open(t.Context(), source) + if err != nil { + t.Fatal(err) + } + defer db.Close() + connection, err := db.DB.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + for _, statement := range []string{ + "SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'", + "SET NAMES utf8mb4 COLLATE utf8mb4_bin", + "CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB", + "CREATE TABLE audit_values (value INT NOT NULL) ENGINE=InnoDB", + "INSERT INTO interop VALUES (1, 10, '原始值')", + "CREATE TRIGGER z_first BEFORE INSERT ON interop FOR EACH ROW BEGIN SET NEW.value = NEW.value + 2; SET NEW.label = CONCAT(NEW.label, ';第一步'); END", + "CREATE TRIGGER a_second BEFORE INSERT ON interop FOR EACH ROW FOLLOWS z_first BEGIN SET NEW.value = NEW.value * 3; SET NEW.label = CONCAT(NEW.label, ';第二步'); END", + "CREATE TRIGGER audit_insert AFTER INSERT ON interop FOR EACH ROW BEGIN INSERT INTO audit_values VALUES (NEW.value); END", + "CREATE TABLE legacy_values (id INT PRIMARY KEY, label VARCHAR(100)) ENGINE=InnoDB", + "ALTER DATABASE COLLATE latin1_swedish_ci", + "SET NAMES latin1 COLLATE latin1_bin", + "CREATE TRIGGER legacy_insert BEFORE INSERT ON legacy_values FOR EACH ROW SET NEW.label = CONCAT(NEW.label, 'caf" + string([]byte{0xe9}) + ";;')", + "SET NAMES utf8mb4 COLLATE utf8mb4_bin", + "ALTER DATABASE COLLATE utf8mb4_unicode_ci", + } { + if _, err := connection.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + definition := readMySQLTriggerDefinitions(t, db.DB) + if strings.Count(definition, "\n") != 3 { + t.Fatal("源库四个触发器没有完整创建") + } + output := filepath.Join(server.Root(), "native-triggers.sql") + if err := backupMySQLDatabase(t.Context(), source, output); err != nil { + t.Fatal(err) + } + for _, direction := range []string{"scert_to_scert", "scert_to_official"} { + t.Run(direction, func(t *testing.T) { + target := server.Database(t) + targetDSN, err := mysql.ParseDSN(target.DSN) + if err != nil { + t.Fatal(err) + } + grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName) + targetDSN.User = parsed.User + target.DSN = targetDSN.FormatDSN() + if direction == "scert_to_official" { + server.Import(t, target, output) + } else if err := restoreMySQL(t.Context(), target, output); err != nil { + t.Fatal(err) + } + verify, err := database.Open(t.Context(), target) + if err != nil { + t.Fatal(err) + } + defer verify.Close() + if restored := readMySQLTriggerDefinitions(t, verify.DB); restored != definition { + t.Fatalf("触发器定义、顺序或创建上下文发生变化:\n源:%s\n恢复:%s", definition, restored) + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值") + checkMySQLInteropValue(t, target, "SELECT CAST(COUNT(*) AS CHAR) FROM audit_values", "0") + if _, err := verify.DB.ExecContext(t.Context(), "INSERT INTO interop VALUES (2, 4, '新行')"); err != nil { + t.Fatal(err) + } + checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 2", "18|新行;第一步;第二步") + checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM audit_values", "18") + if _, err := verify.DB.ExecContext(t.Context(), "INSERT INTO legacy_values VALUES (1, '')"); err != nil { + t.Fatal(err) + } + checkMySQLInteropValue(t, target, "SELECT label FROM legacy_values", "café;;") + checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci") + }) + } + t.Run("hidden_triggers_are_not_silently_lost", func(t *testing.T) { + if _, err := server.Admin().ExecContext(t.Context(), "REVOKE TRIGGER ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+mysqlStringLiteral(parsed.User)+"@'127.0.0.1'"); err != nil { + t.Fatal(err) + } + checkMySQLInteropValue(t, source, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE()", "0") + failed := filepath.Join(server.Root(), "hidden-triggers.sql") + if err := os.WriteFile(failed, []byte("existing backup"), 0o600); err != nil { + t.Fatal(err) + } + if err := backupMySQLDatabase(t.Context(), source, failed); err == nil || !strings.Contains(err.Error(), "TRIGGER") { + t.Fatalf("没有触发器可见权限时必须明确失败:%v", err) + } + data, err := os.ReadFile(failed) + if err != nil || string(data) != "existing backup" { + t.Fatal("权限不足破坏了已有备份") + } + if _, err := os.Stat(failed + ".tmp"); !os.IsNotExist(err) { + t.Fatal("权限不足遗留临时文件") + } + }) +} + +func TestMySQLRealTriggerPrivileges(t *testing.T) { + server := mysqltest.New(t) + scenarios := []string{"schema", "global", "per_table", "partial_table", "unrelated_schema", "escaped_schema", "escaped_identifier", "shadowed_schema", "shadowed_exact"} + if server.IsMySQL8() { + scenarios = append(scenarios, "global_dynamic", "active_role", "nested_role", "inactive_role", "partial_revoke") + } + for _, scenario := range scenarios { + t.Run(scenario, func(t *testing.T) { + cfg := server.Database(t) + parsed, err := mysql.ParseDSN(cfg.DSN) + if err != nil { + t.Fatal(err) + } + account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'" + schema := quoteMySQLIdentifier(parsed.DBName) + first, second := "first_table", "second_table" + if scenario == "escaped_identifier" { + first, second = "first`table`", "second_table%" + } + statements := []string{ + "CREATE TABLE " + schema + "." + quoteMySQLIdentifier(first) + " (id INT) ENGINE=InnoDB", + "CREATE TABLE " + schema + "." + quoteMySQLIdentifier(second) + " (id INT) ENGINE=InnoDB", + "REVOKE TRIGGER ON " + schema + ".* FROM " + account, + } + switch scenario { + case "schema": + statements = append(statements, "GRANT TRIGGER ON "+schema+".* TO "+account) + case "escaped_schema": + escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_") + statements = append(statements, "GRANT SELECT, TRIGGER ON "+quoteMySQLIdentifier(escaped)+".* TO "+account) + case "shadowed_schema": + statements = append(statements, "GRANT TRIGGER ON `scert%`.* TO "+account) + case "shadowed_exact": + escaped := strings.ReplaceAll(parsed.DBName, "_", string(rune(92))+"_") + statements = append(statements, "GRANT TRIGGER ON "+schema+".* TO "+account, "GRANT SELECT ON "+quoteMySQLIdentifier(escaped)+".* TO "+account) + case "global": + statements = append(statements, "GRANT TRIGGER ON *.* TO "+account) + case "global_dynamic": + statements = append(statements, "GRANT TRIGGER, BACKUP_ADMIN ON *.* TO "+account) + case "per_table", "escaped_identifier": + statements = append(statements, "GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(first)+" TO "+account, + "GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(second)+" TO "+account) + case "partial_table": + statements = append(statements, "GRANT TRIGGER ON "+schema+"."+quoteMySQLIdentifier(first)+" TO "+account) + case "unrelated_schema": + statements = append(statements, "GRANT TRIGGER ON `another_database`.* TO "+account) + case "active_role", "nested_role", "inactive_role": + role := quoteMySQLIdentifier("trigger_role_"+scenario) + "@'%'" + statements = append(statements, "CREATE ROLE "+role, "GRANT TRIGGER ON "+schema+".* TO "+role, "GRANT "+role+" TO "+account) + if scenario == "active_role" { + statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account) + } + if scenario == "nested_role" { + parent := "'trigger_parent'@'%'" + statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, + "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account) + } + case "partial_revoke": + statements = append(statements, "SET GLOBAL partial_revokes = ON", "GRANT TRIGGER ON *.* TO "+account, "REVOKE TRIGGER ON "+schema+".* FROM "+account) + } + for _, statement := range statements { + if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + for i, table := range []string{first, second} { + trigger := quoteMySQLIdentifier("probe_" + string(rune('a'+i))) + statement := "CREATE TRIGGER " + schema + "." + trigger + " BEFORE INSERT ON " + schema + "." + quoteMySQLIdentifier(table) + " FOR EACH ROW SET NEW.id = NEW.id + 1" + if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + output := filepath.Join(server.Root(), "privilege-"+scenario+".sql") + err = backupMySQLDatabase(t.Context(), cfg, output) + denied := scenario == "partial_table" || scenario == "unrelated_schema" || scenario == "inactive_role" || scenario == "partial_revoke" || scenario == "shadowed_schema" || scenario == "shadowed_exact" + if denied { + if err == nil || !strings.Contains(err.Error(), "TRIGGER") { + t.Fatalf("缺少有效权限时没有明确拒绝备份:%v", err) + } + if _, err := os.Stat(output); !os.IsNotExist(err) { + t.Fatal("权限不足仍发布了备份") + } + } else if err != nil { + t.Fatal(err) + } + count := "2" + if denied { + count = "0" + if scenario == "partial_table" { + count = "1" + } + } + checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE()", count) + }) + } +} + +func readMySQLTriggerDefinitions(t *testing.T, db *sql.DB) string { + t.Helper() + rows, err := db.QueryContext(t.Context(), `SELECT JSON_ARRAY(TRIGGER_NAME, EVENT_MANIPULATION, + EVENT_OBJECT_TABLE, ACTION_ORDER, ACTION_STATEMENT, ACTION_ORIENTATION, ACTION_TIMING, + SQL_MODE, DEFINER, CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION) + FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = DATABASE() ORDER BY TRIGGER_NAME`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var definitions []string + for rows.Next() { + var definition string + if err := rows.Scan(&definition); err != nil { + t.Fatal(err) + } + definitions = append(definitions, definition) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return strings.Join(definitions, "\n") +} diff --git a/backend/internal/testsupport/mysqltest/server.go b/backend/internal/testsupport/mysqltest/server.go index e7028fe..6ddd4b4 100644 --- a/backend/internal/testsupport/mysqltest/server.go +++ b/backend/internal/testsupport/mysqltest/server.go @@ -212,7 +212,7 @@ func (f *Server) Database(t *testing.T) config.DatabaseConfig { for _, statement := range []string{ "CREATE DATABASE " + name + " CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "CREATE USER '" + user + "'@'127.0.0.1' IDENTIFIED BY '" + f.password + "' REQUIRE SSL", - "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES ON " + name + ".* TO '" + user + "'@'127.0.0.1'", + "GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES, TRIGGER ON " + name + ".* TO '" + user + "'@'127.0.0.1'", } { if _, err := f.admin.ExecContext(t.Context(), statement); err != nil { t.Fatal("创建隔离数据库及库级账户失败")