Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat-context.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
90 changes: 90 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat-context.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
}

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<RolesService, 'resolvePermissions'>;
logger: Logger;
}): Promise<AssistantChatContext> {
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 };
}
44 changes: 20 additions & 24 deletions apps/api/src/assistant-chat/assistant-chat.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Header,
HttpException,
HttpStatus,
Post,
Expand Down Expand Up @@ -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' })
Expand All @@ -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')
Expand All @@ -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();
Expand Down Expand Up @@ -180,6 +172,7 @@ Important:
}

@Get('history')
@Header('Cache-Control', 'no-store')
@ApiOperation({
summary: 'Get assistant chat history',
description:
Expand All @@ -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,
Expand All @@ -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 },
Expand All @@ -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,
Expand Down
Loading
Loading