## 为什么做这一步 权威文档 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...`), 末尾带省略号,是接口文档的示意值,**不是可用凭据**。
325 lines
22 KiB
Markdown
325 lines
22 KiB
Markdown
# 访客与角色分离:鉴权方案建议
|
||
|
||
> **体系编号**:`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`)判三件事:
|
||
|
||
```python
|
||
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 解析 | ❌ **改代码才生效** |
|
||
|
||
```python
|
||
# 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",
|
||
)
|
||
```
|
||
|
||
```python
|
||
# 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`):
|
||
|
||
```python
|
||
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` 的收敛方式一致):
|
||
|
||
```python
|
||
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 |
|