CGL SCRM / security
安全模型
判权、scope、脱敏、闸门
这一页回答一个问题:谁能看到什么
199 个入口里,165 个要身份, 33 个真的不用登录 —— 这一页把那 33 个逐条列出来并说明为什么。
132
要后台登录
同事在浏览器里用的
33
要 API 钥匙
程序和 AI 用的
33
不用登录
每一条都有理由
0
没写清理由的
应为 0
先说一个差点写错的结论。
一开始我们只按「这个入口挂了权限检查没有」来数,
数出来是 34 个不用登录。但有一条
也就是说:只看「挂没挂检查」会多算一个。 真数字是 33 个。 这类错误的可怕之处在于它看起来是对的—— 所以现在的判据是「既看挂没挂,也看函数自己查没查」。 (这一类函数现在有 1 个:
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 | /video/index/index 的 GET —— 一条路径两种用途,按参数分流。 |
|
| POST | — | |
| 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 | 仅自己名下 |
这一档只管后台页面(顾问登录后能翻到哪些线索)。 接口那一侧是按「能做什么动作」分的(看 / 改 / 导出),两套是并行的。
手机号为什么默认是打码的
默认返回
这不是数据损坏,是刻意加的护栏。 老系统的导出表格里手机号全是明文,一份表格转发一次就外泄一次; 而现在是发给 AI 助手用的,数据会在对话里流转,更不能默认给明文。
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类只读操作刻意免闸门。