补齐MySQL触发器备份及有效权限检查
This commit is contained in:
parent
d7f6ac15ff
commit
3f6d5b86b5
@ -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)
|
||||
}
|
||||
|
||||
177
backend/internal/cli/mysql_privileges.go
Normal file
177
backend/internal/cli/mysql_privileges.go
Normal file
@ -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
|
||||
}
|
||||
24
backend/internal/cli/mysql_privileges_test.go
Normal file
24
backend/internal/cli/mysql_privileges_test.go
Normal file
@ -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("不能把角色名称当成直接授予的对象权限")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
118
backend/internal/cli/mysql_triggers.go
Normal file
118
backend/internal/cli/mysql_triggers.go
Normal file
@ -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()
|
||||
}
|
||||
246
backend/internal/cli/mysql_triggers_integration_test.go
Normal file
246
backend/internal/cli/mysql_triggers_integration_test.go
Normal file
@ -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")
|
||||
}
|
||||
@ -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("创建隔离数据库及库级账户失败")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user