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
4 changes: 4 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import { AlertsModule } from './modules/alerts/alerts.module';

import { ReportsModule } from '../../../src/modules/reports/reports.module';
import { ProfileModule } from './modules/profile/profile.module';
import { PrismaModule } from './database/prisma.module';
import { IncidentsModule } from './modules/incidents/incidents.module';

@Module({
imports: [
DatabaseModule,
PrismaModule,
HealthModule,
NotificationsModule,
ReportingModule,
Expand All @@ -30,6 +33,7 @@ import { ProfileModule } from './modules/profile/profile.module';
NotesModule,
AlertsModule,
ProfileModule,
IncidentsModule,
],
controllers: [AppController],
})
Expand Down
10 changes: 10 additions & 0 deletions apps/backend/src/common/decorators/current-user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';

export const CurrentUser = createParamDecorator(
(data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user: AuthenticatedUser = request.user;
return data ? user?.[data] : user;
},
);
5 changes: 5 additions & 0 deletions apps/backend/src/common/decorators/roles.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
5 changes: 5 additions & 0 deletions apps/backend/src/common/enums/role.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum Role {
ADMIN = 'admin',
ANALYST = 'analyst',
VIEWER = 'viewer',
}
51 changes: 51 additions & 0 deletions apps/backend/src/common/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';

interface JwtPayload {
sub: string;
email?: string;
name?: string;
roles?: string[];
}

@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);

if (!token) {
throw new UnauthorizedException('Missing bearer token');
}

try {
const payload = await this.jwtService.verifyAsync<JwtPayload>(token, {
secret: process.env.JWT_SECRET,
});

const user: AuthenticatedUser = {
userId: payload.sub,
email: payload.email,
name: payload.name,
roles: (payload.roles ?? []) as AuthenticatedUser['roles'],
};

request.user = user;
return true;
} catch {
throw new UnauthorizedException('Invalid or expired token');
}
}

private extractToken(request: {
headers: Record<string, string | undefined>;
}): string | undefined {
const header = request.headers['authorization'];
if (!header) return undefined;
const [type, token] = header.split(' ');
return type === 'Bearer' ? token : undefined;
}
}
26 changes: 26 additions & 0 deletions apps/backend/src/common/guards/roles.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../enums/role.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';
import { AuthenticatedUser } from '../interfaces/authenticated-user.interface';

@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);

if (!requiredRoles || requiredRoles.length === 0) {
return true;
}

const request = context.switchToHttp().getRequest();
const user: AuthenticatedUser | undefined = request.user;

return !!user && requiredRoles.some(role => user.roles?.includes(role));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Role } from '../enums/role.enum';

export interface AuthenticatedUser {
userId: string;
email?: string;
name?: string;
roles: Role[];
}
9 changes: 9 additions & 0 deletions apps/backend/src/database/prisma.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
13 changes: 13 additions & 0 deletions apps/backend/src/database/prisma.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}

async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
19 changes: 19 additions & 0 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);

const swaggerConfig = new DocumentBuilder()
.setTitle('Sentinel API')
.setDescription('Sentinel security monitoring & incident response API')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document);

const port = process.env.PORT ?? 3000;
await app.listen(port);
}
Expand Down
126 changes: 126 additions & 0 deletions apps/backend/src/modules/incidents/controllers/incidents.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
import { Roles } from '../../../common/decorators/roles.decorator';
import { Role } from '../../../common/enums/role.enum';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
import { RolesGuard } from '../../../common/guards/roles.guard';
import { AuthenticatedUser } from '../../../common/interfaces/authenticated-user.interface';
import { AssignOwnerDto } from '../dto/assign-owner.dto';
import { CreateIncidentDto } from '../dto/create-incident.dto';
import { QueryIncidentsDto } from '../dto/query-incidents.dto';
import { UpdateIncidentDto } from '../dto/update-incident.dto';
import { UpdatePriorityDto } from '../dto/update-priority.dto';
import { UpdateSeverityDto } from '../dto/update-severity.dto';
import { UpdateStatusDto } from '../dto/update-status.dto';
import { IncidentEntity } from '../entities/incident.entity';
import { IncidentAssignmentService } from '../services/incident-assignment.service';
import { IncidentStatusService } from '../services/incident-status.service';
import { IncidentsService } from '../services/incidents.service';

@ApiTags('incidents')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('incidents')
export class IncidentsController {
constructor(
private readonly incidentsService: IncidentsService,
private readonly statusService: IncidentStatusService,
private readonly assignmentService: IncidentAssignmentService,
) {}

@Post()
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Create a new incident' })
@ApiResponse({ status: 201, type: IncidentEntity })
create(@Body() dto: CreateIncidentDto, @CurrentUser() user: AuthenticatedUser) {
return this.incidentsService.create(dto, user);
}

@Get()
@Roles(Role.ADMIN, Role.ANALYST, Role.VIEWER)
@ApiOperation({ summary: 'List incidents with pagination, filtering, sorting and search' })
findAll(@Query() query: QueryIncidentsDto) {
return this.incidentsService.findAll(query);
}

@Get(':id')
@Roles(Role.ADMIN, Role.ANALYST, Role.VIEWER)
@ApiOperation({ summary: 'Retrieve full incident details, including audit trail' })
@ApiResponse({ status: 200, type: IncidentEntity })
findOne(@Param('id') id: string) {
return this.incidentsService.findOne(id);
}

@Patch(':id')
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Update incident fields (title, description, category, tags, etc.)' })
update(
@Param('id') id: string,
@Body() dto: UpdateIncidentDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.incidentsService.update(id, dto, user);
}

@Patch(':id/status')
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Transition incident lifecycle status' })
updateStatus(
@Param('id') id: string,
@Body() dto: UpdateStatusDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.statusService.transition(id, dto.status, user, dto.reason);
}

@Patch(':id/assign')
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Assign, reassign, or remove the incident owner' })
assign(
@Param('id') id: string,
@Body() dto: AssignOwnerDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.assignmentService.assign(id, dto.assignedUserId, user);
}

@Patch(':id/severity')
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Update incident severity' })
updateSeverity(
@Param('id') id: string,
@Body() dto: UpdateSeverityDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.incidentsService.updateSeverity(id, dto.severity, user);
}

@Patch(':id/priority')
@Roles(Role.ADMIN, Role.ANALYST)
@ApiOperation({ summary: 'Update incident priority' })
updatePriority(
@Param('id') id: string,
@Body() dto: UpdatePriorityDto,
@CurrentUser() user: AuthenticatedUser,
) {
return this.incidentsService.updatePriority(id, dto.priority, user);
}

@Delete(':id')
@Roles(Role.ADMIN)
@ApiOperation({ summary: 'Archive (soft delete) an incident' })
archive(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) {
return this.incidentsService.archive(id, user);
}
}
12 changes: 12 additions & 0 deletions apps/backend/src/modules/incidents/dto/assign-owner.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';

export class AssignOwnerDto {
@ApiPropertyOptional({
description: 'User ID to assign as owner. Omit or pass null to remove the current assignee.',
nullable: true,
})
@IsOptional()
@IsString()
assignedUserId?: string | null;
}
Loading
Loading