Skip to content

Commit 80d4282

Browse files
committed
feat(backend): enforce mission-level read access controls for GET /triggers and GET /triggers/:uuid endpoints
1 parent 9813bbf commit 80d4282

3 files changed

Lines changed: 59 additions & 9 deletions

File tree

backend/src/endpoints/trigger/trigger.controller.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,20 @@ export class TriggerController {
3232
@LoggedIn()
3333
@ApiOkResponse({ type: ActionTriggerDto, isArray: true })
3434
async findAll(
35+
@AddUser() auth: AuthHeader,
3536
@Query('missionUuid') missionUuid?: string,
3637
): Promise<ActionTriggerDto[]> {
37-
return this.triggerService.findAll(missionUuid);
38+
return this.triggerService.findAll(auth.user, missionUuid);
3839
}
3940

4041
@Get(':uuid')
4142
@LoggedIn()
4243
@ApiOkResponse({ type: ActionTriggerDto })
4344
async findOne(
4445
@ParameterUuid('uuid') uuid: string,
46+
@AddUser() auth: AuthHeader,
4547
): Promise<ActionTriggerDto> {
46-
return this.triggerService.findOne(uuid);
48+
return this.triggerService.findOne(uuid, auth.user);
4749
}
4850

4951
@Post()

backend/src/services/trigger.service.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { MissionGuardService } from '@/endpoints/auth/mission-guard.service';
12
import {
23
ActionTriggerDto,
34
CreateActionTriggerDto,
@@ -12,13 +13,16 @@ import {
1213
import { redis } from '@kleinkram/backend-common/consts';
1314
import { ActionDispatcherService } from '@kleinkram/backend-common/modules/action-dispatcher/action-dispatcher.service';
1415
import {
16+
AccessGroupRights,
1517
ActionTriggerSource,
1618
isValidCron,
1719
TriggerEvent,
1820
TriggerType,
21+
UserRole,
1922
} from '@kleinkram/shared';
2023
import {
2124
BadRequestException,
25+
ForbiddenException,
2226
Injectable,
2327
NotFoundException,
2428
OnModuleInit,
@@ -43,13 +47,28 @@ export class TriggerService implements OnModuleInit {
4347
@InjectRepository(MissionEntity)
4448
private missionRepository: Repository<MissionEntity>,
4549
private readonly actionDispatcher: ActionDispatcherService,
50+
private readonly missionGuardService: MissionGuardService,
4651
) {}
4752

4853
onModuleInit(): void {
4954
this.triggerQueue = new Queue('trigger-queue', { redis });
5055
}
5156

52-
async findAll(missionUuid?: string): Promise<ActionTriggerDto[]> {
57+
async findAll(
58+
user: UserEntity,
59+
missionUuid?: string,
60+
): Promise<ActionTriggerDto[]> {
61+
if (missionUuid && user.role !== UserRole.ADMIN) {
62+
const hasAccess = await this.missionGuardService.canAccessMission(
63+
user,
64+
missionUuid,
65+
AccessGroupRights.READ,
66+
);
67+
if (!hasAccess) {
68+
throw new ForbiddenException('Forbidden resource');
69+
}
70+
}
71+
5372
const query = this.triggerRepository
5473
.createQueryBuilder('trigger')
5574
.leftJoinAndSelect('trigger.template', 'template')
@@ -59,10 +78,31 @@ export class TriggerService implements OnModuleInit {
5978
query.where('trigger.missionUuid = :missionUuid', { missionUuid });
6079
}
6180
const entities = await query.getMany();
81+
82+
if (!missionUuid && user.role !== UserRole.ADMIN) {
83+
const allowedEntities: ActionTriggerEntity[] = [];
84+
for (const entity of entities) {
85+
if (entity.creatorUuid === user.uuid) {
86+
allowedEntities.push(entity);
87+
} else {
88+
const hasAccess =
89+
await this.missionGuardService.canAccessMission(
90+
user,
91+
entity.missionUuid,
92+
AccessGroupRights.READ,
93+
);
94+
if (hasAccess) {
95+
allowedEntities.push(entity);
96+
}
97+
}
98+
}
99+
return allowedEntities.map((entity) => this.toDto(entity));
100+
}
101+
62102
return entities.map((entity) => this.toDto(entity));
63103
}
64104

65-
async findOne(uuid: string): Promise<ActionTriggerDto> {
105+
async findOne(uuid: string, user: UserEntity): Promise<ActionTriggerDto> {
66106
const trigger = await this.triggerRepository.findOne({
67107
where: { uuid },
68108
relations: { template: true, mission: true, creator: true },
@@ -72,6 +112,17 @@ export class TriggerService implements OnModuleInit {
72112
throw new NotFoundException('Trigger not found');
73113
}
74114

115+
if (user.role !== UserRole.ADMIN && trigger.creatorUuid !== user.uuid) {
116+
const hasAccess = await this.missionGuardService.canAccessMission(
117+
user,
118+
trigger.missionUuid,
119+
AccessGroupRights.READ,
120+
);
121+
if (!hasAccess) {
122+
throw new ForbiddenException('Forbidden resource');
123+
}
124+
}
125+
75126
return this.toDto(trigger);
76127
}
77128

backend/tests/triggers/trigger-ownership.test.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ describe('Trigger Ownership API Tests', () => {
180180
expect(response.status).toBe(200);
181181
});
182182

183-
test('User B should be able to read User A trigger', async () => {
183+
test('User B should NOT be able to read User A trigger', async () => {
184184
const trigger = await createTrigger(userA, {
185185
name: 'User A Trigger',
186186
description: 'Owned by User A',
@@ -199,10 +199,7 @@ describe('Trigger Ownership API Tests', () => {
199199
},
200200
);
201201

202-
expect(response.status).toBe(200);
203-
const body = (await response.json()) as { uuid: string; name: string };
204-
expect(body.uuid).toBe(trigger.uuid);
205-
expect(body.name).toBe('User A Trigger');
202+
expect(response.status).toBe(403);
206203
});
207204

208205
test('Should return 404 for non-existent trigger', async () => {

0 commit comments

Comments
 (0)