From eafe925d60f1807e96bcafc14a9074f138dcb01d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 1 Oct 2026 07:18:33 +0200 Subject: [PATCH] fix(search): only search record kinds the caller may read search:read alone returned apiary names, colony and queen codes, task titles and batch numbers to roles without the matching read permission. Each kind of result is now searched only when apiaries:read, colonies:read, queens:read, tasks:read or honey-batches:read is held. The search term is validated (string, at most 100 characters). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/search/dto/search-query.dto.ts | 10 +++ .../src/search/search.controller.ts | 17 +++- .../src/search/search.service.spec.ts | 22 ++++- ApiCore-Backend/src/search/search.service.ts | 82 ++++++++++++++----- 4 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 ApiCore-Backend/src/search/dto/search-query.dto.ts 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(), ], );