Files
group_fqcd_jr/开发文档/D3.3-访客与角色分离的鉴权方案建议-2026-09-16.md
张胜宇 bc61d5c579 docs: 入库权威文档目录(客服agent/ 24 份 + 开发文档/ 50 份,替换旧命名的过期副本)
## 为什么做这一步

权威文档 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...`),
  末尾带省略号,是接口文档的示意值,**不是可用凭据**。
2026-09-20 15:03:15 +08:00

22 KiB
Raw Permalink Blame History

访客与角色分离:鉴权方案建议

体系编号: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 的会签流程

方案乙:零契约变更的「收敛」(推荐立即做)

做法:一个字段都不改,只做三件事:

  1. 新增 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: ...
    
  2. 把 6 处 visitor 判断改走它(auth.py:52、runtime.py:193/221、base.py:147、agent_run_application_service.py:141);

  3. 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. 我的建议(明确表态)

  1. 先做方案乙(core/actor.py + 6 处调用点)。它是纯收益、零风险、免会签,且正好把"访客权益定义两遍"这个真实隐患消掉——它本身就是"分离"的第一步:分离的是定义与判定,不是字段。
  2. 方案甲留到 MVP 之后单独一轮,按 docs/33/docs/34 的方式会签(先自查、附证据、单独提交、不混功能)。届时方案乙的 actor.py 正好是甲的落点——甲只是把 actor.py 里的三条函数接到一个新字段上。
  3. 方案丙不要用(把入口当身份)。
  4. 方案丁否决。
  5. 三条不变量原样保留(§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