🦏 第16章 安全管理
📄 一句话概括

CREATE USER 'u'@'%' IDENTIFIED BY '密码', ALTER USER 改密码,DROP USER 删账号。 create user 只建账号、不给任何权限, 权限要另外 grant。 drop user 只删账号,不删它建过的数据——账号是「谁」,数据是「什么」,两者独立。 本沙箱没有密码,只有「谁能读写这个文件」: 只读用 ?mode=ro,临时锁写用 pragma query_only。

📝 本章和前面几章的关系

上一篇把安全模型的三层讲清了,这一篇只讲第一层:账号。 这一篇在本沙箱里会变成「一个用户一个库文件」—— 这不是凑数,而是嵌入式数据库做多租户的真实做法。 理解了它,也就理解了「数据库权限」和「文件权限」的边界在哪。

一、建、改、删:三句话

建:create user 'report'@'%' identified by 'Str0ng!pwd'; 改密码: alter user 'report'@'%' identified by 'NewPwd!2026'; (也可以写 set password for 'report'@'%' = '新密码';) 删:drop user 'report'@'%'; create user 只建账号,不给任何权限。 新建的账号连 select 都做不了, 权限要另外 grant(下一篇)。 这个「默认无权限」的设计是对的:忘了给权限,功能用不了;忘了收权限,数据就漏了。

二、identified by 后面是明文,存的不是

identified by 后面写明文密码, 数据库会自己哈希后存进 mysql.user。 所以「密码有没有加密」不用你操心。 要操心的是「密码强度」: MySQL 8 有 validate_password 插件,可以强制长度、大小写、数字、符号。 默认策略在不少发行版上是「中等」, 所以会见到 ERROR 1819 这类「密码太弱」的报错——那不是 bug,是策略在拦。 还有一条:MySQL 8 默认认证插件是 caching_sha2_password, 老客户端(比如某些版本的 Navicat、老驱动)不支持它, 连上去会报「认证插件不支持」。 解法是建账号时指定 with mysql_native_password, 或者升级客户端。

三、drop user 不删数据

drop user 只删账号,不删它建过的数据。 这一点和 drop table 不一样—— 账号是「谁」,数据是「什么」,两者独立。 所以「某人离职了,删账号」是安全的,但「某个库没人用了,删库」才是删数据。 两件事要分开想。 反过来说,删账号之前要想清楚:这个账号是不是某个定时任务在用? 删掉之后任务会静默失败(连接报错), 而失败告警未必有人看。 这一条和第 13.5 篇「定时任务悄悄不跑了」是同一类事故。

四、本沙箱的「多租户」:一个用户一个文件

没有账号,怎么区分用户? 靠「一个用户一个库文件」。 Demo 里造 a.db、b.db 两个文件,各存各的笔记。 两个库各自是完整的数据库,谁也看不见谁。 这就是这里的多租户——用文件隔离,不用账号隔离。 好处是「删一个租户」就是删一个文件,干净利落; 坏处有两个:一是「跨租户统计」特别麻烦(要 attach 好几个文件); 二是「租户多了之后文件一大把」,备份、迁移、监控都得自己写循环。

五、改密码这件事,在这里变成改文件权限

MySQL 改密码是 alter user … identified by …。 这里没有密码,只有「谁能读写这个文件」。 于是「改密码」就变成了「改文件权限」—— Linux 上 chmod 600 app.db,Windows 上用文件属性的「安全」页。 600 的意思是「只有属主能读写」, 这是数据库文件该有的权限。 见过把 .db 放在网站根目录、权限 644 的—— 浏览器直接 GET /app.db 就把整个库下载走了。 本沙箱没法真演示 chmod(Windows 上语义不同), 但「别把库文件放在能被直接下载的位置」这一条,跟操作系统无关。

六、只读连接:一条 URL 参数就是一个权限

sqlite3.connect('file:a.db?mode=ro', uri=True)——注意 uri=True 是必须的, 不加它 ?mode=ro 会被当成文件名的一部分。 读得到,写不了,报 attempt to write a readonly database。 建表也一样被拦。 关键在「它不是建议,是打开方式」:文件是按只读方式打开的, 所以任何写操作在文件层面就失败了——绕过不了、也没有开关能临时打开。 mode 还有两个值:rw(读写,文件必须已存在)、 rwc(读写,不存在就建,这是默认值)。 用 rw 能顺手防住「路径写错时静默建了个空库」这个坑—— 那个坑会让你以为「数据全丢了」,其实是打开了另一个文件。

七、query_only:连接级的只读开关

pragma query_only = on 之后,这条连接上的写操作全被拦,报错和只读连接一样。 它和 mode=ro 的区别在「什么时候生效」:mode=ro 是打开文件时就只读,改不了; query_only 是连接上的一个开关,随时能 off。 所以它是「防手滑」,不是「防越权」。 适合的场景是「这个函数只该读,我把它锁一下,免得自己写错」; 不适合的场景是「防止别人拿到这个连接干坏事」。 选哪个,取决于你要防的是「自己」还是「别人」。

八、动手 Demo:两个库文件当两个租户,再用只读连接管住写

Demo 五段:造两个库文件模拟两个租户、用 ?mode=ro 打开一个试写、 用 pragma query_only 锁住另一条连接、最后再撞一次账号语句。 第二段和第三段的报错文字一模一样(attempt to write a readonly database), 但背后的机制不同:一个在文件层,一个在连接层。

import sqlite3
import _q46 as Q
c = Q.con()

# 1) 「多租户」:一个用户一个库文件
a = sqlite3.connect("a.db")
b = sqlite3.connect("b.db")
for conn, who in ((a, "A"), (b, "B")):
    conn.execute("CREATE TABLE note (id INTEGER PRIMARY KEY, txt TEXT)")
    conn.execute("INSERT INTO note (txt) VALUES (?)", (who + " 的笔记",))
    conn.commit()
print(a.execute("SELECT * FROM note").fetchall())   # 只有 A 的
print(b.execute("SELECT * FROM note").fetchall())   # 只有 B 的

# 2) 只读连接:一条 URL 参数就是一个权限
ro = sqlite3.connect("file:a.db?mode=ro", uri=True)
Q.show(ro, "SELECT id, txt FROM note")
ro.execute("INSERT INTO note (txt) VALUES ('写一个试试')")
ro.execute("CREATE TABLE tmp (x INT)")

# 3) query_only:连接上的只读开关
c.execute("PRAGMA query_only = ON")
Q.show(c, "PRAGMA query_only")
c.execute("DELETE FROM students")

# 4) 账号语句仍然全不认
for s in ("CREATE USER 'x'@'%'", "DROP USER 'x'@'%'"):
    try:
        c.execute(s)
    except Exception as e:
        print(s, "->", e)

实跑输出:

--------------------------------------------------------------------
第七十六课:用户管理——建账号、改密码、删账号
--------------------------------------------------------------------

  MySQL 里建账号的三句话
  MySQL 原码(本沙箱不执行):
  | CREATE USER 'report'@'%' IDENTIFIED BY 'Str0ng!pwd';
  | ALTER USER 'report'@'%' IDENTIFIED BY 'NewPwd!2026';
  | DROP USER 'report'@'%';
  **改密码也可以写 `SET PASSWORD FOR 'report'@'%' = '新密码';`**
  **`create user` 只建账号,不给任何权限。** **新建的账号连 `select` 都做不了**——
  **权限要另外 `grant`**(下一篇)。
  **这个「默认无权限」的设计是对的:忘了给权限,功能用不了;忘了收权限,数据就漏了。**
  **`identified by` 后面是明文密码**,
  **数据库会自己哈希后存进 `mysql.user`。** **所以「密码有没有加密」这件事不用你操心**,
  **但「密码强度」要操心**——**MySQL 8 有 `validate_password` 插件,可以强制长度和复杂度。**
  **`drop user` 只删账号,不删它建过的数据。** **这一点和 `drop table` 不一样**——
  **账号是「谁」,数据是「什么」,两者独立。**

  本沙箱:「用户」这个概念不存在
  **没有 `create user`,那「区分用户」靠什么?** **靠「一个用户一个库文件」。**
  **Demo 里造 A、B 两个文件,模拟两个租户。**
  SQL> SELECT * FROM note
  id     txt
  ----------------
  1      A 的笔记
  (1 行)
  SQL> SELECT * FROM note
  id     txt
  ----------------
  1      B 的笔记
  (1 行)
  **两个库各自是完整的数据库,谁也看不见谁。** **这就是这里的「多租户」**——
  **用文件隔离,不用账号隔离。**
  **好处是「删一个租户」就是删一个文件**,干净利落;
  **坏处是「跨租户统计」特别麻烦**(要 `attach` 好几个文件),
  **而且「一个租户的数据量很大」时会有一堆文件要管。**

  改密码这件事,在这里变成了「改文件权限」
  **MySQL 改密码是 `alter user … identified by …`。** **这里没有密码,只有「谁能读写这个文件」。**
  **于是「改密码」就变成了「改文件权限」**——**Windows 上用文件属性的「安全」页**,
  **Linux 上 `chmod 600 app.db`。**
  **`600` 的意思是「只有属主能读写」**,
  **这是数据库文件该有的权限。** **见过把 `.db` 放在网站根目录、权限 644 的**——
  **浏览器直接 `GET /app.db` 就把整个库下载走了。** **这一类事故每年都有。**
  **本沙箱没法真演示 `chmod`**(Windows 上语义不同),
  **但「别把库文件放在能被直接下载的位置」这一条,跟操作系统无关。**

  只读连接:一条 URL 参数就是一个权限
  SQL> SELECT id, name FROM students
  id     name
  ------------
  1      张伟
  2      李娜
  3      王强
  (3 行)
  SQL> INSERT INTO students (name, height) VALUES ('赵敏', 168.0)
  报错:attempt to write a readonly database
  SQL> CREATE TABLE tmp (x INT)
  报错:attempt to write a readonly database
  **读得到,写不了**,报 `attempt to write a readonly database`。 **建表也一样被拦。**
  **关键在连接 URL 那个 `?mode=ro`**:**它不是「建议只读」,是「打开时就按只读方式打开文件」**。
  **所以任何写操作在文件层面就失败了**,**绕过不了、也没有开关能临时打开。**
  **`mode` 还有两个值**:**`rw`(读写,文件必须已存在)**、
  **`rwc`(读写,不存在就建,这是默认值)**。
  **用 `rw` 能顺手防住「路径写错时静默建了个空库」这个坑。**

  query_only:连接级的只读开关
  SQL> PRAGMA query_only = ON
  完成
  SQL> PRAGMA query_only
  query_only
  -----------
  1
  (1 行)
  SQL> SELECT COUNT(*) AS 人数 FROM students
  人数
  -----
  3
  (1 行)
  SQL> DELETE FROM students
  报错:attempt to write a readonly database
  **`pragma query_only` 返回 1,删除被拦,报的也是 `attempt to write a readonly database`。**
  **它和 `mode=ro` 的区别在「什么时候生效」**:**`mode=ro` 是打开文件时就只读,改不了**;
  **`query_only` 是连接上的一个开关,随时能 `off`。** **所以它是「防手滑」,不是「防越权」**——
  **适合的场景是「这个函数只该读,我把它锁一下,免得自己写错」。**

第一段两个库各一行:A 只看到「A 的笔记」,B 只看到「B 的笔记」。 第二段只读连接读得到、写不了,报 attempt to write a readonly database。 第三段 pragma query_only 返回 1,delete 同样被拦。 第四段账号语句依旧 syntax error。

30 秒实验

  1. connect('file:a.db?mode=ro') 忘了写 uri=True 会怎样?试一下看它报什么。
  2. 把 mode=ro 换成 mode=rw,再删掉文件重跑,会报什么?
  3. 为什么「删账号」要先去确认有没有定时任务在用?

常见误区

  1. 建完账号就去连。 create user 不给权限,连 select 都做不了,要另外 grant。
  2. 以为 drop user 会删数据。 它只删账号,数据独立存在。
  3. 只读连接忘了 uri=True。 ?mode=ro 会被当成文件名,静默建出一个奇怪名字的空库。
自测三问(点开对答案) 1. `create user` 之后新账号能 `select` 吗? —— 不能。它只建账号,不给任何权限。要给权限得另外 `grant`。 2. `drop user` 会删掉它建的表吗? —— 不会。账号和数据是两件事,删账号只是删「谁」,不影响「什么」。 3. `?mode=ro` 和 `pragma query_only` 有什么区别? —— `mode=ro` 在打开文件时就只读,改不了,是硬限制; `query_only` 是连接上的开关,随时能关,防手滑但不防越权。

账号有了,下一篇给它们分配「能干什么」:16.3 权限管理——grant、revoke,以及这里的替身。

相关笔记