Scert/backend/internal/cli/mysql_privileges.go
xuans a8f4a54723 提取MySQL对象可见权限检查并保持事件校验行为
仅提交可独立验证的权限检查重构;其余视图备份与权限矩阵改动尚未完成,留在工作区,不包含于本提交。

在基于3cc6a7a的独立工作区仅应用本文件后验证:MySQL 5.7权限专项23个叶子用例通过,MySQL 8.0权限专项37个叶子用例通过,均无失败或跳过;CLI常规测试、go vet、go mod verify通过。

全量回归未全部通过:未改动的localreset包TestWindowsRejectsNonElevatedPeer在并行全量执行时超时,单独重跑通过。保留该异常,不将本提交视为全量或生产验收通过。
2026-09-27 23:51:04 +08:00

225 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
grants, err := readMySQLEffectiveGrants(ctx, tx, version)
if err != nil {
return err
}
return requireMySQLTableTriggerGrants(ctx, tx, name, tables, grants, normalize)
}
func readMySQLEffectiveGrants(ctx context.Context, tx *sql.Tx, version string) ([]string, error) {
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 nil, 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 nil, err
}
roles = append(roles, quoteMySQLIdentifier(role)+"@"+quoteMySQLIdentifier(host))
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(roles) > 0 {
query += " USING " + strings.Join(roles, ", ")
}
}
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("读取 MySQL 有效权限失败:%w", err)
}
var grants []string
for rows.Next() {
var grant string
if err := rows.Scan(&grant); err != nil {
_ = rows.Close()
return nil, err
}
grants = append(grants, grant)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
return grants, nil
}
func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string, tables, grants []string, normalize func(string) string) error {
return requireMySQLObjectGrants(ctx, tx, name, tables, grants, normalize, "TRIGGER", false)
}
func requireMySQLEventVisibility(ctx context.Context, tx *sql.Tx) error {
// EVENT 只有全局或库级授权,不能用某一张表的权限证明完整事件列表可见。
return requireMySQLObjectVisibility(ctx, tx, nil, "EVENT", true)
}
func requireMySQLObjectVisibility(ctx context.Context, tx *sql.Tx, objects []string, privilege string, schemaOnly bool) error {
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
}
grants, err := readMySQLEffectiveGrants(ctx, tx, version)
if err != nil {
return err
}
normalize := func(value string) string {
if lowerCaseNames != 0 {
return strings.ToLower(value)
}
return value
}
if schemaOnly {
objects = []string{name}
}
return requireMySQLObjectGrants(ctx, tx, normalize(name), objects, grants, normalize, privilege, schemaOnly)
}
func requireMySQLObjectGrants(ctx context.Context, tx *sql.Tx, name string, objects, grants []string, normalize func(string) string, privilege string, schemaOnly bool) error {
// 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
}
hasPrivilege := mysqlHasPrivilege(match[2], privilege)
if match[3] == "*" && match[4] == "*" {
global = global || (match[1] == "GRANT" && hasPrivilege)
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 || hasPrivilege
} else if match[4] == "*" {
if partialRevokes || !mysqlGrantHasWildcard(databaseName) {
exactSeen = true
exactAllowed = exactAllowed || hasPrivilege
} else {
wildcardSeen = true
wildcardAllowed = wildcardAllowed && hasPrivilege
}
} else if hasPrivilege && !schemaOnly {
covered[normalize(unquoteMySQLGrantIdentifier(match[4]))] = true
}
}
// 精确库授权优先。多个匹配通配授权可能按内部顺序遮蔽,不能简单合并为更大权限。
// 不能证明所有候选都包含 TRIGGER 时保守失败,管理员可提供明确的精确库或表级授权。
schema := exactAllowed
if !exactSeen {
schema = wildcardSeen && wildcardAllowed
}
for _, object := range objects {
if !((global && !revoked) || schema || covered[normalize(object)]) {
return fmt.Errorf("MySQL 无法确认对象 %s 的 %s 可见权限,不能把隐藏的对象当成不存在", object, privilege)
}
}
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 {
return mysqlHasPrivilege(privileges, "TRIGGER")
}
func mysqlHasPrivilege(privileges, required string) bool {
for _, privilege := range strings.Split(privileges, ",") {
if value := strings.TrimSpace(privilege); value == required || 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
}