Scert/backend/internal/cli/mysql_privileges.go

218 lines
6.6 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 {
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
}
// EVENT 只有全局或库级授权,不能用某一张表的权限证明完整事件列表可见。
return requireMySQLObjectGrants(ctx, tx, normalize(name), []string{name}, grants, normalize, "EVENT", true)
}
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
}