diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 0961110f..5e7171e2 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -120,7 +120,7 @@ public class ConversationService { /** * Workspace-scoped variant of {@link #listConversations(String)}. * - *
Strict ownership: only the user's own + {@code system} rows. Used by + *
Strict ownership: only the user's own rows. Used by
* callers that must not see other principals' conversations — notably the
* webchat visitor self-service path, which scopes to one visitor.
*
@@ -131,32 +131,25 @@ public class ConversationService {
}
/**
- * Admin-console variant. When {@code includeChannelPrincipals} is true, also
- * returns conversations owned by external channel principals
- * ({@code webchat: 控制台变体:includeChannelPrincipals 为 true 时额外纳入 webchat 访客会话。
*/
public List Mirrors {@link #listConversations(String, Long)}'s filtering (current
- * user + system rows, top-level only, optional workspace) and adds a
+ * user rows, top-level only, optional workspace) and adds a
* {@code keyword} match against title / conversationId. The keyword is
* case-insensitive and treated as a substring.
*
@@ -272,9 +266,9 @@ public class ConversationService {
com.baomidou.mybatisplus.extension.plugins.pagination.Page IM-channel (Feishu / DingTalk / WeCom / …) conversations must be
- * visible to every logged-in user in the admin console, so the owner is
- * uniformly set to {@code system}. For legacy rows whose owner was
+ * IM-channel (Feishu / DingTalk / WeCom / …) conversations use the
+ * shared {@code system} owner and are only surfaced to global admins in the
+ * admin console. For legacy rows whose owner was
* historically written as a sender nickname / {@code open_id}, this
* method silently rewrites it to {@code system} on read — otherwise the
- * console list and message endpoints would 403 those rows.
+ * admin console list and message endpoints would 403 those rows.
*
- * 获取或创建共享渠道会话。IM 渠道(飞书 / 钉钉 / 企微等)的会话需要在控制台中
- * 对登录用户可见,因此统一使用 {@code system} 作为 owner。对于历史上已写成发送者
- * 昵称 / open_id 的会话,这里会自动修正为 {@code system},避免控制台列表和
- * 消息接口因权限校验而不可见。
+ * 获取或创建共享渠道会话。IM 渠道(飞书 / 钉钉 / 企微等)的会话统一使用
+ * {@code system} 作为 owner,并仅在全局管理员控制台中展示。对于历史上已写成
+ * 发送者昵称 / open_id 的会话,这里会自动修正为 {@code system},避免管理员
+ * 控制台列表和消息接口因权限校验而不可见。
*/
@Transactional
public ConversationEntity getOrCreateSharedConversation(String conversationId, Long agentId) {
@@ -1740,10 +1734,10 @@ public class ConversationService {
}
/**
- * Check whether a user owns the conversation. Direct owners always pass;
- * shared rows (system / IM / {@code webchat: Cross-workspace guard (issue #344). The legacy contract let any
* logged-in user reach a system / IM / webchat-owned conversation by id —
@@ -1752,20 +1746,22 @@ public class ConversationService {
* untrusted isolation boundaries, that asymmetry is a cross-workspace
* authorization gap. This method now also requires, for shared (non-direct)
* conversations, that the requester actually be a member of the
- * conversation's workspace.
+ * conversation's workspace. Issue #616 tightened this further: workspace
+ * membership alone is not enough to read a shared system conversation,
+ * because that lets peers in the same workspace see each other's channel
+ * or scheduled-job conversations.
*
* 校验用户是否拥有该会话。直属会话直接放行;共享会话(system / IM / webchat)
- * 额外要求请求者是该会话所属 workspace 的成员。
+ * 仅允许全局管理员查看,避免同 workspace 成员互相看到对话。
*
* 分支:
* 调用方签名不变;调用方若需在不查 DB 的情况下做 admin 例外,可在外层先短路,
@@ -1786,21 +1782,17 @@ public class ConversationService {
// 共享会话(system / IM / webchat owner)以下收紧。
Long convWorkspaceId = conv.getWorkspaceId();
UserEntity requester = authService.findByUsername(username);
+ // 全局 admin 横切放行,覆盖所有 workspace。
+ if (requester != null && "admin".equalsIgnoreCase(requester.getRole())) {
+ return true;
+ }
// 老数据无 workspace_id,或请求者为匿名(permitAll 端点重连场景):维持旧行为,
// 仅 system owner 可见。避免数据迁移未完成或匿名流式场景下回归。
if (convWorkspaceId == null || requester == null) {
return SYSTEM_USER.equals(conv.getUsername());
}
- // 全局 admin 横切放行,覆盖所有 workspace。
- if ("admin".equalsIgnoreCase(requester.getRole())) {
- return true;
- }
- // #344 的核心守卫:必须是该会话所属 workspace 的成员(viewer 或更高)。
- // 不读 X-Workspace-Id header —— 客户端可伪造;以 DB 成员关系为准。
- if (!workspaceService.hasPermissionCached(convWorkspaceId, requester.getId(), "viewer")) {
- return false;
- }
- return SYSTEM_USER.equals(conv.getUsername());
+ // #616: workspace membership alone is not ownership.
+ return false;
}
/**
diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java
index 97b9a121..73213b5a 100644
--- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceOwnershipWorkspaceTest.java
@@ -26,7 +26,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -42,9 +41,11 @@ import static org.mockito.Mockito.when;
* endpoints did not — an asymmetry that becomes a cross-workspace breach once
* workspaces are untrusted isolation boundaries.
*
- * Post-fix behavior: shared conversations are visible only to members of
- * their own workspace (plus global admins, plus the legacy escape hatches for
- * pre-workspace rows and anonymous permitAll reconnects).
+ * Post-fix behavior: shared conversations are visible only to global admins
+ * (plus the legacy escape hatches for pre-workspace rows and anonymous
+ * permitAll reconnects). Issue #616 intentionally rejects same-workspace
+ * ordinary members too, because workspace membership is not conversation
+ * ownership.
*
* Pure-Mockito (no Spring context) so the test stays fast and isolated.
*
@@ -101,17 +102,16 @@ class ConversationServiceOwnershipWorkspaceTest {
}
// ------------------------------------------------------------------
- // 2. System conv, same-workspace member → allowed
+ // 2. System conv, same-workspace member → rejected (#616)
// ------------------------------------------------------------------
@Test
- @DisplayName("system conv in requester's workspace: member passes")
- void systemConvSameWorkspaceMember() {
+ @DisplayName("system conv in requester's workspace: member rejected (#616)")
+ void systemConvSameWorkspaceMemberRejected() {
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A));
- when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer"))
- .thenReturn(true);
- assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isTrue();
+ assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isFalse();
+ verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
@@ -122,11 +122,9 @@ class ConversationServiceOwnershipWorkspaceTest {
@DisplayName("system conv in another workspace: non-member rejected (#344)")
void systemConvCrossWorkspaceRejected() {
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A));
- // Bob is not a member of tenant A.
- when(workspaceService.hasPermissionCached(WS_TENANT_A, BOB_USER_ID, "viewer"))
- .thenReturn(false);
assertThat(service.isConversationOwner(SYSTEM_CONV, "bob")).isFalse();
+ verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
@@ -189,12 +187,11 @@ class ConversationServiceOwnershipWorkspaceTest {
@DisplayName("webchat conv: invisible to a JWT user even when they share the workspace")
void webchatConvInvisibleToJwtUser() {
when(conversationMapper.selectOne(any())).thenReturn(conv(WEBCHAT_CONV, "webchat:vA", WS_TENANT_A));
- when(workspaceService.hasPermissionCached(WS_TENANT_A, ALICE_USER_ID, "viewer"))
- .thenReturn(true);
- // Alice is a member of the conv's workspace, but the conv is owned by
- // "webchat:vA" — not "system" — so the final OR-clause returns false.
+ // Alice may be a member of the conv's workspace, but the conv is owned
+ // by "webchat:vA" and workspace membership is not ownership.
assertThat(service.isConversationOwner(WEBCHAT_CONV, "alice")).isFalse();
+ verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
@Test
@@ -219,15 +216,12 @@ class ConversationServiceOwnershipWorkspaceTest {
}
@Test
- @DisplayName("system conv + same-workspace member that the workspace service lost track of: rejected")
+ @DisplayName("system conv + ordinary member: rejected without membership lookup (#616)")
void systemConvMemberCacheMiss() {
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A));
- // Membership cache returns false even though we'd expect this user to
- // be a member — defense in depth: when in doubt, deny.
- when(workspaceService.hasPermissionCached(eq(WS_TENANT_A), eq(ALICE_USER_ID), eq("viewer")))
- .thenReturn(false);
assertThat(service.isConversationOwner(SYSTEM_CONV, "alice")).isFalse();
+ verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java
index b37e54e3..3a48631e 100644
--- a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/ConversationServiceWebchatVisibilityTest.java
@@ -79,8 +79,8 @@ class ConversationServiceWebchatVisibilityTest {
}
@Test
- @DisplayName("lenient list, non-admin: excludes webchat principals (no 'webchat:%' param)")
- void lenientListNonAdminExcludesWebchat() {
+ @DisplayName("lenient list, non-admin: excludes shared principals (#616)")
+ void lenientListNonAdminExcludesSharedPrincipals() {
when(authService.findByUsername("alice")).thenReturn(user("member"));
ArgumentCaptor
*
*
*