Files
group_fqcd_jr/开发文档/D3.3-访客与角色分离的鉴权方案建议-2026-09-16.md
T
张胜宇 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

325 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 访客与角色分离:鉴权方案建议
> **体系编号**:`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 |