diff --git a/ApiCore-Backend/src/search/dto/search-query.dto.ts b/ApiCore-Backend/src/search/dto/search-query.dto.ts new file mode 100644 index 0000000..b896a71 --- /dev/null +++ b/ApiCore-Backend/src/search/dto/search-query.dto.ts @@ -0,0 +1,10 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SearchQueryDto { + @ApiPropertyOptional({ description: 'Suchbegriff' }) + @IsOptional() + @IsString() + @MaxLength(100) + q?: string; +} diff --git a/ApiCore-Backend/src/search/search.controller.ts b/ApiCore-Backend/src/search/search.controller.ts index d2ffd7f..3e2b19b 100644 --- a/ApiCore-Backend/src/search/search.controller.ts +++ b/ApiCore-Backend/src/search/search.controller.ts @@ -1,4 +1,8 @@ import { Controller, Get, Query } from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/types/authenticated-user'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; +import { SearchQueryDto } from './dto/search-query.dto'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { SearchService } from './search.service'; @@ -7,11 +11,18 @@ import { SearchService } from './search.service'; @ApiBearerAuth() @Controller('search') export class SearchController { - constructor(private readonly searchService: SearchService) {} + constructor( + private readonly searchService: SearchService, + private readonly permissionResolver: PermissionResolverService, + ) {} @RequirePermission('search:read') @Get() - search(@Query('q') q?: string) { - return this.searchService.search(q ?? ''); + async search( + @Query() query: SearchQueryDto, + @CurrentUser() user: AuthenticatedUser, + ) { + const permissions = await this.permissionResolver.getPermissions(user.id); + return this.searchService.search(query.q ?? '', permissions); } } diff --git a/ApiCore-Backend/src/search/search.service.spec.ts b/ApiCore-Backend/src/search/search.service.spec.ts index cf1bf23..a57b416 100644 --- a/ApiCore-Backend/src/search/search.service.spec.ts +++ b/ApiCore-Backend/src/search/search.service.spec.ts @@ -1,6 +1,14 @@ import { SearchService } from './search.service'; import { PrismaService } from '../prisma/prisma.service'; +const ALL_READ: ReadonlySet = new Set([ + 'apiaries:read', + 'colonies:read', + 'queens:read', + 'tasks:read', + 'honey-batches:read', +]); + describe('SearchService', () => { let service: SearchService; let prisma: { @@ -23,7 +31,7 @@ describe('SearchService', () => { }); it('returns an empty array for a blank query', async () => { - const results = await service.search(' '); + const results = await service.search(' ', ALL_READ); expect(results).toEqual([]); expect(prisma.apiary.findMany).not.toHaveBeenCalled(); }); @@ -36,7 +44,7 @@ describe('SearchService', () => { { id: 'apiary-1', name: 'Heimstand' }, ]); - const results = await service.search('V-001'); + const results = await service.search('V-001', ALL_READ); expect(results).toEqual( expect.arrayContaining([ @@ -45,4 +53,14 @@ describe('SearchService', () => { ]), ); }); + + it('searches only the kinds of records the caller may read', async () => { + await service.search('V-001', new Set(['colonies:read'])); + + expect(prisma.colony.findMany).toHaveBeenCalled(); + expect(prisma.apiary.findMany).not.toHaveBeenCalled(); + expect(prisma.queen.findMany).not.toHaveBeenCalled(); + expect(prisma.task.findMany).not.toHaveBeenCalled(); + expect(prisma.honeyBatch.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/ApiCore-Backend/src/search/search.service.ts b/ApiCore-Backend/src/search/search.service.ts index 345d988..7943453 100644 --- a/ApiCore-Backend/src/search/search.service.ts +++ b/ApiCore-Backend/src/search/search.service.ts @@ -1,5 +1,17 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { + Apiary, + Colony, + HoneyBatch, + Queen, + Task, +} from '../../generated/prisma'; + +/** No query for a kind of record the caller may not read. */ +function skip(): Promise { + return Promise.resolve([]); +} export interface SearchResult { type: 'Apiary' | 'Colony' | 'Queen' | 'Task' | 'HoneyBatch'; @@ -9,38 +21,66 @@ export interface SearchResult { const RESULT_LIMIT = 10; +/** + * search:read alone must not reveal records the caller may not open: each + * kind of result is only searched when the matching read permission is + * held, so the search box is never a way around the role's limits. + */ +const READ_PERMISSION: Record = { + Apiary: 'apiaries:read', + Colony: 'colonies:read', + Queen: 'queens:read', + Task: 'tasks:read', + HoneyBatch: 'honey-batches:read', +}; + @Injectable() export class SearchService { constructor(private readonly prisma: PrismaService) {} - async search(q: string): Promise { + async search( + q: string, + permissions: ReadonlySet, + ): Promise { const term = q.trim(); if (term.length === 0) { return []; } + const may = (type: SearchResult['type']) => + permissions.has(READ_PERMISSION[type]); const [apiaries, colonies, queens, tasks, honeyBatches] = await Promise.all( [ - this.prisma.apiary.findMany({ - where: { name: { contains: term, mode: 'insensitive' } }, - take: RESULT_LIMIT, - }), - this.prisma.colony.findMany({ - where: { code: { contains: term, mode: 'insensitive' } }, - take: RESULT_LIMIT, - }), - this.prisma.queen.findMany({ - where: { code: { contains: term, mode: 'insensitive' } }, - take: RESULT_LIMIT, - }), - this.prisma.task.findMany({ - where: { title: { contains: term, mode: 'insensitive' } }, - take: RESULT_LIMIT, - }), - this.prisma.honeyBatch.findMany({ - where: { batchNumber: { contains: term, mode: 'insensitive' } }, - take: RESULT_LIMIT, - }), + may('Apiary') + ? this.prisma.apiary.findMany({ + where: { name: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }) + : skip(), + may('Colony') + ? this.prisma.colony.findMany({ + where: { code: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }) + : skip(), + may('Queen') + ? this.prisma.queen.findMany({ + where: { code: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }) + : skip(), + may('Task') + ? this.prisma.task.findMany({ + where: { title: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }) + : skip(), + may('HoneyBatch') + ? this.prisma.honeyBatch.findMany({ + where: { batchNumber: { contains: term, mode: 'insensitive' } }, + take: RESULT_LIMIT, + }) + : skip(), ], );