最小权限原则在 MySQL 上最容易停留在文档里:新建账号直接 GRANT ALL ON *.*、外包账号与 root 共用、视图和存储过程以 DEFINER=root 执行。只要其中任意一条存在,一个被注入的查询就可能拿到整库数据,甚至借 FILE 权限读写服务器文件。本文给出一套可复现的账号盘点、按需授权、历史权限回收与 DEFINER 清理流程。
适用场景
- 一个实例上跑多个应用或多个客户站点,账号之间没有做库级隔离;
- 历史遗留账号长期使用
GRANT ALL PRIVILEGES ON *.* WITH GRANT OPTION; - 外包、临时运维账号在项目结束后未回收,来源主机写成
%; - 数据库中存在 DEFINER 为 root 的视图、存储过程、触发器,低权限账号借此越权读表;
- 等保测评要求提供账号权限清单与最小化授权说明。
前置条件
- MySQL 5.7 或 8.0,具备
SYSTEM_USER或 root 权限; - 已完成逻辑备份:
mysqldump --all-databases --routines --events --triggers > all.sql; - 清楚每个数据库对应的应用与连接来源网段;
- 可在业务低峰执行 REVOKE,避免连接池复用旧权限造成瞬时失败;
- MySQL 8.0 需注意
mysql.proc表已被移除,DEFINER 只能通过 information_schema 查询。
原理说明
MySQL 的权限分为全局、库、表、列、例程五级,检查顺序是「由细到粗」:先看列权限,再看表、库,最后看全局。很多“已经收紧了”的实例出问题,是因为表级授权没有撤销,而 SHOW GRANTS 默认只显示当前会话身份能看到的部分,逐条核对才不会漏。
更隐蔽的是 DEFINER。视图、存储过程、触发器、事件在创建时都会记录创建者,默认 SQL SECURITY DEFINER 表示“以定义者身份执行”。如果一个视图由 root 创建,那么只有该视图 SELECT 权限的低权限账号,实际读取时是以 root 上下文运行的,行级权限、库级隔离在这条路径上全部失效。
因此最小权限要同时做两件事:把账号本身的授权压到业务真实需要的最小集合,并把以高权限身份运行的 DEFINER 对象改成专用低权账号或 INVOKER 语义。只做前者,越权链依然存在。
操作步骤
第一步:盘点账号与授权现状
先拉出全量账号,重点标记空密码、已锁定、来源为通配符、密码永不过期四类:
SELECT user, host, plugin,
LENGTH(authentication_string) AS pwd_len,
password_expired, account_locked,
password_lifetime
FROM mysql.user
ORDER BY user, host;
-- 找出所有全局权限账号(含 GRANT OPTION)
SELECT GRANTEE, PRIVILEGE_TYPE, IS_GRANTABLE
FROM information_schema.USER_PRIVILEGES
WHERE PRIVILEGE_TYPE IN ('ALL PRIVILEGES','FILE','SUPER','PROCESS','GRANT OPTION')
ORDER BY GRANTEE;
-- 逐账号导出授权文本,结果需留档
SELECT CONCAT('SHOW GRANTS FOR ''', user, '''@''', host, ''';') AS stmt
FROM mysql.user WHERE user <> 'mysql.sys';
把 stmt 列的结果批量执行,得到每个账号的完整授权文本。这一步不要用脚本自动改,先人工确认哪些账号仍在使用。
第二步:按应用建立专用账号并精确授权
一个应用一个账号,一个账号只授权它自己的库;能限定表就不授权整库,能限定主机就不写 %:
CREATE USER 'app_shop_rw'@'10.0.2.%'
IDENTIFIED BY 'R7p!vQ2mZ#9tLw'
PASSWORD EXPIRE INTERVAL 90 DAY
FAILED_LOGIN_ATTEMPTS 5
PASSWORD_LOCK_TIME 30;
-- 只给业务实际用到的四类 DML 权限,且限定到库
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app_shop_rw'@'10.0.2.%';
-- 报表账号只读,且只开放需要的表
CREATE USER 'app_report_ro'@'10.0.3.%' IDENTIFIED BY 'Kd3$wN8xQ!2pRt';
GRANT SELECT (id, order_no, amount, created_at) ON shop.orders
TO 'app_report_ro'@'10.0.3.%';
-- 强制 TLS,禁止明文连接
ALTER USER 'app_shop_rw'@'10.0.2.%' REQUIRE SSL;
FLUSH PRIVILEGES;
列级授权是收敛读权限最有效的手段:报表只需要几个字段时,不要授予整表 SELECT。不要给业务账号授予 FILE、SUPER、PROCESS、CREATE USER,这四项分别对应文件读写、改全局参数、看全部连接、建号提权。
第三步:回收历史授权
回收必须整条撤销后再按需重建,只 REVOKE 单条容易残留 GRANT OPTION:
-- 先撤销全部权限与转授权能力
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'legacy_app'@'%';
-- 再按实际需要重建最小集合
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'legacy_app'@'10.0.2.%';
-- 缩小来源主机:改用新账号后删除通配符账号
DROP USER IF EXISTS 'legacy_app'@'%';
-- 核对结果,应只剩业务库的四类权限
SHOW GRANTS FOR 'legacy_app'@'10.0.2.%';
MySQL 8.0 支持用 REVOKE ... ON *.* 精确回收部分全局权限,但 5.7 上任何 ON *.* 的 REVOKE 都会把该级别的全部权限一并清掉,随后必须重新 GRANT。执行前把 SHOW GRANTS 输出贴到变更单里,是避免误删的唯一可靠办法。
第四步:排查高权限 DEFINER 对象
四类对象要一起查,只看视图会漏掉存储过程和触发器:
-- 视图
SELECT TABLE_SCHEMA, TABLE_NAME, DEFINER, SECURITY_TYPE
FROM information_schema.VIEWS
WHERE DEFINER NOT IN ('app_def@localhost','app_def@10.0.2.%');
-- 存储过程与函数
SELECT ROUTINE_SCHEMA, ROUTINE_NAME, ROUTINE_TYPE, DEFINER, SECURITY_TYPE
FROM information_schema.ROUTINES
WHERE DEFINER NOT IN ('app_def@localhost','app_def@10.0.2.%');
-- 触发器
SELECT TRIGGER_SCHEMA, TRIGGER_NAME, DEFINER
FROM information_schema.TRIGGERS
WHERE DEFINER NOT IN ('app_def@localhost','app_def@10.0.2.%');
-- 定时事件
SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER
FROM information_schema.EVENTS
WHERE DEFINER NOT IN ('app_def@localhost','app_def@10.0.2.%');
查询结果里出现 root@localhost、admin@% 的条目就是越权入口。合规做法是建立一个只具备必要表权限的专用定义者账号 app_def,把所有对象统一改成它。
第五步:批量重建 DEFINER 对象
MySQL 不支持直接修改存储过程的 DEFINER,可靠做法是导出、去 DEFINER 子句、再导入。视图可以用 ALTER VIEW 直接改语义:
# 1) 导出对象定义(不含数据)
mysqldump -uroot -p --routines --triggers --events --no-data \
--skip-add-drop-table --databases shop > shop_objects.sql
# 2) 去掉所有 DEFINER=`user`@`host` 子句
sed -E 's/DEFINER=`[^`]*`@`[^`]*`//g' shop_objects.sql > shop_objects_nodef.sql
# 3) 确认已清零,输出应为 0
grep -c "DEFINER=" shop_objects_nodef.sql
# 4) 以 app_def 身份导入(对象将记录当前执行者)
mysql -uapp_def -p shop < shop_objects_nodef.sql
视图若想以调用者身份执行,可改用 INVOKER 语义,这样权限判断回到当前连接,越权链被彻底切断:
ALTER ALGORITHM=UNDEFINED
SQL SECURITY INVOKER
VIEW shop.v_order_summary AS
SELECT id, order_no, amount FROM shop.orders WHERE deleted = 0;
改成 INVOKER 后,调用者必须自己拥有底层表的 SELECT 权限,否则查询会报 SELECT command denied。这一步要和应用方确认调用账号,不要在生产高峰切换。
第六步:补上登录与口令策略
SET GLOBAL validate_password.policy = STRONG;
SET GLOBAL validate_password.length = 12;
SET GLOBAL max_connect_errors = 1000;
-- 8.0 可用口令重用限制(5.7 用 password_history 插件)
ALTER USER 'app_shop_rw'@'10.0.2.%' PASSWORD HISTORY 5;
策略类参数写在 my.cnf 的 [mysqld] 段才能持久化,SET GLOBAL 重启即失效。
第七步:账号生命周期与定期复核
权限收敛是一次性动作,账号治理是长期动作。建议固定为季度任务,产出三类清单:长期未改密账号、权限与业务不符账号、来源主机过宽账号。
-- 找出 180 天未改密且仍可登录的账号
SELECT user, host, password_last_changed,
DATEDIFF(NOW(), password_last_changed) AS days_since_change
FROM mysql.user
WHERE account_locked = 'N'
ORDER BY days_since_change DESC;
-- 失败登录次数偏高的来源(需启用 performance_schema)
SELECT USER, HOST, SUM(SUM_ERROR_RAISED) AS fails
FROM performance_schema.events_errors_summary_by_account_by_error
WHERE ERROR_NUMBER IN (1045, 1698)
GROUP BY USER, HOST
ORDER BY fails DESC LIMIT 20;
另一条容易漏的路径是监听地址。若实例只服务本机应用,把 bind-address 改成 127.0.0.1 比任何账号策略都彻底;跨机访问则用安全组只放行业务网段,数据库端口不对公网开放。人员离场、外包结项时同步 DROP USER 并清理其名下的 DEFINER 对象,避免出现「账号已删、视图仍在以它身份运行」的悬空定义——这类对象在 MySQL 8.0 导入时甚至会直接报错中断。
配置验证
改完之后必须用低权限账号实测,不能只看 SHOW GRANTS:
# 用业务账号连接,验证越权被拒
mysql -uapp_shop_rw -p -h 10.0.2.11 shop -e "
SELECT COUNT(*) FROM orders;
SELECT * FROM mysql.user;
"
# 期望:第一条成功,第二条报 SELECT command denied
# 验证来源限制生效(从非授权网段连接应被拒绝)
mysql -uapp_shop_rw -p -h 10.0.2.11 shop -e "SELECT 1;"
# 期望:Host '10.9.9.9' is not allowed to connect
# 复核 DEFINER 是否清零,四条查询都应返回空集
- 账号清单中不再存在
'%'来源的业务账号; - 业务账号权限只剩目标库的 DML,无 FILE、SUPER、GRANT OPTION;
- 四类对象的 DEFINER 全部指向
app_def或已改为 INVOKER; - 应用回归测试通过,慢查询数量无明显上升(视图改 INVOKER 后可能少走索引)。
常见问题
Q1:REVOKE 之后应用立刻报 Access denied,但权限明明给了?
先确认连接实际使用的账号与主机。MySQL 按 user@host 精确匹配,app@10.0.2.% 与 app@% 是两个不同账号,应用连上来命中的可能是被回收的那个。用 SELECT CURRENT_USER(), USER(); 查看实际身份,前者是匹配到的账号,后者是客户端声明的主机。另一常见原因是连接池仍持有旧会话,权限变更对新会话生效,重启连接池或等待超时后恢复。
Q2:把视图改成 SQL SECURITY INVOKER 后查不到数据了?
INVOKER 语义要求调用者自己拥有底层表的 SELECT 权限。原先是借 DEFINER 的权限读表,改完之后调用账号没有对应授权,自然返回空集或直接报权限错误。两种处理:给调用账号补上底层表的最小列级 SELECT,或者保留 DEFINER 但把定义者换成专用低权账号 app_def,只授予该视图涉及的几张表。后者改动更小,推荐优先采用。
Q3:只读账号为什么还能写入数据?
检查是否存在该账号可调用的、DEFINER 为高权限用户的存储过程。调用者只需要过程的 EXECUTE 权限,过程内部的 INSERT/UPDATE 以定义者身份执行,只读限制在过程内部不生效。用第四步的 ROUTINES 查询过滤 SECURITY_TYPE='DEFINER' 即可定位,处理方式同上:换低权定义者或改为 INVOKER。
总结
MySQL 最小权限不是一条 GRANT 语句,而是三件事的组合:账号层面按应用隔离、权限层面精确到库表列、执行层面清理高权 DEFINER。建议按「盘点、重建、回收、DEFINER 清理、低权实测」五步推进,每一步都保留 SHOW GRANTS 与 information_schema 查询结果作为变更证据。真正容易被忽略的是第三步之后的回归测试,权限收紧导致的故障往往在业务高峰才暴露。