Skip to content
Open
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
33 changes: 33 additions & 0 deletions backend/src/endpoints/auth/auth-helper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ActionTriggerEntity } from '@kleinkram/backend-common/entities/action/action-trigger.entity';
import { FileEntity as File } from '@kleinkram/backend-common/entities/file/file.entity';
import { MissionEntity } from '@kleinkram/backend-common/entities/mission/mission.entity';
import { ProjectEntity } from '@kleinkram/backend-common/entities/project/project.entity';
Expand Down Expand Up @@ -160,6 +161,38 @@ export const addAccessConstraintsToFileQuery = (
return query;
};

export const addAccessConstraintsToTriggerQuery = (
query: SelectQueryBuilder<ActionTriggerEntity>,
userUUID: string,
): SelectQueryBuilder<ActionTriggerEntity> => {
const tok = uuidv4().replaceAll('-', '');
const missionUUIDQuery = missionAccessUUIDQuery(query, userUUID);
const projectUUIDQuery = projectAccessUUIDQuery(query, userUUID);
const userIsAdminSubQuery = getUserIsAdminSubQuery(query, userUUID);

const accessBracket = new Brackets((qb) => {
qb.where(`mission.uuid IN (${missionUUIDQuery.getQuery()})`);
qb.orWhere(`project.uuid IN (${projectUUIDQuery.getQuery()})`);
qb.orWhere(`trigger.creatorUuid = :creatorUserUUID_${tok}`);
});

query.andWhere(
new Brackets((qb) => {
qb.where(`EXISTS (${userIsAdminSubQuery.getQuery()})`);
qb.orWhere(accessBracket);
}),
);

query.setParameters({
...userIsAdminSubQuery.getParameters(),
...missionUUIDQuery.getParameters(),
...projectUUIDQuery.getParameters(),
[`creatorUserUUID_${tok}`]: userUUID,
});

return query;
};

export function addAccessConstraints(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
qb: SelectQueryBuilder<any>,
Expand Down
59 changes: 59 additions & 0 deletions backend/src/endpoints/auth/guards/action.guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { AccessGroupRights, ActionState, UserRole } from '@kleinkram/shared';
import {
BadRequestException,
ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
Expand Down Expand Up @@ -51,6 +53,63 @@ export class CanModifyTriggerGuard extends BaseGuard {
}
}

@Injectable()
export class CanReadTriggerGuard extends BaseGuard {
constructor(
@InjectRepository(ActionTriggerEntity)
private actionTriggerRepository: Repository<ActionTriggerEntity>,
private missionGuardService: MissionGuardService,
) {
super();
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const { user, apiKey, request } = await this.getUser(context);

const params = request.params as { uuid?: string } | undefined;
const triggerUUID = params?.uuid;

if (!triggerUUID) {
return false;
}

const trigger = await this.actionTriggerRepository.findOne({
where: { uuid: triggerUUID },
select: ['uuid', 'creatorUuid', 'missionUuid'],
});

if (!trigger) {
throw new NotFoundException('Trigger not found');
}

if (trigger.creatorUuid === user.uuid) {
return true;
}

if (user.role === UserRole.ADMIN) {
return true;
}

if (apiKey) {
return this.missionGuardService.canKeyAccessMission(
apiKey,
trigger.missionUuid,
AccessGroupRights.READ,
);
}

const hasAccess = await this.missionGuardService.canAccessMission(
user,
trigger.missionUuid,
AccessGroupRights.READ,
);
if (!hasAccess) {
throw new ForbiddenException('Forbidden resource');
}
return true;
}
}

@Injectable()
export class ReadActionGuard extends BaseGuard {
constructor(private actionGuardService: ActionGuardService) {
Expand Down
1 change: 1 addition & 0 deletions backend/src/endpoints/auth/guards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { FileAccessGuard, MoveFilesGuard } from './file.guards';
// Action guards
export {
CanModifyTriggerGuard,
CanReadTriggerGuard,
CancelActionGuard,
CreateActionGuard,
CreateActionsGuard,
Expand Down
13 changes: 13 additions & 0 deletions backend/src/endpoints/auth/roles.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CanEditGroupByGroupUuid,
CanModifyTriggerGuard,
CanReadManyMissionsGuard,
CanReadTriggerGuard,
CreateActionGuard,
CreateActionsGuard,
CreateGuard,
Expand Down Expand Up @@ -379,3 +380,15 @@ export function CanModifyTrigger() {
}),
);
}

export function CanReadTrigger() {
return applyDecorators(
SetMetadata('CanReadTrigger', true),
UseGuards(CanReadTriggerGuard),
ApiResponse({
status: 403,
type: ForbiddenException,
description: 'User does not have Read permissions on this trigger.',
}),
);
}
20 changes: 19 additions & 1 deletion backend/src/endpoints/trigger/trigger.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AddUser, AuthHeader } from '../auth/parameter-decorator';
import {
CanCreateInMissionByBody,
CanModifyTrigger,
CanReadTrigger,
LoggedIn,
} from '../auth/roles.decorator';

Expand All @@ -32,9 +33,26 @@ export class TriggerController {
@LoggedIn()
@ApiOkResponse({ type: ActionTriggerDto, isArray: true })
async findAll(
@AddUser() auth: AuthHeader,
@Query('missionUuid') missionUuid?: string,
): Promise<ActionTriggerDto[]> {
return this.triggerService.findAll(missionUuid);
const scopedMissionUuid = auth.apiKey
? auth.apiKey.mission.uuid
: missionUuid;
return this.triggerService.findAll(
auth.user,
scopedMissionUuid,
auth.apiKey,
);
}

@Get(':uuid')
@CanReadTrigger()
@ApiOkResponse({ type: ActionTriggerDto })
async findOne(
@ParameterUuid('uuid') uuid: string,
): Promise<ActionTriggerDto> {
return this.triggerService.findOne(uuid);
}
Comment thread
LevinCeglie marked this conversation as resolved.

@Post()
Expand Down
58 changes: 55 additions & 3 deletions backend/src/services/trigger.service.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { addAccessConstraintsToTriggerQuery } from '@/endpoints/auth/auth-helper';
import { MissionGuardService } from '@/endpoints/auth/mission-guard.service';
import {
ActionTriggerDto,
CreateActionTriggerDto,
Expand All @@ -6,19 +8,23 @@ import {
import {
ActionTemplateEntity,
ActionTriggerEntity,
ApiKeyEntity,
MissionEntity,
UserEntity,
} from '@kleinkram/backend-common';
import { redis } from '@kleinkram/backend-common/consts';
import { ActionDispatcherService } from '@kleinkram/backend-common/modules/action-dispatcher/action-dispatcher.service';
import {
AccessGroupRights,
ActionTriggerSource,
isValidCron,
TriggerEvent,
TriggerType,
UserRole,
} from '@kleinkram/shared';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
OnModuleInit,
Expand All @@ -43,25 +49,71 @@ export class TriggerService implements OnModuleInit {
@InjectRepository(MissionEntity)
private missionRepository: Repository<MissionEntity>,
private readonly actionDispatcher: ActionDispatcherService,
private readonly missionGuardService: MissionGuardService,
) {}

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

async findAll(missionUuid?: string): Promise<ActionTriggerDto[]> {
async findAll(
user: UserEntity,
missionUuid?: string,
apiKey?: ApiKeyEntity,
): Promise<ActionTriggerDto[]> {
if (missionUuid && user.role !== UserRole.ADMIN) {
const hasAccess = apiKey
? this.missionGuardService.canKeyAccessMission(
apiKey,
missionUuid,
AccessGroupRights.READ,
)
: await this.missionGuardService.canAccessMission(
user,
missionUuid,
AccessGroupRights.READ,
);
if (!hasAccess) {
throw new ForbiddenException('Forbidden resource');
}
}

const query = this.triggerRepository
.createQueryBuilder('trigger')
.leftJoinAndSelect('trigger.template', 'template')
.leftJoinAndSelect('trigger.creator', 'creator');
.leftJoinAndSelect('trigger.creator', 'creator')
.leftJoin('trigger.mission', 'mission')
.leftJoin('mission.project', 'project');

if (missionUuid) {
query.where('trigger.missionUuid = :missionUuid', { missionUuid });
query.andWhere('trigger.missionUuid = :missionUuid', {
missionUuid,
});
}

// API keys are scoped to their mission (verified above); skip user-level
// access constraints for them. Session users get SQL-level filtering.
if (user.role !== UserRole.ADMIN && !apiKey) {
addAccessConstraintsToTriggerQuery(query, user.uuid);
}

const entities = await query.getMany();
return entities.map((entity) => this.toDto(entity));
}

async findOne(uuid: string): Promise<ActionTriggerDto> {
const trigger = await this.triggerRepository.findOne({
where: { uuid },
relations: { template: true, creator: true },
});

if (!trigger) {
throw new NotFoundException('Trigger not found');
}

return this.toDto(trigger);
}

async create(
dto: CreateActionTriggerDto,
creator: UserEntity,
Expand Down
Loading
Loading