fix(M2-005,M2-006): enforce user ownership at repo level for conversations and agents (#293)
Some checks failed
ci/woodpecker/push/ci Pipeline failed

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #293.
This commit is contained in:
2026-03-21 20:34:11 +00:00
committed by jason.woltje
parent cf51fd6749
commit ebf99d9ff7
5 changed files with 122 additions and 40 deletions

View File

@@ -57,11 +57,13 @@ function createBrain() {
describe('Resource ownership checks', () => {
it('forbids access to another user conversation', async () => {
const brain = createBrain();
brain.conversations.findById.mockResolvedValue({ id: 'conv-1', userId: 'user-2' });
// The repo enforces ownership via the WHERE clause; it returns undefined when the
// conversation does not belong to the requesting user.
brain.conversations.findById.mockResolvedValue(undefined);
const controller = new ConversationsController(brain as never);
await expect(controller.findOne('conv-1', { id: 'user-1' })).rejects.toBeInstanceOf(
ForbiddenException,
NotFoundException,
);
});

View File

@@ -62,7 +62,11 @@ export class AgentConfigsController {
if (!agent.isSystem && agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
const updated = await this.brain.agents.update(id, dto);
// Pass ownerId for user agents so the repo WHERE clause enforces ownership.
// For system agents (admin path) pass undefined so the WHERE matches only on id.
const ownerId = agent.isSystem ? undefined : user.id;
const updated = await this.brain.agents.update(id, dto, ownerId);
if (!updated) throw new NotFoundException('Agent not found');
return updated;
}
@@ -78,7 +82,8 @@ export class AgentConfigsController {
if (agent.ownerId !== user.id) {
throw new ForbiddenException('Agent does not belong to the current user');
}
const deleted = await this.brain.agents.remove(id);
// Pass ownerId so the repo WHERE clause enforces ownership at the DB level.
const deleted = await this.brain.agents.remove(id, user.id);
if (!deleted) throw new NotFoundException('Agent not found');
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
@@ -16,7 +17,6 @@ import type { Brain } from '@mosaic/brain';
import { BRAIN } from '../brain/brain.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { assertOwner } from '../auth/resource-ownership.js';
import {
CreateConversationDto,
UpdateConversationDto,
@@ -35,7 +35,9 @@ export class ConversationsController {
@Get(':id')
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.getOwnedConversation(id, user.id);
const conversation = await this.brain.conversations.findById(id, user.id);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
@Post()
@@ -53,8 +55,7 @@ export class ConversationsController {
@Body() dto: UpdateConversationDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedConversation(id, user.id);
const conversation = await this.brain.conversations.update(id, dto);
const conversation = await this.brain.conversations.update(id, user.id, dto);
if (!conversation) throw new NotFoundException('Conversation not found');
return conversation;
}
@@ -62,15 +63,16 @@ export class ConversationsController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedConversation(id, user.id);
const deleted = await this.brain.conversations.remove(id);
const deleted = await this.brain.conversations.remove(id, user.id);
if (!deleted) throw new NotFoundException('Conversation not found');
}
@Get(':id/messages')
async listMessages(@Param('id') id: string, @CurrentUser() user: { id: string }) {
await this.getOwnedConversation(id, user.id);
return this.brain.conversations.findMessages(id);
// Verify ownership explicitly to return a clear 404 rather than an empty list.
const conversation = await this.brain.conversations.findById(id, user.id);
if (!conversation) throw new NotFoundException('Conversation not found');
return this.brain.conversations.findMessages(id, user.id);
}
@Post(':id/messages')
@@ -79,19 +81,16 @@ export class ConversationsController {
@Body() dto: SendMessageDto,
@CurrentUser() user: { id: string },
) {
await this.getOwnedConversation(id, user.id);
return this.brain.conversations.addMessage({
conversationId: id,
role: dto.role,
content: dto.content,
metadata: dto.metadata,
});
}
private async getOwnedConversation(id: string, userId: string) {
const conversation = await this.brain.conversations.findById(id);
if (!conversation) throw new NotFoundException('Conversation not found');
assertOwner(conversation.userId, userId, 'Conversation');
return conversation;
const message = await this.brain.conversations.addMessage(
{
conversationId: id,
role: dto.role,
content: dto.content,
metadata: dto.metadata,
},
user.id,
);
if (!message) throw new ForbiddenException('Conversation not found or access denied');
return message;
}
}