diff --git a/apps/api/src/assistant-chat/assistant-chat-context.spec.ts b/apps/api/src/assistant-chat/assistant-chat-context.spec.ts new file mode 100644 index 0000000000..0258b6de4d --- /dev/null +++ b/apps/api/src/assistant-chat/assistant-chat-context.spec.ts @@ -0,0 +1,160 @@ +jest.mock('@db', () => ({ + db: { + member: { + findFirst: jest.fn(), + }, + }, +})); + +import { + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Request } from 'express'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { resolveAssistantChatContext } from './assistant-chat-context'; + +const mockedDb = db as unknown as { member: { findFirst: jest.Mock } }; + +function makeAuth(overrides: Partial = {}): AuthContextType { + return { + organizationId: 'org_active', + authType: 'session', + isApiKey: false, + isPlatformAdmin: false, + userId: 'usr_1', + userRoles: ['admin'], + ...overrides, + }; +} + +function makeReq(orgHeader?: string): Request { + return { + headers: orgHeader ? { 'x-organization-id': orgHeader } : {}, + } as unknown as Request; +} + +describe('resolveAssistantChatContext', () => { + const logger = { log: jest.fn() } as unknown as Logger; + const rolesService = { resolvePermissions: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + rolesService.resolvePermissions.mockResolvedValue({ app: ['read'] }); + }); + + it('uses the session active org when no org header is sent', async () => { + const result = await resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq(), + rolesService, + logger, + }); + + expect(result.organizationId).toBe('org_active'); + expect(result.userId).toBe('usr_1'); + expect(mockedDb.member.findFirst).not.toHaveBeenCalled(); + expect(rolesService.resolvePermissions).toHaveBeenCalledWith('org_active', [ + 'admin', + ]); + }); + + it('uses the session active org when the header matches it (no membership re-check)', async () => { + const result = await resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq('org_active'), + rolesService, + logger, + }); + + expect(result.organizationId).toBe('org_active'); + expect(mockedDb.member.findFirst).not.toHaveBeenCalled(); + }); + + it('scopes to a different requested org after verifying active membership + app access', async () => { + mockedDb.member.findFirst.mockResolvedValue({ role: 'admin' }); + + const result = await resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq('org_other'), + rolesService, + logger, + }); + + expect(mockedDb.member.findFirst).toHaveBeenCalledWith({ + where: { + userId: 'usr_1', + organizationId: 'org_other', + deactivated: false, + }, + select: { role: true }, + }); + expect(rolesService.resolvePermissions).toHaveBeenCalledWith('org_other', [ + 'admin', + ]); + expect(result.organizationId).toBe('org_other'); + }); + + it('rejects a requested org the user is not a member of', async () => { + mockedDb.member.findFirst.mockResolvedValue(null); + + await expect( + resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq('org_not_mine'), + rolesService, + logger, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects a requested org where the member lacks app access', async () => { + mockedDb.member.findFirst.mockResolvedValue({ role: 'employee' }); + rolesService.resolvePermissions.mockResolvedValue({ policy: ['read'] }); + + await expect( + resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq('org_other'), + rolesService, + logger, + }), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('requires a user id', async () => { + await expect( + resolveAssistantChatContext({ + auth: makeAuth({ userId: undefined }), + req: makeReq(), + rolesService, + logger, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('requires an organization when neither header nor active org is present', async () => { + await expect( + resolveAssistantChatContext({ + auth: makeAuth({ organizationId: '' }), + req: makeReq(), + rolesService, + logger, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('ignores a blank org header and falls back to the active org', async () => { + const result = await resolveAssistantChatContext({ + auth: makeAuth(), + req: makeReq(' '), + rolesService, + logger, + }); + + expect(result.organizationId).toBe('org_active'); + expect(mockedDb.member.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/assistant-chat/assistant-chat-context.ts b/apps/api/src/assistant-chat/assistant-chat-context.ts new file mode 100644 index 0000000000..2da0ee5677 --- /dev/null +++ b/apps/api/src/assistant-chat/assistant-chat-context.ts @@ -0,0 +1,90 @@ +import { + BadRequestException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Request } from 'express'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import type { RolesService } from '../roles/roles.service'; + +export interface AssistantChatContext { + organizationId: string; + userId: string; + permissions: Record; +} + +function readRequestedOrgId(req: Request): string | undefined { + const raw = req.headers['x-organization-id']; + const value = Array.isArray(raw) ? raw[0] : raw; + return value?.trim() || undefined; +} + +/** + * Resolve the organization the assistant chat is operating in. + * + * The chat scopes its (per-org, per-user) storage by the org the client is + * actually viewing — sent via `X-Organization-Id` — NOT the session's ambient + * `activeOrganizationId`. The active org can lag the URL for multi-org users, + * which previously let one org's chat be read/written under another org's key. + * + * When the requested org is absent or matches the session's active org, the + * guards (HybridAuthGuard + PermissionGuard) already verified membership and + * app access. When it differs, re-verify active membership AND app access here + * before scoping any chat data to it. + */ +export async function resolveAssistantChatContext({ + auth, + req, + rolesService, + logger, +}: { + auth: AuthContextType; + req: Request; + rolesService: Pick; + logger: Logger; +}): Promise { + const userId = auth.userId; + if (!userId) { + throw new BadRequestException('User ID is required'); + } + + const requested = readRequestedOrgId(req); + + if (!requested || requested === auth.organizationId) { + if (!auth.organizationId) { + throw new BadRequestException('Organization ID is required'); + } + const permissions = await rolesService.resolvePermissions( + auth.organizationId, + auth.userRoles ?? [], + ); + return { organizationId: auth.organizationId, userId, permissions }; + } + + const member = await db.member.findFirst({ + where: { userId, organizationId: requested, deactivated: false }, + select: { role: true }, + }); + if (!member) { + throw new ForbiddenException( + 'You are not a member of the requested organization.', + ); + } + + const permissions = await rolesService.resolvePermissions( + requested, + member.role ? member.role.split(',') : [], + ); + if (!permissions.app?.includes('read')) { + throw new ForbiddenException( + 'Your role does not have app access in the requested organization.', + ); + } + + logger.log( + `Assistant chat scoped to requested org ${requested} ` + + `(session active org: ${auth.organizationId}) for user ${userId}`, + ); + return { organizationId: requested, userId, permissions }; +} diff --git a/apps/api/src/assistant-chat/assistant-chat.controller.ts b/apps/api/src/assistant-chat/assistant-chat.controller.ts index d06124b88c..ec6b0ef554 100644 --- a/apps/api/src/assistant-chat/assistant-chat.controller.ts +++ b/apps/api/src/assistant-chat/assistant-chat.controller.ts @@ -1,9 +1,9 @@ import { - BadRequestException, Body, Controller, Delete, Get, + Header, HttpException, HttpStatus, Post, @@ -40,6 +40,7 @@ import { buildTools } from './assistant-chat-tools'; import type { AssistantChatMessage } from './assistant-chat.types'; import { RolesService } from '../roles/roles.service'; import { ASSISTANT_OPENAI_PROVIDER_OPTIONS } from './openai-options'; +import { resolveAssistantChatContext } from './assistant-chat-context'; @ApiTags('Assistant Chat') @Controller({ path: 'assistant-chat', version: '1' }) @@ -54,19 +55,13 @@ export class AssistantChatController { private readonly rolesService: RolesService, ) {} - private getUserScopedContext(auth: AuthContextType): { - organizationId: string; - userId: string; - } { - if (!auth.organizationId) { - throw new BadRequestException('Organization ID is required'); - } - - if (!auth.userId) { - throw new BadRequestException('User ID is required'); - } - - return { organizationId: auth.organizationId, userId: auth.userId }; + private resolveContext(auth: AuthContextType, req: Request) { + return resolveAssistantChatContext({ + auth, + req, + rolesService: this.rolesService, + logger: this.logger, + }); } @Post('completions') @@ -91,17 +86,14 @@ export class AssistantChatController { return; } - const { organizationId, userId } = this.getUserScopedContext(auth); + const { organizationId, userId, permissions } = await this.resolveContext( + auth, + req, + ); const body = req.body as { messages?: UIMessage[] }; const messages = body?.messages ?? []; - const userRoles = auth.userRoles ?? []; - const permissions = await this.rolesService.resolvePermissions( - organizationId, - userRoles, - ); - const tools = buildTools({ organizationId, userId, permissions }); const nowIso = new Date().toISOString(); @@ -180,6 +172,7 @@ Important: } @Get('history') + @Header('Cache-Control', 'no-store') @ApiOperation({ summary: 'Get assistant chat history', description: @@ -197,8 +190,9 @@ Important: }) async getHistory( @AuthContext() auth: AuthContextType, + @Req() req: Request, ): Promise<{ messages: AssistantChatMessage[] }> { - const { organizationId, userId } = this.getUserScopedContext(auth); + const { organizationId, userId } = await this.resolveContext(auth, req); const messages = await this.assistantChatService.getHistory({ organizationId, @@ -217,9 +211,10 @@ Important: }) async saveHistory( @AuthContext() auth: AuthContextType, + @Req() req: Request, @Body() dto: SaveAssistantChatHistoryDto, ): Promise<{ success: true }> { - const { organizationId, userId } = this.getUserScopedContext(auth); + const { organizationId, userId } = await this.resolveContext(auth, req); await this.assistantChatService.saveHistory( { organizationId, userId }, @@ -237,8 +232,9 @@ Important: }) async clearHistory( @AuthContext() auth: AuthContextType, + @Req() req: Request, ): Promise<{ success: true }> { - const { organizationId, userId } = this.getUserScopedContext(auth); + const { organizationId, userId } = await this.resolveContext(auth, req); await this.assistantChatService.clearHistory({ organizationId, diff --git a/apps/app/src/components/ai/chat.tsx b/apps/app/src/components/ai/chat.tsx index fd3e9d9611..4518197b42 100644 --- a/apps/app/src/components/ai/chat.tsx +++ b/apps/app/src/components/ai/chat.tsx @@ -126,6 +126,14 @@ export default function Chat() { const transport = new DefaultChatTransport({ api: `${API_URL}/v1/assistant-chat/completions`, credentials: 'include', + // Scope the AI (and its org-data tools) to the org the user is viewing, + // not the session's ambient active org which can lag for multi-org users. + // Read the ref at request time so a stale transport closure can't send the + // wrong org after the user switches. + headers: (): Record => { + const orgId = resolvedOrganizationIdRef.current; + return orgId ? { 'X-Organization-Id': orgId } : {}; + }, }); const { messages, sendMessage, error, status, stop, setMessages } = useChat({ @@ -153,6 +161,7 @@ export default function Chat() { void (async () => { const res = await apiClient.get<{ messages: AssistantStoredMessage[] }>( '/v1/assistant-chat/history', + orgIdAtStart, ); if (res.error || res.status !== 200) { @@ -225,12 +234,14 @@ export default function Chat() { } const delayMs = isLoading ? 300 : 0; + const orgForSave = resolvedOrganizationId; const timeout = window.setTimeout(() => { void apiClient.call( '/v1/assistant-chat/history', { method: 'PUT', body: JSON.stringify({ messages: storedMessages }), + organizationId: orgForSave, }, ); }, delayMs); @@ -246,11 +257,16 @@ export default function Chat() { const snapshot = latestSnapshotRef.current; if (!snapshot || snapshot.messages.length === 0) return; + // Persist under the org the snapshot belongs to — NOT the current org. + // On an org switch this cleanup fires after the URL/active org has already + // moved on, so keying by the snapshot's org prevents one org's chat from + // being written into another org's history. void apiClient.call( '/v1/assistant-chat/history', { method: 'PUT', body: JSON.stringify({ messages: snapshot.messages }), + organizationId: snapshot.organizationId, keepalive: true, }, ); @@ -269,7 +285,10 @@ export default function Chat() { disabled={isLoading || messages.length === 0 || !resolvedOrganizationId || !userId} onClick={() => { if (!resolvedOrganizationId || !userId) return; - void apiClient.delete('/v1/assistant-chat/history'); + void apiClient.delete( + '/v1/assistant-chat/history', + resolvedOrganizationId, + ); setMessages([]); setInput(''); }}