## 为什么做这一步 权威文档 74 份此前**只在本机**,评审者 clone 分支后看不到任何设计文档;而仓库里那两份同名目录 是 **2026-09-16 之前的过期副本,连文件名都是旧的**(无体系编号)。本次按「**权威覆盖过期**」入库。 ## 入库内容 | 目录 | 文件数 | 体积 | 说明 | |---|---|---|---| | `客服agent/` | 24 | 0.77 MB | `D2.1`~`D2.6` 对外交付四件套 + 演示脚本/答辩报告 + `_build` 构建工具 | | `开发文档/` | 50 | 2.16 MB | `D1.x` 索引与决策、`D3.x` 方案、`D4.x` 清除与重构留痕、`D5.x` 业务流程、`D6.x` 业务事实基座、`D7.x` 交付物、`D8.x` 规范 | **旧的过期副本整体移除**(`客服Agent执行Todolist.md` → `D2.1-客服Agent执行Todolist.md` 之类 的改名 + 新增 `D2.5`/`D2.6`),入库后目录内容与权威副本**逐文件一致(零差异,已复核)**。 ## 入库前的安全扫描(必须留痕) - 扫描规则:`sk-` 类密钥 / `Bearer` 长串 / `password=`、`api_key=` 赋值 / 会话中出现过的两把明文 key 片段。 - 结论:**真实密钥只出现在 `.env`**(已被 `.gitignore` 命中,未入库);`.env.example` 与 `config/risk.env.example` 只有**空占位**。 - 文档内唯一命中是 `D3.1` 里一处**截断的示例 JWT**(`Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...`), 末尾带省略号,是接口文档的示意值,**不是可用凭据**。
22 KiB
访客与角色分离:鉴权方案建议
体系编号:
D3.3· 域:三、现行权威·完整版与专项 · 编号体系见D1.1§4.0
编号:CS-AUTH-2026-011 日期:2026-09-16 触发:用户新设计思路——「将访客与角色(角色体系)分离处理」,并要求就鉴权方案给出建议 依据:
AGENTS.md(项目级约束)、docs/33/docs/34(架构师对访客扩展的正式答复)、docs/05(接口唯一权威)、docs/29(登录接口约定)、docs/01/docs/14(底座与接入规范)、tools/seed_test_rbac.py(RBAC 定义源)、以及逐文件读码 性质:建议书。不改任何代码;一旦你选定方案,我按本文件落地 关联:报告D4.1-客服Agent重构报告-2026-09-16.md的 §2.5/§2.7 已按你要求清除并归档(归档-访客鉴权与访客分层讨论-已清除-2026-09-16.md),本文件是那两节的替代
0. 一句话结论
你的方向是对的,而且底座里已经埋着一条正好用来承载它的轴——但「分离」有两个档位:
| 档位 | 做什么 | 现在能不能做 | 我的建议 |
|---|---|---|---|
| 档位一(丙→乙,立即可做) | 收敛:把「访客」的判定与权限收敛到单一来源,roles 字段不动 |
✅ 能做,零契约变更 | 先做这个 |
| 档位二(甲,需会签) | 拆轴:roles 回归纯 RBAC,访客改由身份类型轴承载 |
⚠️ 是底座契约变更,须走 docs/33/34 那套会签 |
排在 MVP 之后 |
关键判断:档位二能让角色体系变干净,但它推翻不了三条已被架构师批准的既有裁定(见 §4)。分离的收益主要在档位一——而档位一不需要任何人批准。
1. 先看底座怎么规定鉴权(这是判据,不是我的偏好)
1.1 六条硬约定(逐条取证)
| # | 约定 | 出处(原文) |
|---|---|---|
| 1 | 不得绕过公共鉴权 | AGENTS.md 规则 7:「业务 Agent 必须继承公共 BaseAgent 并由 AgentFactory 创建,不得绕过公共鉴权、记忆、模型路由、工具、合规、审计和事件流程」 |
| 2 | 单点鉴权:不得另写第二套 | docs/33 §1.2:「不是自己另写一套。另写一套等于开了第二个鉴权入口,这与"单点鉴权"的约定冲突」 |
| 3 | 令牌里只有用户 id,角色/权限/数据范围一律服务端按库实时解析 | docs/29 §1:「令牌里只有用户 id,你的角色、权限、能看多少数据全部由服务端按库里的 RBAC 实时解析」;docs/29 §3:「真正的鉴权每次请求都由服务端查库解析,所以权限被改后立刻生效」 |
| 4 | 客户端不得声明身份 | docs/05 §:「客户端不得提交 user_id、portal 或澄清轮次」;metadata、Header、JWT 中的 roles/portal 均不作为授权依据 |
| 5 | 授权判定的三个声明维度 | docs/01 §1111:① agent 已注册 ② 当前 portal 允许调用该 Agent ③ 角色/权限;AgentDefinition 携带 allowed_roles/allowed_portals |
| 6 | 权限码定义源唯一 | AGENTS.md §E:tools/seed_test_rbac.py 的 PERMISSIONS,DELETE 重建语义,漏并进去的权限码重建一次就没了 |
1.2 角色体系的实际形状(sys_role 里有什么)
tools/seed_test_rbac.py:202-206 定义的角色只有三行,advisor 由 grant_advisor_role.py 另建:
| role_id | role_code | 名称 | 来源 |
|---|---|---|---|
| 9001 | customer |
客户 | 种子 |
| 9002 | risk_operator |
风控专员 | 种子 |
| 9003 | admin |
平台管理员 | 种子 |
| 9004 | advisor |
投顾 | grant_advisor_role.py |
| — | operator / super_admin |
— | 代码引用但库里从未建过(docs/29 §8 明说) |
⇒ visitor 不在 sys_role,也不在任何种子或迁移里。 它只作为代码级字符串存在于 AgentDefinition.allowed_roles、ToolDefinition.allowed_roles、context.roles。
1.3 三条轴里,有一条已经声明但完全闲置
AgentAuthorizer.ensure_allowed(app/service/agent/authorizer.py:7-14)判三件事:
if not set(definition.allowed_roles).intersection(context.roles): # 轴① 角色
raise ForbiddenAgentError("当前角色不能使用此 Agent")
if context.portal not in definition.allowed_portals: # 轴② 入口
raise ForbiddenAgentError("当前入口不能使用此 Agent")
if "agent:run" not in context.permissions: # 轴③ 权限
raise ForbiddenAgentError("缺少 Agent 运行权限")
轴②(入口)已存在,但全仓 8 个 Agent 的 allowed_portals 一律是 ("api",),而 identity_repository.py:60 把 portal 写死为 "api" → 这条轴从来没有拒绝过任何人。
这是底座里一个已声明、未启用的维度。它对本方案的意义见 §3 方案丙。
2. 现状:访客与角色体系「混」在三处
这是你说「想分离」的真实病灶,逐条取证。
混① 命名混——同一字段装两种东西
roles 里同时装:DB 角色(customer/advisor/risk_operator/…,来自 sys_user_role)与匿名受众(visitor,代码常量)。
混② 来源混——这是最实质的问题:同一字段有两个事实来源
| 身份 | 角色从哪来 | 是否实时 |
|---|---|---|
| 登录用户 | auth.py:53 → IdentityService.resolve → IdentityRepository.load_context → 每次请求查 sys_user_role/sys_role_permission |
✅ 实时(约定 3) |
| 访客 | security.py:92-97 —— 鉴权时由令牌 claim 直接生成硬编码三元组,且 auth.py:52-53 显式跳过 DB 解析 |
❌ 改代码才生效 |
# app/core/security.py:92-97 —— 访客权限在这里被写死
if claims.get("visitor") is True:
return RequestContext(
user_id=str(subject), trace_id=str(uuid4()), roles=("visitor",),
# 访客仅可运行 Agent 与读取已发布的公共知识,绝不含个人数据权限。
permissions=("agent:run", "knowledge:query"), data_scope="public",
)
# app/api/dependencies/auth.py:51-53 —— 访客跳过身份解析
context = _authenticator().authenticate(credentials.credentials)
if "visitor" not in context.roles:
context = await IdentityService().resolve(context)
⇒ 现状是本平台唯一一处「权限写在令牌里、不查库」的地方,与约定 3、约定 4 都相抵。这是你想做分离的最强理由,也是我认同该方向的根本原因。
混③ 档位混——data_scope 被用来表达「看哪些内容」
访客拿到 data_scope="public",但 data_scope 的取值词表是 RBAC 三档(identity_repository.py:34):
rank = {"self": 0, "own_customers": 1, "all": 2}
"public" 不在这张表里。架构师已核过全部 26 个消费点,结论是一致 fail closed、安全,并明确:
「
public目前主要是语义标注——这没问题,标注清楚比复用self更好」——docs/33§1.1
⇒ 所以这一处「混」已被追认,属于「刻意的语义标注」,不是缺陷。 分离时不要动它(详见 §4 不变量③)。
3. 四个方案对比与推荐
方案甲:新增「身份类型」轴(终态,需会签)
做法:RequestContext 增 subject_type: Literal["visitor","user","service"] = "user";访客 subject_type="visitor"、roles=();AgentDefinition/ToolDefinition 增 allowed_subject_types;authorizer 与 tool_executor 改为双维判定。
| 维度 | 评价 |
|---|---|
| 收益 | roles 回归纯 RBAC;访客不再伪装成角色;「谁是谁」一字段可见 |
| 成本 | 6 个底座文件(contracts.py/authorizer.py/tool_executor.py/bootstrap.py/auth.py/runtime.py)+ 声明面 20+ 处 allowed_roles 要挪 "visitor" + 全部相关测试 |
| 风险 | ① 漏改 authorizer/tool_executor → 访客一问即 403(fail-closed,能发现);② 漏改 bootstrap 声明 → 同样 403;③ 漏改 base.py:147 → 访客被召回长期记忆,静默、不报错(最危险) |
| 批准 | ❌ 未批准。属底座契约变更,须走 docs/33/34 的会签流程 |
方案乙:零契约变更的「收敛」(推荐立即做)
做法:一个字段都不改,只做三件事:
-
新增
app/core/actor.py——访客判定的唯一口径(与项目既有先例app/core/memory_scope.py的收敛方式一致):def is_anonymous(context: RequestContext) -> bool: ... def anonymous_context(*, subject: str, trace_id: str) -> RequestContext: ... # 访客三元组唯一构造点 def requires_db_resolution(context: RequestContext) -> bool: ... -
把 6 处 visitor 判断改走它(
auth.py:52、runtime.py:193/221、base.py:147、agent_run_application_service.py:141); -
security.py:92-97与runtime.py:179-184改为调用同一个构造点 → 两份副本变一份。
| 维度 | 评价 |
|---|---|
| 收益 | ① 消除「访客权益定义两遍」;② 访客权限改一处即生效(不再有 Worker 侧漏网);③ 判定口径收敛,为方案甲铺路;④ 对外行为完全不变,验收方式就是「行为不变」 |
| 成本 | 新增 1 个文件(core/actor.py);改 6 处调用点;无需会签(不动任何契约/声明) |
| 风险 | 极低。roles 字段名义上仍混装(字段名没变),但语义已在文档与单点模块里分离 |
| 批准 | ✅ 不需要——不新增字段、不改声明、不改判定结果 |
为什么
base.py:147也必须改走actor.py:架构师明确要求「访客不召回保留在base.py的角色判断里,不要下放给声明位——角色判断是底座级安全兜底」(docs/34§0)。改走actor.py保持位置与层级不变(仍在底座、仍不依赖 Agent 声明),只把谓词统一,正是这条裁定的延伸而非违反。
方案丙:复用闲置的入口轴(备选,不推荐)
做法:让访客 portal="guest",把客服 Agent 的 allowed_portals 改成 ("api","guest"),authorizer 的二维判定改为「任一命中即通过」。
| 维度 | 评价 |
|---|---|
| 优点 | 零新增契约字段(portal/allowed_portals 都已存在,且 docs/05 已承认服务端可决定 portal,示例值有 customer_chat) |
| 致命点 | 语义错位:portal 按 docs/01/docs/05 的定义是「入口/端」,不是「受众身份」。把访客塞进与 customer_chat 并列的位置,等于把「入口」偷换成「身份」——会签时大概率被同样理由驳回,且日后要区分「访客从哪个端来」时无从表达 |
| 结论 | ❌ 不如直接加字段(方案甲)。仅记录,不建议 |
方案丁:把访客登记成 DB 角色(应否决)
理由三条:① 访客没有 sys_user 行,而 identity_repository.py:19-23 查到 sys_user.status != '正常' 即 401 → 要支持必须为匿名主体造一条假用户;② 污染 RBAC 主数据(角色 = 人的职责,不是匿名性);③ operator/super_admin 都还没建(docs/29 §8),再加匿名角色会让「角色=人」的语义进一步模糊。
推荐路线
【现在】方案乙(收敛)—— 零会签,立刻拿掉「定义两遍」与「口径散落」
↓ MVP 演示跑通之后
【终态】方案甲(拆轴)—— 单独一轮,按 docs/33/34 方式会签
理由:分离的痛苦已经被感知到的那部分(改一处不生效、口径散落)全部在档位一,而档位一不需要任何人批准;档位二的收益是"语义更干净",但它要改底座契约,放进 MVP 关键路径会拖慢演示,且它推翻不了 §4 的任何一条既有裁定——先做乙,是让甲将来更容易过会的准备,不是替代。
4. 分离后的三条「不变量」(越过任何一条都会推翻已批裁定)
无论走哪条路线,下面三条必须原样保留。 它们不是我的偏好,是架构师已签发并写进 docs/33/docs/34 的结论。
| # | 不变量 | 出处 | 分离时怎么处理 |
|---|---|---|---|
| ① | 单点鉴权:不新增免鉴权入口、不另写第二套 decode |
docs/33 §1.2「另写一套等于开了第二个鉴权入口」 |
访客仍走 build_request_context → JwtAuthenticator.authenticate,只改"跳过 DB 解析"的表达方式,不新增路由/入口 |
| ② | 「访客不召回」留在底座的角色判断里,不下放给声明位 | docs/34 §0「角色判断是底座级安全兜底:声明位漏写一个 Agent 就会静默召回访客记忆」 |
谓词可换("visitor" in roles → is_anonymous(context)),位置与层级不得变:仍在 base.py、仍不依赖任何 Agent 声明 |
| ③ | 访客最小权限 ("agent:run","knowledge:query") + data_scope="public" 作语义标注 |
docs/33 §1.3 + §1.1「标注清楚比复用 self 更好」 |
不要为了"更干净"把 public 改成 self,也不要顺手加 knowledge:reference:read |
⚠️ 不变量③ 是最容易被"设计洁癖"误伤的一条:
data_scope="public"看起来"不属于这张词表",但架构师已逐点核过 26 个消费点并明确偏好它。分离时把它改掉 = 主动回退一条已批准的合规结论。
5. 访客与角色分离后,鉴权流程的影响(逐环节对照)
5.1 当前流程
① POST /api/v1/visitor-tokens 无认证,仅按 IP 限流 30 次/分
↓ 签发 JWT{sub=随机18位, visitor:true}
② Authorization: Bearer <token>
↓
③ build_request_context ← 全站唯一入口(auth.py:24-68)
↓
④ JwtAuthenticator.authenticate(security.py:68-98)
├─ 登录用户 → 只回 {user_id} ← 约定3:令牌只带 id
└─ 访客 → 直接回 {roles=("visitor",), permissions=(…2个), data_scope="public"}
← 混②:权限写在令牌层
↓
⑤ auth.py:52 if "visitor" not in roles → 真实用户走 IdentityService.resolve(查库)
访客在此被显式跳过
↓
⑥ AgentAuthorizer.ensure_allowed → allowed_roles ∩ roles / portal / agent:run
⑦ ToolExecutor.execute → 意图白名单 → required_permission → allowed_roles ∩ roles
↓
⑧ 受理:actor_type = "visitor" if "visitor" in roles else "authenticated"(写 Outbox)
↓
⑨ Worker: restore_context(actor_type) → 【第二条路径】又构造一遍访客三元组(runtime.py:179-184)
已确认的隐患:④/⑨ 是两份逐字相同的访客三元组,且无一致性测试(test_security.py 与 test_runtime_worker_dispatch.py 各测一份、互不关联)→ 改一处不生效。
5.2 走方案乙之后(对外行为零变化)
①~② 不变
③ 不变 —— 仍是 build_request_context 唯一入口(不变量①)
④ 不变 —— 仍是同一套 jwt.decode / sub 校验
但访客三元组改为调用 actor.anonymous_context(sub, trace) ← 单一定义
⑤ 跳过 DB 解析的条件改由 actor.requires_db_resolution(context) 表达(语义化,不再靠字面量判断)
⑥⑦ 不变 —— allowed_roles 仍含 "visitor"(架构师已批:声明式扩集合不改语义)
⑧ 改为 actor.is_anonymous(context)
⑨ 改为调用同一个 actor.anonymous_context(...) → 【两份变一份】
影响面:对外行为零变化;改动集中在 6 处判定 + 1 处构造。验收方式 = 行为不变(见 §6)。
5.3 若将来走方案甲(终态)
| 环节 | 变化 | 漏改的后果 |
|---|---|---|
| ④ | 访客 → subject_type="visitor"、roles=() |
— |
⑥ AgentAuthorizer |
增身份轴判定;customer_service 的 "visitor" 从 allowed_roles 挪到 allowed_subject_types |
漏改 → 访客一问即 403(fail-closed,能发现) |
⑦ ToolExecutor |
同上;query_knowledge 工具的 allowed_roles 同样挪走 |
同上 |
| ⑧⑨ | 同方案乙 | — |
base.py:147 |
谓词换成 is_anonymous(context) |
漏改 → 访客被召回长期记忆并注入提示词,静默无报错(本项目已有同类前科:AGENTS.md §E 记录的"三处口径不一导致越权") |
⇒ 方案甲必须配套「访客五查」单测(否则最危险那一条漏了也不会红):
| # | 断言 | 守的是 |
|---|---|---|
| 1 | 访客调 agent-runs + customer_service → 202 |
⑥ Agent 层 |
| 2 | Worker 内 customer_service 执行成功(不 403) |
⑨ Worker 重建 |
| 3 | 访客调 query_knowledge → 成功 |
⑦ 工具层(游客入口) |
| 4 | 访客调 search_knowledge → 被拒 |
⑦ 客户/游客工具分离 |
| 5 | 访客的 agent.run_completed 不产生 memory.extraction_requested / customer_profile.candidate_requested,且 interaction_audit.target_customer_id IS NULL |
不变量② + docs/33 §1.3 |
6. 落地方案(方案乙,具体到文件)
| 步 | 文件 | 动作 | 是否需会签 |
|---|---|---|---|
| 1 | app/core/actor.py(新增) |
访客判定与三元组的唯一来源;含 is_anonymous / anonymous_context / requires_db_resolution |
❌ 新增文件,不动契约 |
| 2 | app/core/security.py:92-97 |
改为 return anonymous_context(subject=str(subject), trace_id=str(uuid4())) |
❌ |
| 3 | app/api/dependencies/auth.py:52-53 |
跳过条件改 if requires_db_resolution(context) |
❌ |
| 4 | app/worker/runtime.py:179-184 |
改为同一构造点(消除第二份副本) | ❌ |
| 5 | app/worker/runtime.py:193/221 |
判定改 is_anonymous(context) |
❌ |
| 6 | app/service/agent/base.py:147 |
判定改 is_anonymous(context)(位置与层级不变,不变量②) |
❌ |
| 7 | app/service/agent_run_application_service.py:141 |
actor_type 投影改 is_anonymous(context) |
❌ |
| 8 | 新增单测 tests/unit/core/test_actor.py + 复用 §5.3 的「访客五查」 |
锁住单一来源:同一输入下 security.py 与 runtime.py 产出必须相等(这正是当前缺失的接缝测试) |
❌ |
为什么不需会签:本方案不新增字段、不改任何 AgentDefinition/ToolDefinition 声明、不改任何授权判定结果——只是把同一套值收拢到一个模块。按 AGENTS.md 的口径,这属于实现内部整理;security.py / auth.py / runtime.py / base.py 若被判定为"底座骨架不可改",则第 2~7 步需你确认;第 1、8 步(新增 actor.py + 单测)无论如何都可做。
⚠️ 白名单提醒:
core/security.py、api/dependencies/auth.py、worker/runtime.py、service/agent/base.py均不在 A-09 的 12 文件白名单内。这是本方案唯一需要你点头的地方——技术风险为零,但纪律上必须你授权。
7. 我的建议(明确表态)
- 先做方案乙(
core/actor.py+ 6 处调用点)。它是纯收益、零风险、免会签,且正好把"访客权益定义两遍"这个真实隐患消掉——它本身就是"分离"的第一步:分离的是定义与判定,不是字段。 - 方案甲留到 MVP 之后单独一轮,按
docs/33/docs/34的方式会签(先自查、附证据、单独提交、不混功能)。届时方案乙的actor.py正好是甲的落点——甲只是把actor.py里的三条函数接到一个新字段上。 - 方案丙不要用(把入口当身份)。
- 方案丁否决。
- 三条不变量原样保留(§4)。特别是
data_scope="public"和base.py的兜底位置——它们不是"不干净",是已批准的结论。
8. 待你拍板(3 项)
| # | 事项 | 我的建议 | 备选 |
|---|---|---|---|
| 1 | 是否现在做方案乙(新增 core/actor.py + 改 6 处调用点) |
做(纯收敛、零行为变化、免会签) | 等方案甲一起做(拖到 MVP 之后) |
| 2 | 若第 1 项选"做":security.py/auth.py/runtime.py/base.py 均不在 A-09 白名单,是否授权改动这 4 个文件 |
授权(技术风险为零,只是纪律披露) | 只允许新增 actor.py + 单测,调用点等甲一起改 |
| 3 | 终态是否采用方案甲(新增 subject_type 轴、roles 回归纯 RBAC) |
采用,但排在 MVP 之后单独一轮会签 | 不做甲,长期停在乙(roles 字段名义上仍混装) |
附:本文件的证据清单
| 结论 | 取证位置 |
|---|---|
| 鉴权唯一入口、访客跳过 DB 解析 | app/api/dependencies/auth.py:24-68(跳过在 :52-53) |
| 访客三元组由 claim 直接生成 | app/core/security.py:92-97;签发 :40-50 |
| 访客三元组第二份副本 | app/worker/runtime.py:179-184 |
| 登录用户角色实时查库 | app/repository/identity_repository.py:16-61(rank 在 :34) |
| 角色体系只有 4 个真角色 | tools/seed_test_rbac.py:202-206 + grant_advisor_role.py;docs/29 §8 |
visitor 不在 RBAC |
全仓 grep:仅出现在代码字符串、docs、测试 |
| 三条轴与闲置的入口轴 | app/service/agent/authorizer.py:7-14;8 个 Agent 的 allowed_portals 全为 ("api",);identity_repository.py:60 写死 portal="api" |
| 工具层三闸门 | app/service/tool_executor.py:97-105;query_knowledge 注册见 bootstrap.py:329-336 |
actor_type 投影 |
app/service/agent_run_application_service.py:141 |
data_scope="public" 已批准 |
docs/33 §1.1(26 个消费点逐点核过) |
| 访客最小权限已批准 | docs/33 §1.3 |
allowed_roles 含 visitor 已批准 |
docs/33 §2.1 |
兜底必须留在 base.py |
docs/34 §0 |
| 单点鉴权、不得另写入口 | docs/33 §1.2;AGENTS.md 规则 7 |
| 令牌只带 id、权限实时解析 | docs/29 §1/§3;docs/05 §(不得提交 portal/roles) |
授权三判定 + portal 语义 |
docs/01 §1111;docs/05 §236-237/§518 |