记忆召回补一道能力码闸门:归属关系不等于授权
上一版把员工召回改成"按 sys_customer_assignment 归属"时,只看了**归属关系**, 没校验**能力码** —— 这留下一个我自己带出来的口子: - 平台读他人画像/记忆的正式路径(customer_profile_service、public_platform_service) 校验的是 `memory:read:customer`(sys_permission id=9010,data_scope='own_customers') + `customer_ids`; - 而召回是**另一条**把客户记忆送进模型上下文的路径,只按归属放行,等于让 "有归属行但没有该权限"的账号(例如 operator:只有 financial:nl2sql:read + offsite:write)凭空获得读他人客户记忆的能力 —— 它上一版之前是读不到的。 现改为**两个条件都满足才放行**(缺一即失败关闭,并在日志里分开点名"缺能力码" 与"归属未维护",避免运维在错误的表上找问题): ① `memory:read:customer` 在 context.permissions 里; ② 客户落在 context.customer_ids(sys_customer_assignment 生效行)内。 演示账号不受影响:9002(risk_operator)、9020(advisor)、9003(admin) 三个角色都持有该权限码, 真实身份链路复验 9020 仍是"修复前 0 条 → 修复后 2 条"。 测试:tests/unit/core/test_memory_scope.py +3 条(缺能力码读不到/能力码与归属是"与"关系/ 现有用例补上能力码),tests/unit/service/test_agent_governance.py 同步。 `pytest tests/unit tests/contract` → 1448 passed, 2 skipped, 1 failed (唯一失败仍是组员正在改的投顾页面)。
This commit is contained in:
@@ -21,10 +21,18 @@
|
||||
|
||||
| 身份 | 可读范围 |
|
||||
|---|---|
|
||||
| **客户**(`customer` / `authenticated_user`) | **只有自己**(自身 `user_id` 当客户号);别家客户一律不可读 |
|
||||
| **员工**(风控/投顾/运营/管理员/system) | 只有 `sys_customer_assignment` 里**分配给自己**的客户;归属未维护 ⇒ 读不到(失败关闭) |
|
||||
| **客户**(`customer` / `authenticated_user`) | **只有自己**;别家客户一律不可读 |
|
||||
| **员工** | ① 有能力码 `memory:read:customer`,**且** ② 客户已分配给自己 |
|
||||
| **访客** | 无(调用方在上游已拦) |
|
||||
|
||||
"员工"指风控/投顾/运营/管理员/system 等一切非客户身份;上面那两条
|
||||
**缺一即读不到**(失败关闭)。
|
||||
|
||||
**为什么要两个条件**:归属关系回答"谁负责谁",能力码回答"能不能读他人客户数据"。
|
||||
平台读他人画像/记忆的正式路径(`customer_profile_service`、`public_platform_service`)
|
||||
校验的是后者(`memory:read:customer` + `own_customers` 范围)。召回是**另一条**把客户记忆
|
||||
送进模型上下文的路径,只按归属放行会让"有归属行但无此权限"的账号凭空获得该能力。
|
||||
|
||||
`sys_customer_assignment` 由 `IdentityRepository.load_context()` 读入 `context.customer_ids`,
|
||||
即本模块的输入。**归属未维护不是故障,是"没有授权"** —— 所以日志必须点名它,
|
||||
让运维知道该去维护分配表,而不是去查"记忆是不是坏了"。
|
||||
@@ -39,6 +47,15 @@ logger = logging.getLogger(__name__)
|
||||
#: 认定为"客户身份"的角色码。与 `app/worker/runtime.py` 的画像候选判定同一口径。
|
||||
CUSTOMER_ROLES: frozenset[str] = frozenset({"customer", "authenticated_user"})
|
||||
|
||||
#: 跨客户读记忆所需的**能力码**(`sys_permission` id=9010,`data_scope='own_customers'`)。
|
||||
#:
|
||||
#: 为什么召回也要看它:`customer_profile_service` / `public_platform_service` 读他人画像与记忆
|
||||
#: 时校验的正是这个码 + `own_customers` 范围 + `customer_ids`。召回是**另一条**把客户记忆
|
||||
#: 送进模型上下文的路径,**只按归属关系放行是不够的** —— 归属是"谁负责谁",能力码才是
|
||||
#: "能不能读他人客户数据"。两者都满足才放行,否则一个只有归属行、没有该权限的账号
|
||||
#: (例如 `operator`:只有 `financial:nl2sql:read` + `offsite:write`)会凭空获得读他人记忆的能力。
|
||||
REQUIRED_EMPLOYEE_PERMISSION = "memory:read:customer"
|
||||
|
||||
#: 单次运行最多召回多少个归属客户。
|
||||
#:
|
||||
#: 为什么要有上限:员工可能有成百上千个归属客户,逐个召回会变成 N 次库查询 + N 次
|
||||
@@ -70,6 +87,15 @@ def customer_memory_scope(context: RequestContext) -> tuple[int, ...]:
|
||||
return ()
|
||||
return (own,) if own > 0 else ()
|
||||
|
||||
# 能力码先于归属判定:两者是**与**关系。只在有权限时看归属,避免"有归属行就放行"。
|
||||
if REQUIRED_EMPLOYEE_PERMISSION not in context.permissions:
|
||||
logger.warning(
|
||||
"记忆范围为空:身份 roles=%s 缺少 %s 能力(读他人客户记忆的授权码),"
|
||||
"失败关闭。归属关系只解决「谁负责谁」,不构成读他人客户数据的授权",
|
||||
list(context.roles), REQUIRED_EMPLOYEE_PERMISSION,
|
||||
)
|
||||
return ()
|
||||
|
||||
parsed: set[int] = set()
|
||||
for raw in context.customer_ids:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user