fix(security): cross-workspace guard on isConversationOwner (#344)

Closes the authorization asymmetry between list endpoints (which filter
by workspaceId) and direct-access endpoints (which did not): a logged-in
user could reach another workspace's system / IM / webchat-owned
conversation by id and run any of messages / delete / rename / pin /
setModel / clear / chat-files download on it.

Per the maintainer's guidance on issue #344, workspaces are now treated
as untrusted isolation boundaries — the fix is the cross-cutting
hardening, kept out of feature work.

Behavior change (only for shared, non-direct convs):
- requester is a global admin (user.role=admin) → pass
- requester is a member of the conversation's workspace → pass
- otherwise → deny

Preserved to avoid regressions:
- direct owner (username == conv.username) → pass without lookup
- convs without workspace_id (legacy rows) → legacy system-owner check
- anonymous user (authService returns null, e.g. permitAll reconnect)
  → legacy system-owner check

Callers in ConversationController / ChatController / SubagentController /
GoalController / ApprovalController (18 sites) are unchanged — the
signature stays isConversationOwner(conversationId, username). The
workspace membership check is done via WorkspaceService.hasPermissionCached
(Caffeine-backed, same cache the WorkspaceAccessInterceptor uses) and
ignores the X-Workspace-Id header, which is client-controlled.

Tests: 11 cases in ConversationServiceOwnershipWorkspaceTest covering
each branch of the new logic. No caller-side test changes — the 66
caller tests (ConversationService*Test, ChatController*Test,
SubagentController*Test, GoalController*Test, ApprovalController*Test)
still pass.
This commit is contained in:
倪程伟 2026-06-18 02:03:47 +08:00 committed by matevip
parent 6bbb6489f4
commit cf5d47d249
2 changed files with 307 additions and 11 deletions

View File

@ -17,6 +17,8 @@ import vip.mate.approval.MetadataDecision;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.approval.model.ToolApprovalEntity;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.channel.model.ChannelSessionEntity;
import vip.mate.channel.repository.ChannelSessionMapper;
import vip.mate.task.model.AsyncTaskEntity;
@ -29,6 +31,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
import vip.mate.workspace.conversation.repository.MessageMapper;
import vip.mate.workspace.conversation.vo.ConversationVO;
import vip.mate.workspace.conversation.vo.MessageVO;
import vip.mate.workspace.core.service.WorkspaceService;
import java.io.IOException;
import java.nio.file.Files;
@ -81,6 +84,8 @@ public class ConversationService {
private final AsyncTaskMapper asyncTaskMapper;
private final ChannelSessionMapper channelSessionMapper;
private final ApplicationEventPublisher eventPublisher;
private final AuthService authService;
private final WorkspaceService workspaceService;
/**
* Optional spill store. Injected via a setter so the existing @RequiredArgsConstructor
@ -1516,14 +1521,37 @@ public class ConversationService {
}
/**
* Check whether a user owns the conversation, treating system-owned
* rows (e.g. from scheduled jobs / IM channels) and external channel
* principals ({@code webchat:<visitorId>}) as visible to every
* authenticated user otherwise the console could list a webchat
* conversation but 403 on opening / deleting / renaming it.
* Check whether a user owns the conversation. Direct owners always pass;
* shared rows (system / IM / {@code webchat:<visitorId>} principals) are
* additionally gated by the requester's membership in the conversation's
* workspace, so they are not reachable cross-workspace by id.
*
* <p>校验用户是否拥有该会话定时任务产生的会话username = system
* webchat 访客会话username = webchat:&lt;visitorId&gt;对所有登录用户可见
* <p><b>Cross-workspace guard (issue #344).</b> The legacy contract let any
* logged-in user reach a system / IM / webchat-owned conversation by id
* the list endpoints filtered by {@code workspaceId} but the direct-access
* endpoints did not. Under a multi-tenant model where workspaces are
* 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.
*
* <p>校验用户是否拥有该会话直属会话直接放行;共享会话(system / IM / webchat)
* 额外要求请求者是该会话所属 workspace 的成员
*
* <p>分支:
* <ul>
* <li>会话不存在 false</li>
* <li>请求者是该会话的直属 owner true(自己的会话,workspace 隐式一致)</li>
* <li>会话无 workspace_id(老数据) 仅看是否 system owner(维持旧行为,避免回归)</li>
* <li>请求者用户记录不存在(permitAll 端点的匿名重连) 仅看是否 system owner(维持旧行为)</li>
* <li>请求者是全局 admin(user.role=admin) true(横切覆盖,与具体 workspace 无关)</li>
* <li>请求者非该会话 workspace 的成员 false</li>
* <li>否则 system owner 检查(共享会话对本 workspace 成员可见)</li>
* </ul>
*
* <p>调用方签名不变;调用方若需在不查 DB 的情况下做 admin 例外,可在外层先短路,
* 但通常让本方法统一处理以避免散落的 admin 例外逻辑注意:本方法不读
* {@code X-Workspace-Id} header header 客户端可伪造, DB 中的成员关系为准
*/
public boolean isConversationOwner(String conversationId, String username) {
ConversationEntity conv = conversationMapper.selectOne(
@ -1532,10 +1560,28 @@ public class ConversationService {
if (conv == null) {
return false;
}
String owner = conv.getUsername();
return username.equals(owner)
|| SYSTEM_USER.equals(owner)
|| (owner != null && owner.startsWith(WEBCHAT_OWNER_PREFIX));
// 直属 owner 一律放行:会话由该用户创建,workspace 自然一致,无需再做成员校验
if (username != null && username.equals(conv.getUsername())) {
return true;
}
// 共享会话(system / IM / webchat owner)以下收紧
Long convWorkspaceId = conv.getWorkspaceId();
UserEntity requester = authService.findByUsername(username);
// 老数据无 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());
}
/**

View File

@ -0,0 +1,250 @@
package vip.mate.workspace.conversation;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import vip.mate.agent.repository.AgentMapper;
import vip.mate.approval.repository.ToolApprovalMapper;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.channel.repository.ChannelSessionMapper;
import vip.mate.task.repository.AsyncTaskMapper;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper;
import vip.mate.workspace.conversation.repository.MessageMapper;
import vip.mate.workspace.core.service.WorkspaceService;
import static org.assertj.core.api.Assertions.assertThat;
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;
/**
* Pin the cross-workspace authorization guard added to
* {@link ConversationService#isConversationOwner(String, String)} in response
* to issue #344.
*
* <p>Pre-fix behavior: any logged-in user could reach a system / IM / webchat
* -owned conversation by id, regardless of which workspace the conversation
* lived in. List endpoints filtered by {@code workspaceId}, direct-access
* endpoints did not an asymmetry that becomes a cross-workspace breach once
* workspaces are untrusted isolation boundaries.
*
* <p>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).
*
* <p>Pure-Mockito (no Spring context) so the test stays fast and isolated.
*
* @author MateClaw Team
*/
@ExtendWith(MockitoExtension.class)
class ConversationServiceOwnershipWorkspaceTest {
private static final String SYSTEM_CONV = "cron:daily-report";
private static final String ALICE_CONV = "alice-uuid-1";
private static final String WEBCHAT_CONV = "webchat:testkey1:vA";
private static final long WS_TENANT_A = 10L;
private static final long WS_TENANT_B = 20L;
private static final long ALICE_USER_ID = 1001L;
private static final long BOB_USER_ID = 1002L;
private static final long ADMIN_USER_ID = 1003L;
@Mock private ConversationMapper conversationMapper;
@Mock private MessageMapper messageMapper;
@Mock private AgentMapper agentMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private ToolApprovalMapper toolApprovalMapper;
@Mock private AsyncTaskMapper asyncTaskMapper;
@Mock private ChannelSessionMapper channelSessionMapper;
@Mock private ApplicationEventPublisher eventPublisher;
@Mock private AuthService authService;
@Mock private WorkspaceService workspaceService;
@InjectMocks private ConversationService service;
@BeforeEach
void stubUsers() {
// Lenient stubs not every test needs both users (admin-only tests
// would trip strict-stubbing otherwise).
org.mockito.Mockito.lenient().when(authService.findByUsername("alice"))
.thenReturn(user(ALICE_USER_ID, "user"));
org.mockito.Mockito.lenient().when(authService.findByUsername("bob"))
.thenReturn(user(BOB_USER_ID, "user"));
}
// ------------------------------------------------------------------
// 1. Direct owner no workspace check needed
// ------------------------------------------------------------------
@Test
@DisplayName("direct owner: always allowed, no membership lookup")
void directOwnerShortCircuits() {
when(conversationMapper.selectOne(any())).thenReturn(conv(ALICE_CONV, "alice", WS_TENANT_A));
assertThat(service.isConversationOwner(ALICE_CONV, "alice")).isTrue();
// Workspace membership is NOT consulted alice owns it, end of story.
verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
// 2. System conv, same-workspace member allowed
// ------------------------------------------------------------------
@Test
@DisplayName("system conv in requester's workspace: member passes")
void systemConvSameWorkspaceMember() {
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();
}
// ------------------------------------------------------------------
// 3. System conv, cross-workspace user DENIED (the #344 fix)
// ------------------------------------------------------------------
@Test
@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();
}
// ------------------------------------------------------------------
// 4. Global admin bypass
// ------------------------------------------------------------------
@Test
@DisplayName("system conv in another workspace: global admin bypasses")
void systemConvAdminBypass() {
when(authService.findByUsername("admin")).thenReturn(user(ADMIN_USER_ID, "admin"));
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A));
// Admin skips the membership check entirely.
assertThat(service.isConversationOwner(SYSTEM_CONV, "admin")).isTrue();
verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
// 5. Anonymous / permitAll reconnect user lookup returns null
// ------------------------------------------------------------------
@Test
@DisplayName("system conv + null user record (anonymous reconnect): legacy behavior preserved")
void anonymousReconnectLegacyFallback() {
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", WS_TENANT_A));
when(authService.findByUsername("anonymous")).thenReturn(null);
// Pre-fix: anonymous could see system convs. Preserve that.
assertThat(service.isConversationOwner(SYSTEM_CONV, "anonymous")).isTrue();
verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
@Test
@DisplayName("anonymous reconnect to a non-system conv: still rejected")
void anonymousReconnectNonSystemConv() {
when(conversationMapper.selectOne(any())).thenReturn(conv(ALICE_CONV, "alice", WS_TENANT_A));
when(authService.findByUsername("anonymous")).thenReturn(null);
assertThat(service.isConversationOwner(ALICE_CONV, "anonymous")).isFalse();
}
// ------------------------------------------------------------------
// 6. Legacy rows without workspace_id fall back to old logic
// ------------------------------------------------------------------
@Test
@DisplayName("conv without workspace_id: legacy system-owner check, no membership lookup")
void legacyConvWithoutWorkspace() {
when(conversationMapper.selectOne(any())).thenReturn(conv(SYSTEM_CONV, "system", null));
assertThat(service.isConversationOwner(SYSTEM_CONV, "bob")).isTrue();
verify(workspaceService, never()).hasPermissionCached(anyLong(), anyLong(), anyString());
}
// ------------------------------------------------------------------
// 7. Webchat convs already isolated; verify the fix doesn't open them
// ------------------------------------------------------------------
@Test
@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.
assertThat(service.isConversationOwner(WEBCHAT_CONV, "alice")).isFalse();
}
@Test
@DisplayName("webchat conv: global admin can still reach it (consistent with system convs)")
void webchatConvAdminBypass() {
when(authService.findByUsername("admin")).thenReturn(user(ADMIN_USER_ID, "admin"));
when(conversationMapper.selectOne(any())).thenReturn(conv(WEBCHAT_CONV, "webchat:vA", WS_TENANT_A));
assertThat(service.isConversationOwner(WEBCHAT_CONV, "admin")).isTrue();
}
// ------------------------------------------------------------------
// 8. Edge cases
// ------------------------------------------------------------------
@Test
@DisplayName("conversation not found: false")
void notFound() {
when(conversationMapper.selectOne(any())).thenReturn(null);
assertThat(service.isConversationOwner("missing", "alice")).isFalse();
}
@Test
@DisplayName("system conv + same-workspace member that the workspace service lost track of: rejected")
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();
}
// ------------------------------------------------------------------
// helpers
// ------------------------------------------------------------------
private static UserEntity user(long id, String role) {
UserEntity u = new UserEntity();
u.setId(id);
u.setRole(role);
return u;
}
private static ConversationEntity conv(String conversationId, String username, Long workspaceId) {
ConversationEntity c = new ConversationEntity();
c.setConversationId(conversationId);
c.setUsername(username);
c.setWorkspaceId(workspaceId);
return c;
}
}