CGL SCRM  /  security

安全模型

判权、scope、脱敏、闸门

这一页回答一个问题:谁能看到什么

199 个入口里,165 个要身份, 33 个真的不用登录 —— 这一页把那 33 个逐条列出来并说明为什么。

132
要后台登录
同事在浏览器里用的
33
要 API 钥匙
程序和 AI 用的
33
不用登录
每一条都有理由
0
没写清理由的
应为 0
先说一个差点写错的结论。 一开始我们只按「这个入口挂了权限检查没有」来数, 数出来是 34 个不用登录。但有一条 POST /manager/password(改密码)挂的检查是空的, 却在函数第一行自己去查「你登录了吗」——没登录根本进不去。

也就是说:只看「挂没挂检查」会多算一个。 真数字是 33 个。 这类错误的可怕之处在于它看起来是对的—— 所以现在的判据是「既看挂没挂,也看函数自己查没查」。 (这一类函数现在有 1 个: /manager/password, 调用的是 get_current_user。为了保险, 这一页上的分类是读代码算出来的,不是人写的。)

检查身份的地方只有两处

这是整套系统的设计核心,理解了这两条,后面所有权限问题都能自己推。

在哪怎么判说明
后台 UI user.is_admin(+ data_scope) app/routers/manager/* 的路由依赖
API / MCP ApiIdentity.can(scope) app/routers/api/v1.py + app/routers/mcp.py
最容易误解的一点:系统里确实有一段代码会在每个请求上「认出你是谁」, 但它只负责认,不负责拦——认不出就当你是游客,然后照样放行。

所以「这个页面安不安全」,答案只看它自己挂没挂检查, 不能因为「系统认人了」就认为整体安全。 这条规矩在本项目里被总结成一句话: 写下「因为……所以安全」之前,先拿一条真实的请求把它证出来。

那 33 个不用登录的入口

按「为什么可以不用登录」分成 8 类。每一类都给了理由。

体检与图标 4 个 服务还活着吗、准备好了吗;浏览器标签页的小图标;根路径跳转。这些给监控和浏览器看,不含业务数据。

服务还活着吗、准备好了吗;浏览器标签页的小图标;根路径跳转。这些给监控和浏览器看,不含业务数据。

方式地址干什么
GET / —
GET /favicon.ico —
GET /healthz —
GET /readyz —
登录 / 退出 5 个 登录页和「提交登录」这个动作本身不能要求先登录——否则永远进不去,成死循环。

登录页和「提交登录」这个动作本身不能要求先登录——否则永远进不去,成死循环。

方式地址干什么
POST /Manager/Common/newlogin 路径与字段名刻意复刻原站,方便老脚本/老前端平移。
GET /manager/login —
POST /manager/login —
GET /manager/logout —
POST /manager/logout —
客户看到的页面 12 个 客户在手机上打开的活动页、报名表、问卷。他们没有后台账号,不能要求登录。

客户在手机上打开的活动页、报名表、问卷。他们没有后台账号,不能要求登录。

方式地址干什么
GET /video/A_Event/a_event 活动报名落地页。
GET /video/A_Event/a_event_new 活动报名落地页。
GET /video/A_Event/a_event_share_er 顾问分享页 —— 原站就是从这里拿到二维码矩阵(技能里逆向出的真入口)。
POST /video/A_Event/a_event_signup 提交报名。表单字段名与原站一致(name/mobile/company/jobtitle/corpemail)。
GET /video/A_Event/signup_done —
GET /video/Questnaire/done —
GET /video/Questnaire/index —
GET /video/Questnaire/questionnaire —
POST /video/Questnaire/submit —
GET /video/Questnaire/survey —
GET /video/index/index /video/index/index 的 GET —— 一条路径两种用途,按参数分流。
POST /video/index/index —
二维码图片 5 个 扫出来的码要能直接打开图片。网址路径沿用老系统的名字(连拼写都照搬)。

扫出来的码要能直接打开图片。网址路径沿用老系统的名字(连拼写都照搬)。

方式地址干什么
GET /Botch_qrcode/New_aevent/{aid:int}/{mid:int} 活动 × 顾问 的二维码直出(原站同名路径,匿名可访问)。
GET /Botch_qrcode/New_aevent/{aid}/{mid}.jpg 活动 × 顾问 的二维码直出(原站同名路径,匿名可访问)。
GET /Botch_qrcode/survey/{qid:int}/{mid:int} 问卷 × 顾问 的二维码直出。语义与 event_qr 一致(必须走微信带参码), 差异只有 scene_str 里带的是 survey_id 而不是 activity_id。见 event_qr 的注释。
GET /Botch_qrcode/survey/{qid}/{mid}.jpg 问卷 × 顾问 的二维码直出。语义与 event_qr 一致(必须走微信带参码), 差异只有 scene_str 里带的是 survey_id 而不是 activity_id。见 event_qr 的注释。
GET /Botch_qrcode/url.png 任意 URL 直出二维码(新功能)。
微信回调 3 个 微信服务器来推事件,它没有我们的登录凭证——靠签名校验确认「这确实是微信发来的」。

微信服务器来推事件,它没有我们的登录凭证——靠签名校验确认「这确实是微信发来的」。

方式地址干什么
GET /wechat /video/index/index 的 GET —— 一条路径两种用途,按参数分流。
POST /wechat —
GET /wechat/health 回调链路自检(运维用,不暴露敏感值)。
文件出站 1 个 二维码、海报这些图片文件。地址本身就是权限:key 里含内容哈希,猜不出来。

二维码、海报这些图片文件。地址本身就是权限:key 里含内容哈希,猜不出来。

方式地址干什么
GET /files/{key:path} 按 key 出图。匿名可访问 —— 与原有的静态目录语义一致。
MCP 的两个占位动作 2 个 GET 明确回「本服务不支持推送」,DELETE...

GET 明确回「本服务不支持推送」,DELETE 回「收尾完成」——两个都不返回任何数据,只是别让客户端挂住。真正取数据的是 POST,那个要钥匙。

方式地址干什么
DELETE /mcp 会话终止。无状态实现无需清理,回 204 让客户端收尾干净。
GET /mcp 规范里 GET 是「服务端主动推送流」。我们不做推送,明确 405,别让客户端挂住。
接口体检 1 个 给监控用的,回答「活着吗、连得上数据库吗」。它顺带会报出线索总数,这一点单独记在缺陷台账里。

给监控用的,回答「活着吗、连得上数据库吗」。它顺带会报出线索总数,这一点单独记在缺陷台账里。

方式地址干什么
GET /api/v1/health 供 Aily / 监控探活。
上面每一类都是想清楚了才开放的,不是漏了。 如果你新增了一个不用登录的入口而这里没给它理由, 这一页会自动长出一块「未归类」——页面自己会变红,不用靠人记得来看。

钥匙怎么存

API 钥匙
数据库里只存一段不可逆的摘要值(哈希),不存钥匙本身。 钥匙明文只在签发那一刻打印一次,之后连管理员也取不回来—— 丢了就重新签一把。
后台密码
同样是不可逆的哈希。生产账号清单存在 deploy/seed_accounts.json 里, 里面也只有哈希,没有明文口令。

每把钥匙可以额外限三件事

  • 有效期——到期自动失效,不用记得去删;
  • 每分钟最多调几次——防止某个程序写错循环把系统打满;
  • 能看哪些数据——分三档,见下。

「能看哪些数据」的三档

档位能看到谁的数据
all全部数据
group本组(同 account 组)
self仅自己名下

这一档只管后台页面(顾问登录后能翻到哪些线索)。 接口那一侧是按「能做什么动作」分的(看 / 改 / 导出),两套是并行的。

手机号为什么默认是打码的

默认返回 138****9999。要看真实号码,钥匙必须额外带 lead:read_pii 这一档。

这不是数据损坏,是刻意加的护栏。 老系统的导出表格里手机号全是明文,一份表格转发一次就外泄一次; 而现在是发给 AI 助手用的,数据会在对话里流转,更不能默认给明文。

还有一层:脚本不许手滑打生产库

运维用的那些小工具(改密码、签钥匙、清数据、导数据)都有同一个保险: 如果发现你连的是线上正式库、而你没明确说「我知道,继续」,脚本会直接拒绝运行。

为什么要做成「拒绝运行」而不是「弹个提示」: 这些脚本通常在同一个终端里连着跑。你为了查点东西临时设了线上库的连接串, 它就会一直留在这个终端里——下一个脚本一不小心就把线上当本地改了。 这个坑本项目踩过:有四个这样的脚本当时一个保险都没有, 其中两个正好是「改管理员密码」和「签发管理员权限的钥匙」。

修完之后专门做了验证:伪装成线上连接串去跑,脚本在连库之前就退出了; 而只读的 --list(列一下有哪些钥匙)则照常放行。

几条本来就该守的

  • 生产账号不要用固定口令,也不要造「口令写死」的后台账号;
  • 临时开的排查账号用完就删;
  • 测试不许往共享的开发库里写数据——不是怕脏,是会把「跟记录对不上」这个最好的排查线索弄哑。
  • 鉴权完全靠路由依赖 —— inject_user 只往 request.state 塞人,从不拦截。所以「这条路由安全不安全」只看它挂没挂依赖。
  • API Key 服务端只存 sha256;issue_token.py 的明文只在 stdout 出现一次。
  • PII 默认脱敏(138****9999)是防明文在 AI 对话里流转的设计,不是数据损坏。
  • 会写库 / 会真发请求的脚本必须自带 fail-closed 库闸门(判生效的连接串,不是环境变量)。
  • 生产库只读/写通道:DATABASE_URL='postgresql+httpgw://',不挂 VPC;--list 类只读操作刻意免闸门。