多人使用后,谁有权看到和修改数据?
读解释、检查证据、修订自己的设计。正文与参考记录可离线阅读,真实网络观察需要联网或启动本机服务。
导出包含第 0–7 章作答。导入会替换八章全部作答,包括文件中未包含的章节;建议先导出备份。
5.1 · 一份正确数据,也可能交给了错误的人
登录成功,不等于可以读所有笔记
Alice 把私人笔记应用推荐给 Bob。Bob 注册并登录后,把地址里的笔记编号从 2 改成 1,竟然看到了 Alice 的旅行安排。数据没有损坏,接口也返回了成功,但系统违背了最重要的需求:私人笔记只能给获得许可的人看。
认证回答“这次请求对应谁”,授权回答“这个人能否对这个对象执行这项操作”。本章先规定一项具体功能:所有者可以读写自己的笔记,并邀请另一个账号只读;没有获得分享的人不能查看。规则从产品需求出发,再决定由哪些程序执行。
课程用 Alice、Bob、Eve 和未登录四种测试身份,便于逐个检查。它们是测试程序直接提供的身份夹具,不是经过登录验证的真实用户。我们会运行权限策略与 SQLite 检查,也会使用浏览器中的矩阵模拟;两者都不能证明已经建成安全认证系统。正式项目应接入成熟身份组件,再验证与业务授权的连接。
学完交付一张权限矩阵、一条可信身份的进入路径,以及至少三个拒绝案例。判断拒绝是否正确时,同时检查数据没有泄露、没有被错误修改。
5.2 · 找到值得信任的身份来源
请求可以自报姓名,服务不能照单全收
假设浏览器提交 {"owner":"alice","title":"新的笔记"}。这个 owner 只是客户端写来的普通字段;任何调用者都能改它。创建时应由服务端已验证的当前身份决定所有者,而不是让字段决定当前身份。读取和编辑也不能靠“请求里的 owner 与目标相同”就放行。
在一种常见的会话方案里,身份组件先验证登录,然后签发会话标识;浏览器随后携带它,服务端检查其有效性并找到对应账号,再把可信身份交给业务代码。仅有字符串、Cookie 或解码出来的姓名,都不等于完成了验证。到期、退出或被撤销的会话,也不能继续被当成有效身份。
课程记录从最后一步开始:测试入口明确给出 principal = bob,再询问 Bob 能不能读笔记 1。这个参数模拟身份组件的输出。把它改成 Alice 可以测试另一条策略,却不是“通过修改参数成功登录 Alice”;真实 HTTP 入口必须阻止客户端直接控制这个可信参数。
5.3 · 对具体对象逐格决定
权限属于“谁、什么对象、什么操作”的组合
把笔记 1 设为 Alice 所有,分享给 Bob 只读;Eve 已登录但没有获得分享。矩阵里的“所有者”是相对于这条笔记的关系,不是永久赋予某个人的全站权力。Bob 对自己的笔记可以是所有者,对 Alice 的这条仍只是读者。
| 当前身份与关系 | 单条读取 | 出现在其列表 | 修改/删除 | 分享/撤销分享 |
|---|---|---|---|---|
| Alice,所有者 | 允许 | 允许 | 允许 | 允许 |
| Bob,有效只读分享 | 允许 | 允许 | 拒绝 | 拒绝 |
| Eve,其他已登录账号 | 拒绝 | 不出现 | 拒绝 | 拒绝 |
| 未登录 | 拒绝 | 列表请求被拒绝 | 拒绝 | 拒绝 |
本例在没有匹配到许可时拒绝。不能把“未明确禁止”当成允许,否则新增下载、批量导出等操作时,就可能意外继承过大的权限。另一方面,也不要为了少写规则直接让所有已登录者都能读取:登录解决身份问题,不会消除用户之间的数据边界。
下面是客户端权限矩阵模拟。先预测,再改变身份、操作或分享状态,观察规则的结果。模拟帮你检查是否读懂矩阵,不会创建账号、登录用户或访问真实私人笔记。服务端是否执行同样策略,要看下一节的独立记录。
5.4 · 所有通往数据的路都要守住
单条接口检查了,列表仍可能泄露
一种看似合理的实现是:单条接口先检查所有者,列表却返回所有笔记,让浏览器隐藏不属于当前用户的卡片。这时数据已经传到浏览器,隐藏显示来不及保护正文;标题、摘要和记录数量也可能透露信息。正确的列表应在服务端限定可读范围,再返回允许的对象。分页与统计也应采用相同范围。
对单条操作,按可信身份、目标对象及动作作出判断,再读取允许公开的字段或进行修改。请求的编号很长、难猜,只能降低偶然碰到的可能,不能代替检查。授权规则最好集中表达,并让单条、列表、编辑等入口都调用它;各处复制一份判断容易在新增功能时漏掉一处。
本课程约定:未登录返回 401;对象不存在或当前用户根本无权看见,统一返回 404;Bob 已获只读权限却尝试编辑,返回 403。统一 404 是减少对象存在性泄露的一种设计,不是证明对象真的不存在。错误结果不应附上那条私人笔记的标题或正文。
已执行的 Python / SQLite 记录 · 不是当前 HTTP 请求
这里真正运行了数据库操作或应用函数。身份由测试程序提供,没有登录认证流程;状态码是函数返回的契约字段。原始前后快照供核对,不是发送给访问者的业务响应。
1. 所有者读取自己的对象
{
"调用": {
"fixturePrincipal": "alice",
"action": "read",
"noteId": 1,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-013",
"note": {
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
}
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}2. 被分享者只读
{
"调用": {
"fixturePrincipal": "bob",
"action": "read",
"noteId": 1,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-014",
"note": {
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
}
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}3. eve尝试read
{
"调用": {
"fixturePrincipal": "eve",
"action": "read",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 404,
"body": {
"requestId": "req-015",
"error": "not_found"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}4. eve尝试edit
{
"调用": {
"fixturePrincipal": "eve",
"action": "edit",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 404,
"body": {
"requestId": "req-016",
"error": "not_found"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}5. eve尝试delete
{
"调用": {
"fixturePrincipal": "eve",
"action": "delete",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 404,
"body": {
"requestId": "req-017",
"error": "not_found"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}6. None尝试read
{
"调用": {
"fixturePrincipal": null,
"action": "read",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 401,
"body": {
"requestId": "req-018",
"error": "login_required"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}7. None尝试edit
{
"调用": {
"fixturePrincipal": null,
"action": "edit",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 401,
"body": {
"requestId": "req-019",
"error": "login_required"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}8. None尝试delete
{
"调用": {
"fixturePrincipal": null,
"action": "delete",
"noteId": 1,
"payload": {
"title": "伪装修改",
"body": "不应写入",
"owner_id": "alice",
"version": 2
}
},
"响应": {
"status": 401,
"body": {
"requestId": "req-020",
"error": "login_required"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}9. 只读分享者尝试edit
{
"调用": {
"fixturePrincipal": "bob",
"action": "edit",
"noteId": 1,
"payload": {
"title": "修改",
"version": 2
}
},
"响应": {
"status": 403,
"body": {
"requestId": "req-021",
"error": "read_only"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}10. 只读分享者尝试delete
{
"调用": {
"fixturePrincipal": "bob",
"action": "delete",
"noteId": 1,
"payload": {
"title": "修改",
"version": 2
}
},
"响应": {
"status": 403,
"body": {
"requestId": "req-022",
"error": "read_only"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}11. 列表只返回有权读取的对象
{
"调用": {
"fixturePrincipal": "bob",
"action": "list",
"noteId": null,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-023",
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
}
]
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}12. 无分享者读取列表
{
"调用": {
"fixturePrincipal": "eve",
"action": "list",
"noteId": null,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-024",
"notes": []
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}13. 创建时伪造owner_id不改变归属
{
"调用": {
"fixturePrincipal": "eve",
"action": "create",
"noteId": null,
"payload": {
"title": "Eve自己的笔记",
"owner_id": "alice"
}
},
"响应": {
"status": 201,
"body": {
"requestId": "req-025",
"note": {
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
}
}14. 测试夹具撤销关系:不是管理接口授权测试
{
"SQL": "DELETE FROM shares WHERE note_id=1 AND reader_id='bob'; COMMIT",
"前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": [
{
"note_id": 1,
"reader_id": "bob"
}
]
},
"后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"边界": "夹具直接维护关系。Application未实现分享/撤销入口;不证明只有所有者可以调用管理接口。下两项检查关系变化后的读取策略。"
}15. 撤销分享后重新读取
{
"调用": {
"fixturePrincipal": "bob",
"action": "read",
"noteId": 1,
"payload": null
},
"响应": {
"status": 404,
"body": {
"requestId": "req-026",
"error": "not_found"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}16. 撤销后列表也不再返回该对象
{
"调用": {
"fixturePrincipal": "bob",
"action": "list",
"noteId": null,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-027",
"notes": []
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}17. 所有者正常编辑
{
"调用": {
"fixturePrincipal": "alice",
"action": "edit",
"noteId": 1,
"payload": {
"title": "所有者更新",
"body": "所有者正文",
"version": 2
}
},
"响应": {
"status": 200,
"body": {
"requestId": "req-028",
"note": {
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
}
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "草稿一",
"body": "A已经确认的编辑",
"version": 2
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}18. 所有者创建可删除的测试对象
{
"调用": {
"fixturePrincipal": "bob",
"action": "create",
"noteId": null,
"payload": {
"title": "临时对象"
}
},
"响应": {
"status": 201,
"body": {
"requestId": "req-029",
"note": {
"id": 5,
"owner_id": "bob",
"title": "临时对象",
"body": "",
"version": 1
}
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
},
{
"id": 5,
"owner_id": "bob",
"title": "临时对象",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}19. 所有者可以删除自己的对象
{
"调用": {
"fixturePrincipal": "bob",
"action": "delete",
"noteId": 5,
"payload": null
},
"响应": {
"status": 200,
"body": {
"requestId": "req-030",
"deletedId": 5
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
},
{
"id": 5,
"owner_id": "bob",
"title": "临时对象",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}20. 删除后再读找不到
{
"调用": {
"fixturePrincipal": "bob",
"action": "read",
"noteId": 5,
"payload": null
},
"响应": {
"status": 404,
"body": {
"requestId": "req-031",
"error": "not_found"
}
},
"数据前": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
},
"数据后": {
"notes": [
{
"id": 1,
"owner_id": "alice",
"title": "所有者更新",
"body": "所有者正文",
"version": 3
},
{
"id": 2,
"owner_id": "alice",
"title": "带标签的笔记",
"body": "",
"version": 1
},
{
"id": 3,
"owner_id": "alice",
"title": "只改标题",
"body": "新版正文",
"version": 3
},
{
"id": 4,
"owner_id": "eve",
"title": "Eve自己的笔记",
"body": "",
"version": 1
}
],
"note_tags": [
{
"note_id": 2,
"tag_id": 1
}
],
"shares": []
}
}怎样复现与检查范围
下载底部实验包,运行 python3 evidence_lab.py。脚本在临时目录创建、检查和清理数据库;不会打开你的笔记库。运行结果只支持这些用例,不证明生产安全、并发压力、真实断电或云灾备。
记录对应源码 SHA-256:e7aecd720f9614777ee3d5c70996c518a381157d17f4988736b9061b7706481d。
CREATE TABLE users(id TEXT PRIMARY KEY);
CREATE TABLE notes(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 80),
body TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0));
CREATE TABLE tags(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL, UNIQUE(owner_id,name));
CREATE TABLE note_tags(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(note_id,tag_id));
CREATE TABLE shares(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
reader_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY(note_id,reader_id));
INSERT INTO users VALUES ('alice'),('bob'),('eve');
INSERT INTO tags VALUES (1,'alice','学习'),(2,'bob','私有标签');
上面的证据实际运行 Python 策略与 SQLite,展示夹具身份、操作参数、返回结果和前后数据。它没有执行真实登录,也没有经过 HTTP。逐项找出:所有者能读写;Bob 可读但不能改;Eve 看不到该对象;未登录被拒绝;列表只含当前身份可读的数据。记录没有覆盖的入口仍需另测。
5.5 · 权限会变化,检查不能只发生一次
昨天分享过,不等于今天仍然允许
Alice 昨天把笔记分享给 Bob,今天撤销。若服务仅在 Bob 登录时记下“能读笔记 1”,之后一直沿用旧结果,撤销就没有生效。对这个小系统,一种容易说明的方案是每次访问都读取当前分享关系;它增加一次判断工作,但让撤销后的行为更直接。
先把时间边界说清:在本章顺序实验中,撤销关系提交完成之后发起的读取应得到 404,之后的列表也不应出现该笔记。检查撤销前允许、撤销操作、撤销后拒绝三个阶段;只证明删除了一行分享关系,还没有证明读取入口实际使用它。并发中的读取和撤销谁先完成,是更进一步需要定义的边界,当前顺序记录不能替它作证。
记录中的分享关系由测试夹具直接写入或删除。本例验证关系变化后的读取策略,尚未实现分享、撤销管理入口的授权;因此不能据此声称“只有所有者能执行撤销”已经通过验收。
撤销也不能让 Bob 忘掉昨天已经读到、截图或下载的内容。系统能控制后续取数,无法保证收回外部副本。若采用授权结果缓存或短期分享链接,应另写有效期与失效规则,并检查是否符合你承诺的撤销速度;不要默认为“点了撤销立即清除所有副本”。
提示 1:区分过去收到的内容与新的读取
已经打开的页面和撤销后的新请求,不是同一份证据。先给出一个明确时间顺序。
提示 2:至少检查两条读取路径
用 Bob 身份分别读取单条和列表;用 Alice 身份确认原笔记仍然存在。撤销分享不应误删笔记。
提示 3:一种参考判断与替代方案
顺序测试中,撤销后 Bob 的单条读取 404、列表不含该对象,Alice 仍可读。旧页面可能仍显示之前的内容。另一种方案允许授权短暂缓存,但必须明确最大延迟,并让产品需求接受这段时间,不能同时承诺即时生效。
5.6 · 为自己的分享功能补齐规则
照片能分享,意味着授予了哪一种能力?
把私人笔记换成家庭相册。需求是“邀请亲友查看,不能修改,并允许撤销”。可以邀请指定账号,也可以使用持有链接即可读取的分享方式。前者能按账号区别受邀者,但增加登录步骤;后者打开方便,却意味着链接被转发后,持有者也可能获得访问能力。先确认“亲友”是否必须是指定的人,再选择方案。
为你的方案写一张矩阵和三条拒绝案例:未获许可的人读取、只读者修改、撤销后重新读取。说明身份或访问能力从哪里得到验证,单张照片、相册列表、缩略图和下载是否使用相同范围。没有实现的入口可以明确暂不支持,不能悄悄绕过规则。
参考判断:允许不同方案,但不允许边界含糊
若必须限制到具体亲友,指定账号更容易表达这个条件;若需求接受转发链接后别人也可读,可以选择链接能力,但应明确有效期与撤销方式。两种方案都要把权限限制落实到数据入口,并承认已获取内容无法完全收回。测试夹具只检查业务策略,真实登录或链接凭据验证需要另外实现和验收。
把权限条件写回上一章的接口表。接下来,即使身份、输入和权限都正确,响应仍可能丢失;第六章再处理这一类失败。
选读:对照规则,而不是背安全术语
- OWASP · Authorization:读 “Deny by Default”“Validate the Permissions on Every Request” 和 “Verify that Authorization Checks are Performed in the Right Location”。用它们检查你的矩阵遗漏了哪些入口。
- OWASP · Session Management,Introduction:只读身份、会话与访问控制的联系。回答“为什么测试里给 principal 赋值不等于实现登录”;具体安全会话实现留给成熟组件与后续专项学习。