fix(auth): 工作区成为读权限的边界 —— Agent 侧读端点按会话工作区收窄
用户报的:「agentmail 工作区的邮件会话被 trueagent 工作区的 agent 看到了, 还需要我亲自去解释。」 ## 根因不是漏了一个 WHERE,是隔离单位选错了 Agent 注册时 `workspaces` 是空的(B-1.2:cwd 由每封邮件的 `to_workspace` 决定), 所以**一个 Agent 同时服务所有工作区**。而可见性判据一直是 `AgentCanAccessSession(agentName, sid)` = "这个 Agent 名出现在这条会话的 from/to/cc 里" —— 于是同一个 agent `pi`,在 TrueAgent 里干活的 worker 眼里,对 agentmail 的会话 也成立。 现场证据:`mail_reads` 里 08:11–09:19 有 8 次「同一瞬间读了多个不同工作区的会话」 (08:23:59 一次跨 agentmail / TrueAgent / webui4frpc 三条会话),最后一次是 09:19:11 —— 正好停在 `read_inbox` 按会话收窄那个提交(552fbc7,09:19:25)之前。 更要紧的是 `mail_reads` 只记 `reader_name`、**没有「读的人当时在哪个工作区」这一列**, 所以这类越界读在数据上与正常读**无法区分** —— 这也是为什么只能由用户自己去解释。 ## 改法:补一维,而不是逐个端点打补丁 - 新增 `repo.AgentMayReadSession(agentName, scope, target)`:① 参与过(原有判据) ② 两条会话的 `workspace` 相同(新增)。`scope` = 调用方当前所在的那条会话。 - 服务端只认一条**会话 id**(`?session_id=`),由它反查 workspace —— **不接受调用方直接声明工作区**,否则等于让它自己给自己发通行证。 - 应用到四个读端点:`read_mail` / `read_thread` / `session_participants` / `list_contacts`,以及 `contacts/suggest` 的**会话候选**(name/path 两段不收窄: 跨工作区**发信**是设计允许的,被挡的只是"浏览同行的线索")。 - 未声明 `session_id` 时保留旧语义(放行)并**记警告日志**:迁移要能分步走, 但"还有谁没接线"必须可观测(另四家桥仍走这条路)。 - pi 桥:五个读工具全部带上自己那条邮件会话 id(由 worker 闭包注入,模型改不了)。 ## 顺手修掉一个真 bug 联系人查询的未读计数子查询里一直有 `r.reader_name = $1`,而原写法是 "forUser 为空就不传参" ⇒ $1 悬空:Postgres 直接报 `no parameter $1`, SQLite 把 `= $1` 当 `= NULL` 比、次次不成立(未读计数静默退化成"全部未归档")。 管理员 `?all=true` 走的正是这条路。现在 $1 恒传。 ## 判据(两侧都验 + 变异) - repo:同工作区放行 / 跨工作区拒且 reason 分得清 / 没参与过拒 / 未声明 scope 的旧语义;列表类有反向对照(不带收窄两条都在); 建议补全同工作区照常给候选、跨工作区查路径不给、不带收窄会给(对照组)。 - ★ 这条判据我第一版**写错了对照组**:拿 path=wsA 去比 —— 而 path 本来就收窄, 于是"不带收窄"也只剩一条,判据等于空的。改成拿 path=wsB 比才有区分力。 - 变异 3 处(拿掉工作区判据 / ListContactsInWorkspace 不收窄 / SuggestSessionCandidatesInWorkspace 不收窄)⇒ 各自恰好红在对应那条断言。 - pi 桥 14 条:6 个读工具 × 带上/不带 scope 两侧 + worker 闭包 + 自检; 变异 read_mail 去掉收窄 ⇒ 恰好那一条红。 (工作区是多会话共用的,本次只 add 了 server/ 与 plugins/pi-mail-bridge/ 的 7 个文件。)
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@ -45,10 +46,73 @@ import (
|
||||
// 这里没有任何写端点。归档、改别名、决策权限都仍然只有人能做 ——
|
||||
// Agent 可以「看见并寻址」,但不能替人整理邮箱。
|
||||
|
||||
// agentScope 取「调用方当前所在的那条邮件会话」(查询串 `session_id`)。
|
||||
//
|
||||
// # 这一维是干什么的
|
||||
//
|
||||
// 请求里原先**根本没有**「我现在在哪个工作区」这个事实 —— 而隔离判据需要它。
|
||||
// 这里的立场是:不接受调用方直接声明工作区(那等于自己给自己发通行证),
|
||||
// 只接受一条**会话 id**,由服务端反查它的 `workspace`。
|
||||
//
|
||||
// # 三种返回
|
||||
//
|
||||
// - 声明了且合法 → 返回该 id
|
||||
// - 未声明 → 返回 nil(旧语义:放行),并记一条警告
|
||||
// - 声明了但不合法 → 写 400 并返回 ok=false
|
||||
// (不静默忽略:静默忽略会让调用方以为自己收窄了,而实际是全量)
|
||||
func agentScope(w http.ResponseWriter, r *http.Request, agentName string) (*uuid.UUID, bool) {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("session_id"))
|
||||
if raw == "" {
|
||||
// 还未接线的桥/脚本/浏览器会走这里。放行是迁移期的妥协,
|
||||
// 警告是收尾用的抓手:按日志把没接线的调用方找全。
|
||||
log.Printf("[agent-scope] %s 读了 %s 但没声明 session_id(旧语义放行:未按工作区隔离)",
|
||||
agentName, r.URL.Path)
|
||||
return nil, true
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "非法的 session_id")
|
||||
return nil, false
|
||||
}
|
||||
return &id, true
|
||||
}
|
||||
|
||||
// canReadSession 是五个读端点共用的那道闸门,失败时自己写响应。
|
||||
//
|
||||
// 两类拒绝的文案刻意不同:`not-participant` 说"不是你的线索",
|
||||
// `cross-workspace` 说"你的工作区不对" —— 但**不报出对方的工作区**
|
||||
// (那本身就是跨工作区信息)。调用方能看见的只有自己那个工作区名。
|
||||
func canReadSession(w http.ResponseWriter, r *http.Request, agentName string, scope *uuid.UUID, target uuid.UUID) bool {
|
||||
ok, reason, err := repo.AgentMayReadSession(r.Context(), agentName, scope, target)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return false
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
switch reason {
|
||||
case "cross-workspace":
|
||||
self := ""
|
||||
if scope != nil {
|
||||
self = repo.SessionWorkspaceOf(r.Context(), *scope)
|
||||
}
|
||||
Error(w, http.StatusForbidden,
|
||||
"无权访问该会话:调用方当前所在的工作区是 "+self+",目标会话不在同一个工作区")
|
||||
default:
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GET /api/v1/agent/contacts
|
||||
//
|
||||
// 本 Agent 参与过的全部会话,每条给出可直接投递的 `address`。
|
||||
// 本 Agent 参与过的会话,每条给出可直接投递的 `address`。
|
||||
// 与人类侧 `/contacts` 同源(`repo.ListContactsFor`),scope 固定为自己。
|
||||
//
|
||||
// 声明了 `session_id`(= 调用方当前所在那条会话)时只列**同工作区**的会话:
|
||||
// 一个 Agent 同时服务所有工作区,不收窄的话在 TrueAgent 里干活的 worker 会拿到
|
||||
// agentmail 的会话标题/别名/未读计数。
|
||||
func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
@ -56,8 +120,20 @@ func AgentListContacts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
scope, ok := agentScope(w, r, agentName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
archived := r.URL.Query().Get("archived") == "true"
|
||||
contacts, err := repo.ListContactsFor(r.Context(), agentName, archived)
|
||||
var contacts []repo.Contact
|
||||
var err error
|
||||
if scope == nil {
|
||||
contacts, err = repo.ListContactsFor(r.Context(), agentName, archived)
|
||||
} else {
|
||||
contacts, err = repo.ListContactsInWorkspace(
|
||||
r.Context(), agentName, repo.SessionWorkspaceOf(r.Context(), *scope), archived)
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to list contacts")
|
||||
return
|
||||
@ -160,7 +236,23 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 可见性传自己的名字:只提示自己参与过的会话。
|
||||
// 传空会把别人的私下线索也列出来,那是越权。
|
||||
sessions, err := repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||||
//
|
||||
// 声明了 `session_id` 时额外要求这些会话与调用方**同工作区**:参与过不等于
|
||||
// 该看 —— 一个 Agent 同时服务所有工作区(见 repo.AgentMayReadSession)。
|
||||
// 跨工作区寻址本身仍然可行(`new` 总在最后,paths 候选也不收窄),
|
||||
// 收窄的只是"浏览别的会话的标题/别名"。
|
||||
scope, ok := agentScope(w, r, agentName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var sessions []repo.SessionCandidate
|
||||
var err error
|
||||
if scope == nil {
|
||||
sessions, err = repo.SuggestSessionCandidates(r.Context(), agentName, name, path)
|
||||
} else {
|
||||
sessions, err = repo.SuggestSessionCandidatesInWorkspace(
|
||||
r.Context(), agentName, name, path, repo.SessionWorkspaceOf(r.Context(), *scope))
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to suggest sessions")
|
||||
return
|
||||
@ -192,15 +284,25 @@ func AgentSuggestAddress(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /api/v1/agent/mail/{id}/thread
|
||||
//
|
||||
// 与人类侧 `/mail/{id}/thread` 同一份实现,可见性判据换成
|
||||
// 「本 Agent 参与过该会话」。抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||||
// 「本 Agent 参与过该会话**且工作区相同**」(见 repo.AgentMayReadSession)。
|
||||
// 抄送协作要靠它回答「谁已经回了、谁还没回」。
|
||||
func AgentGetMailThread(w http.ResponseWriter, r *http.Request) {
|
||||
agentName := middleware.GetAgentName(r)
|
||||
if agentName == "" {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
scope, ok := agentScope(w, r, agentName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
serveMailThread(w, r, func(sid uuid.UUID) (bool, error) {
|
||||
return repo.AgentCanAccessSession(r.Context(), agentName, sid)
|
||||
// 丢掉 reason 而不是改 serveMailThread 的签名:人类侧 `/mail/{id}/thread`
|
||||
// 与这里共用同一份实现,只为一个调用点的文案去改它不划算。
|
||||
// 拒绝原因(跨工作区 / 没参与过)在 403 文案上合流成同一句,
|
||||
// 反而少泄一点信息。
|
||||
allowed, _, err := repo.AgentMayReadSession(r.Context(), agentName, scope, sid)
|
||||
return allowed, err
|
||||
})
|
||||
}
|
||||
|
||||
@ -214,6 +316,10 @@ func AgentGetMail(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
scope, ok := agentScope(w, r, agentName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mailID, ok := pathUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
@ -224,13 +330,7 @@ func AgentGetMail(w http.ResponseWriter, r *http.Request) {
|
||||
Error(w, http.StatusNotFound, "Mail not found")
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, mail.SessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该邮件")
|
||||
if !canReadSession(w, r, agentName, scope, mail.SessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -264,13 +364,11 @@ func AgentSessionParticipants(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
allowed, err := repo.AgentCanAccessSession(r.Context(), agentName, sessionID)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to check permission")
|
||||
scope, ok := agentScope(w, r, agentName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
Error(w, http.StatusForbidden, "无权访问该会话")
|
||||
if !canReadSession(w, r, agentName, scope, sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user