fix(team): allow admins to read completed worker runs (#596)

This commit is contained in:
matevip 2026-08-20 03:26:15 -04:00
parent 656a0b0436
commit 1eabbb68ac
8 changed files with 181 additions and 9 deletions

View File

@ -27,7 +27,8 @@ public class TeamWorkerConversationController {
@RequestParam(required = false) Long taskId, @RequestParam(required = false) Long taskId,
Authentication authentication) { Authentication authentication) {
String username = authentication == null ? "anonymous" : authentication.getName(); String username = authentication == null ? "anonymous" : authentication.getName();
if (!conversationService.isConversationOwner(conversationId, username)) { if (!conversationService.isConversationOwner(conversationId, username)
&& !governanceService.canReadTranscript(conversationId, runId, taskId, username)) {
return R.fail(403, "无权访问该会话"); return R.fail(403, "无权访问该会话");
} }
return governanceService.resolve(conversationId, runId, taskId) return governanceService.resolve(conversationId, runId, taskId)

View File

@ -3,10 +3,13 @@ package vip.mate.team.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper; import vip.mate.team.repository.TeamTaskMapper;
import vip.mate.workspace.core.service.WorkspaceService;
import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.ConversationMapper;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
@ -21,6 +24,8 @@ public class TeamWorkerConversationGovernanceService {
private final TeamTaskMapper taskMapper; private final TeamTaskMapper taskMapper;
private final TeamRunMapper runMapper; private final TeamRunMapper runMapper;
private final ConversationMapper conversationMapper; private final ConversationMapper conversationMapper;
private final AuthService authService;
private final WorkspaceService workspaceService;
public Optional<TeamWorkerConversationContext> resolve( public Optional<TeamWorkerConversationContext> resolve(
String conversationId, Long requestedRunId, Long requestedTaskId) { String conversationId, Long requestedRunId, Long requestedTaskId) {
@ -58,4 +63,47 @@ public class TeamWorkerConversationGovernanceService {
true, "team_worker", conversationId, run.getId(), task.getId(), run.getTeamId(), true, "team_worker", conversationId, run.getId(), task.getId(), run.getTeamId(),
run.getLeadConversationId(), task.getAssigneeAgentId())); run.getLeadConversationId(), task.getAssigneeAgentId()));
} }
/**
* Team worker transcripts are read-only evidence for a team run. The worker
* conversation is owned by the executing agent/user, so workspace admins and
* reviewers are not direct conversation owners. Allow them to read only when
* the persisted task/run/conversation linkage is canonical and they belong
* to that run's workspace.
*/
public boolean canReadTranscript(String conversationId, Long requestedRunId, Long requestedTaskId,
String username) {
TeamWorkerAccess access = resolveAccess(conversationId, requestedRunId, requestedTaskId);
if (access == null) {
return false;
}
UserEntity requester = authService.findByUsername(username);
if (requester == null) {
return false;
}
if ("admin".equalsIgnoreCase(requester.getRole())) {
return true;
}
return workspaceService.hasPermissionCached(access.workspaceId(), requester.getId(), "viewer");
}
private TeamWorkerAccess resolveAccess(String conversationId, Long requestedRunId, Long requestedTaskId) {
if (resolve(conversationId, requestedRunId, requestedTaskId).isEmpty()) {
return null;
}
TeamTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper<TeamTaskEntity>()
.eq(TeamTaskEntity::getConversationId, conversationId)
.last("LIMIT 1"));
if (task == null || task.getRunId() == null) {
return null;
}
TeamRunEntity run = runMapper.selectById(task.getRunId());
if (run == null || run.getWorkspaceId() == null) {
return null;
}
return new TeamWorkerAccess(run.getWorkspaceId());
}
private record TeamWorkerAccess(Long workspaceId) {
}
} }

View File

@ -9,6 +9,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.ConversationVO;
import vip.mate.workspace.conversation.vo.MessageVO; import vip.mate.workspace.conversation.vo.MessageVO;
@ -32,6 +33,7 @@ public class ConversationController {
private final ConversationService conversationService; private final ConversationService conversationService;
private final ChatStreamTracker streamTracker; private final ChatStreamTracker streamTracker;
private final TeamWorkerConversationGovernanceService teamWorkerGovernanceService;
/** /**
* 获取当前用户的会话列表 * 获取当前用户的会话列表
@ -94,9 +96,12 @@ public class ConversationController {
public R<?> listMessages(@PathVariable String conversationId, public R<?> listMessages(@PathVariable String conversationId,
@RequestParam(required = false) Long beforeId, @RequestParam(required = false) Long beforeId,
@RequestParam(required = false) Integer limit, @RequestParam(required = false) Integer limit,
@RequestParam(required = false) Long runId,
@RequestParam(required = false) Long taskId,
Authentication auth) { Authentication auth) {
String username = auth != null ? auth.getName() : "anonymous"; String username = auth != null ? auth.getName() : "anonymous";
if (!conversationService.isConversationOwner(conversationId, username)) { if (!conversationService.isConversationOwner(conversationId, username)
&& !teamWorkerGovernanceService.canReadTranscript(conversationId, runId, taskId, username)) {
return R.fail(403, "无权访问该会话"); return R.fail(403, "无权访问该会话");
} }

View File

@ -4,10 +4,13 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.team.model.TeamRunEntity; import vip.mate.team.model.TeamRunEntity;
import vip.mate.team.model.TeamTaskEntity; import vip.mate.team.model.TeamTaskEntity;
import vip.mate.team.repository.TeamRunMapper; import vip.mate.team.repository.TeamRunMapper;
import vip.mate.team.repository.TeamTaskMapper; import vip.mate.team.repository.TeamTaskMapper;
import vip.mate.workspace.core.service.WorkspaceService;
import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.repository.ConversationMapper; import vip.mate.workspace.conversation.repository.ConversationMapper;
@ -21,6 +24,8 @@ class TeamWorkerConversationGovernanceServiceTest {
@Mock private TeamTaskMapper taskMapper; @Mock private TeamTaskMapper taskMapper;
@Mock private TeamRunMapper runMapper; @Mock private TeamRunMapper runMapper;
@Mock private ConversationMapper conversationMapper; @Mock private ConversationMapper conversationMapper;
@Mock private AuthService authService;
@Mock private WorkspaceService workspaceService;
@Test @Test
void returnsVerifiedCanonicalContextOnlyWhenRequestedLinkageMatches() { void returnsVerifiedCanonicalContextOnlyWhenRequestedLinkageMatches() {
@ -102,8 +107,39 @@ class TeamWorkerConversationGovernanceServiceTest {
assertThat(service().resolve("team-task-legacy-no-parent", 77L, 501L)).isPresent(); assertThat(service().resolve("team-task-legacy-no-parent", 77L, 501L)).isPresent();
} }
@Test
void allowsWorkspaceAdminToReadVerifiedWorkerTranscript() {
TeamTaskEntity task = task(501L, 77L, "worker-conversation");
when(conversationMapper.selectOne(any())).thenReturn(conversation(
"worker-conversation", 30L, 41L, "lead-conversation", "team_worker"));
when(taskMapper.selectOne(any())).thenReturn(task);
when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead-conversation"));
when(authService.findByUsername("workspace-admin")).thenReturn(user(900L, "user"));
when(workspaceService.hasPermissionCached(30L, 900L, "viewer")).thenReturn(true);
assertThat(service().canReadTranscript("worker-conversation", 77L, 501L,
"workspace-admin")).isTrue();
}
@Test
void rejectsWorkerTranscriptReadWhenRouteLinkageOrWorkspaceMembershipDoesNotMatch() {
TeamTaskEntity task = task(501L, 77L, "worker-conversation");
when(conversationMapper.selectOne(any())).thenReturn(conversation(
"worker-conversation", 30L, 41L, "lead-conversation", "team_worker"));
when(taskMapper.selectOne(any())).thenReturn(task);
when(runMapper.selectById(77L)).thenReturn(run(77L, 20L, "lead-conversation"));
when(authService.findByUsername("outsider")).thenReturn(user(901L, "user"));
when(workspaceService.hasPermissionCached(30L, 901L, "viewer")).thenReturn(false);
assertThat(service().canReadTranscript("worker-conversation", 88L, 501L,
"outsider")).isFalse();
assertThat(service().canReadTranscript("worker-conversation", 77L, 501L,
"outsider")).isFalse();
}
private TeamWorkerConversationGovernanceService service() { private TeamWorkerConversationGovernanceService service() {
return new TeamWorkerConversationGovernanceService(taskMapper, runMapper, conversationMapper); return new TeamWorkerConversationGovernanceService(taskMapper, runMapper, conversationMapper,
authService, workspaceService);
} }
private static TeamTaskEntity task(Long id, Long runId, String conversationId) { private static TeamTaskEntity task(Long id, Long runId, String conversationId) {
@ -135,4 +171,11 @@ class TeamWorkerConversationGovernanceServiceTest {
conversation.setConversationKind(kind); conversation.setConversationKind(kind);
return conversation; return conversation;
} }
private static UserEntity user(long id, String role) {
UserEntity user = new UserEntity();
user.setId(id);
user.setRole(role);
return user;
}
} }

View File

@ -8,6 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import vip.mate.channel.web.ChatStreamTracker; import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList; import java.util.ArrayList;
@ -25,13 +26,14 @@ class ConversationControllerBatchDeleteTest {
@Mock private ConversationService conversationService; @Mock private ConversationService conversationService;
@Mock private ChatStreamTracker streamTracker; @Mock private ChatStreamTracker streamTracker;
@Mock private TeamWorkerConversationGovernanceService teamWorkerGovernanceService;
@Mock private Authentication authentication; @Mock private Authentication authentication;
private ConversationController controller; private ConversationController controller;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
controller = new ConversationController(conversationService, streamTracker); controller = new ConversationController(conversationService, streamTracker, teamWorkerGovernanceService);
when(authentication.getName()).thenReturn("alice"); when(authentication.getName()).thenReturn("alice");
} }

View File

@ -0,0 +1,65 @@
package vip.mate.workspace.conversation.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.common.result.R;
import vip.mate.team.service.TeamWorkerConversationGovernanceService;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ConversationControllerTeamWorkerTranscriptTest {
@Mock private ConversationService conversationService;
@Mock private ChatStreamTracker streamTracker;
@Mock private TeamWorkerConversationGovernanceService teamWorkerGovernanceService;
@Mock private Authentication authentication;
private ConversationController controller;
@BeforeEach
void setUp() {
controller = new ConversationController(conversationService, streamTracker, teamWorkerGovernanceService);
when(authentication.getName()).thenReturn("workspace-admin");
}
@Test
void listMessagesAllowsVerifiedTeamWorkerTranscriptForNonOwner() {
when(conversationService.isConversationOwner("worker-conversation", "workspace-admin"))
.thenReturn(false);
when(teamWorkerGovernanceService.canReadTranscript("worker-conversation", 77L, 501L,
"workspace-admin")).thenReturn(true);
when(conversationService.listMessageViews("worker-conversation")).thenReturn(List.of());
R<?> result = controller.listMessages("worker-conversation", null, null, 77L, 501L,
authentication);
assertEquals(200, result.getCode());
assertEquals(List.of(), result.getData());
}
@Test
void listMessagesRejectsNonOwnerWhenWorkerTranscriptIsNotVerified() {
when(conversationService.isConversationOwner("ordinary-conversation", "workspace-admin"))
.thenReturn(false);
when(teamWorkerGovernanceService.canReadTranscript("ordinary-conversation", 77L, 501L,
"workspace-admin")).thenReturn(false);
R<?> result = controller.listMessages("ordinary-conversation", null, null, 77L, 501L,
authentication);
assertEquals(403, result.getCode());
verify(conversationService, never()).listMessageViews("ordinary-conversation");
}
}

View File

@ -204,7 +204,7 @@ export const conversationApi = {
*/ */
page: (params: { page?: number; size?: number; keyword?: string }) => page: (params: { page?: number; size?: number; keyword?: string }) =>
http.get('/conversations/page', { params }), http.get('/conversations/page', { params }),
listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number }) => listMessages: (conversationId: string, params?: { beforeId?: number; limit?: number; runId?: string; taskId?: string }) =>
http.get(`/conversations/${encId(conversationId)}/messages`, { params }), http.get(`/conversations/${encId(conversationId)}/messages`, { params }),
getStatus: (conversationId: string) => getStatus: (conversationId: string) =>
http.get(`/conversations/${encId(conversationId)}/status`), http.get(`/conversations/${encId(conversationId)}/status`),

View File

@ -857,6 +857,14 @@ const workerRunContext = computed(() => workerGuard.context.value
?? readLegacyWorkerRouteContext(currentConversationId.value, route.query)) ?? readLegacyWorkerRouteContext(currentConversationId.value, route.query))
const workerConversationReadOnly = computed(() => workerGuard.readOnly.value) const workerConversationReadOnly = computed(() => workerGuard.readOnly.value)
function workerTranscriptMessageParams() {
const query = teamRunRouteQuery.value
return {
runId: query.teamRunId,
taskId: query.taskId,
}
}
// ============ ============ // ============ ============
const connectionStatusClass = computed(() => { const connectionStatusClass = computed(() => {
if (isGenerating.value) return 'status-streaming' if (isGenerating.value) return 'status-streaming'
@ -1678,7 +1686,7 @@ async function refreshCurrentConversationMessages(conversationId: string) {
if (isGenerating.value) return if (isGenerating.value) return
if (streamPhase.value === 'awaiting_approval') return if (streamPhase.value === 'awaiting_approval') return
try { try {
const res: any = await conversationApi.listMessages(conversationId) const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
// Stale guardawait // Stale guardawait
if (currentConversationId.value !== conversationId) return if (currentConversationId.value !== conversationId) return
// isGenerating await // isGenerating await
@ -1715,7 +1723,7 @@ async function hydrateStateFromRoute() {
currentConversationId.value = conversationId currentConversationId.value = conversationId
messages.value = [] messages.value = []
try { try {
const res: any = await conversationApi.listMessages(conversationId) const res: any = await conversationApi.listMessages(conversationId, workerTranscriptMessageParams())
if (currentConversationId.value !== conversationId) return if (currentConversationId.value !== conversationId) return
messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, true)) messages.value = extractMessages(res).messages.map((msg: Message) => normalizeMessage(msg, true))
} catch { } catch {
@ -1783,7 +1791,7 @@ async function selectConversation(conv: Conversation, routeAgentId = '') {
markConversationViewed(conv.conversationId, conv.lastActiveTime) markConversationViewed(conv.conversationId, conv.lastActiveTime)
const requestedConvId = conv.conversationId const requestedConvId = conv.conversationId
try { try {
const res: any = await conversationApi.listMessages(requestedConvId) const res: any = await conversationApi.listMessages(requestedConvId, workerTranscriptMessageParams())
// Stale guardawait // Stale guardawait
if (currentConversationId.value !== requestedConvId) return if (currentConversationId.value !== requestedConvId) return
// SSE // SSE