fix(gateway): security hardening — auth guards, ownership checks, validation, rate limiting
This commit is contained in:
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
import type { Brain } from '@mosaic/brain';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import type { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { assertOwner } from '../auth/resource-ownership.js';
|
||||
import { CreateTaskDto, UpdateTaskDto } from './tasks.dto.js';
|
||||
|
||||
@Controller('api/tasks')
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -39,10 +42,8 @@ export class TasksController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string) {
|
||||
const task = await this.brain.tasks.findById(id);
|
||||
if (!task) throw new NotFoundException('Task not found');
|
||||
return task;
|
||||
async findOne(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
return this.getOwnedTask(id, user.id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -61,7 +62,18 @@ export class TasksController {
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateTaskDto) {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateTaskDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
await this.getOwnedTask(id, user.id);
|
||||
if (dto.projectId) {
|
||||
await this.getOwnedProject(dto.projectId, user.id, 'Task');
|
||||
}
|
||||
if (dto.missionId) {
|
||||
await this.getOwnedMission(dto.missionId, user.id, 'Task');
|
||||
}
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
...dto,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : dto.dueDate === null ? null : undefined,
|
||||
@@ -72,8 +84,46 @@ export class TasksController {
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@Param('id') id: string) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
|
||||
await this.getOwnedTask(id, user.id);
|
||||
const deleted = await this.brain.tasks.remove(id);
|
||||
if (!deleted) throw new NotFoundException('Task not found');
|
||||
}
|
||||
|
||||
private async getOwnedTask(id: string, userId: string) {
|
||||
const task = await this.brain.tasks.findById(id);
|
||||
if (!task) throw new NotFoundException('Task not found');
|
||||
|
||||
if (task.projectId) {
|
||||
await this.getOwnedProject(task.projectId, userId, 'Task');
|
||||
return task;
|
||||
}
|
||||
|
||||
if (task.missionId) {
|
||||
await this.getOwnedMission(task.missionId, userId, 'Task');
|
||||
return task;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Task does not belong to the current user');
|
||||
}
|
||||
|
||||
private async getOwnedMission(missionId: string, userId: string, resourceName: string) {
|
||||
const mission = await this.brain.missions.findById(missionId);
|
||||
if (!mission?.projectId) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
await this.getOwnedProject(mission.projectId, userId, resourceName);
|
||||
return mission;
|
||||
}
|
||||
|
||||
private async getOwnedProject(projectId: string, userId: string, resourceName: string) {
|
||||
const project = await this.brain.projects.findById(projectId);
|
||||
if (!project) {
|
||||
throw new ForbiddenException(`${resourceName} does not belong to the current user`);
|
||||
}
|
||||
|
||||
assertOwner(project.ownerId, userId, resourceName);
|
||||
return project;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,103 @@
|
||||
export interface CreateTaskDto {
|
||||
title: string;
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsISO8601,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
const taskStatuses = ['not-started', 'in-progress', 'blocked', 'done', 'cancelled'] as const;
|
||||
const taskPriorities = ['critical', 'high', 'medium', 'low'] as const;
|
||||
|
||||
export class CreateTaskDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskPriorities)
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
missionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
assignee?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTaskDto {
|
||||
export class UpdateTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10_000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskStatuses)
|
||||
status?: 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(taskPriorities)
|
||||
priority?: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
projectId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
missionId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
assignee?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
tags?: string[] | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
dueDate?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user