补齐MySQL触发器备份及有效权限检查

This commit is contained in:
xuans 2026-09-27 21:46:54 +08:00
parent d7f6ac15ff
commit 3f6d5b86b5
7 changed files with 583 additions and 3 deletions

View File

@ -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)
}

View 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
}

View 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("不能把角色名称当成直接授予的对象权限")
}
}

View File

@ -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)

View 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()
}

View 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")
}

View File

@ -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("创建隔离数据库及库级账户失败")