仅提交可独立验证的权限检查重构;其余视图备份与权限矩阵改动尚未完成,留在工作区,不包含于本提交。 在基于3cc6a7a的独立工作区仅应用本文件后验证:MySQL 5.7权限专项23个叶子用例通过,MySQL 8.0权限专项37个叶子用例通过,均无失败或跳过;CLI常规测试、go vet、go mod verify通过。 全量回归未全部通过:未改动的localreset包TestWindowsRejectsNonElevatedPeer在并行全量执行时超时,单独重跑通过。保留该异常,不将本提交视为全量或生产验收通过。
225 lines
6.8 KiB
Go
225 lines
6.8 KiB
Go
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
|
||
}
|