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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:18:33 +02:00
1 parent 0ba8996e3a
commit eafe925d60
4 files changed
+95 -16

No files matched your search

@@ -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;
}
@@ -1,4 +1,8 @@
import { Controller, Get, Query } from '@nestjs/common'; 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 { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator'; import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
@@ -7,11 +11,18 @@ import { SearchService } from './search.service';
@ApiBearerAuth() @ApiBearerAuth()
@Controller('search') @Controller('search')
export class SearchController { export class SearchController {
constructor(private readonly searchService: SearchService) {} constructor(
private readonly searchService: SearchService,
private readonly permissionResolver: PermissionResolverService,
) {}
@RequirePermission('search:read') @RequirePermission('search:read')
@Get() @Get()
search(@Query('q') q?: string) { async search(
return this.searchService.search(q ?? ''); @Query() query: SearchQueryDto,
@CurrentUser() user: AuthenticatedUser,
) {
const permissions = await this.permissionResolver.getPermissions(user.id);
return this.searchService.search(query.q ?? '', permissions);
} }
} }
@@ -1,6 +1,14 @@
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
const ALL_READ: ReadonlySet<string> = new Set([
'apiaries:read',
'colonies:read',
'queens:read',
'tasks:read',
'honey-batches:read',
]);
describe('SearchService', () => { describe('SearchService', () => {
let service: SearchService; let service: SearchService;
let prisma: { let prisma: {
@@ -23,7 +31,7 @@ describe('SearchService', () => {
}); });
it('returns an empty array for a blank query', async () => { 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(results).toEqual([]);
expect(prisma.apiary.findMany).not.toHaveBeenCalled(); expect(prisma.apiary.findMany).not.toHaveBeenCalled();
}); });
@@ -36,7 +44,7 @@ describe('SearchService', () => {
{ id: 'apiary-1', name: 'Heimstand' }, { 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(results).toEqual(
expect.arrayContaining([ 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();
});
}); });
+51 -11
View File
@@ -1,5 +1,17 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; 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<T>(): Promise<T[]> {
return Promise.resolve([]);
}
export interface SearchResult { export interface SearchResult {
type: 'Apiary' | 'Colony' | 'Queen' | 'Task' | 'HoneyBatch'; type: 'Apiary' | 'Colony' | 'Queen' | 'Task' | 'HoneyBatch';
@@ -9,38 +21,66 @@ export interface SearchResult {
const RESULT_LIMIT = 10; 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<SearchResult['type'], string> = {
Apiary: 'apiaries:read',
Colony: 'colonies:read',
Queen: 'queens:read',
Task: 'tasks:read',
HoneyBatch: 'honey-batches:read',
};
@Injectable() @Injectable()
export class SearchService { export class SearchService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async search(q: string): Promise<SearchResult[]> { async search(
q: string,
permissions: ReadonlySet<string>,
): Promise<SearchResult[]> {
const term = q.trim(); const term = q.trim();
if (term.length === 0) { if (term.length === 0) {
return []; return [];
} }
const may = (type: SearchResult['type']) =>
permissions.has(READ_PERMISSION[type]);
const [apiaries, colonies, queens, tasks, honeyBatches] = await Promise.all( const [apiaries, colonies, queens, tasks, honeyBatches] = await Promise.all(
[ [
this.prisma.apiary.findMany({ may('Apiary')
? this.prisma.apiary.findMany({
where: { name: { contains: term, mode: 'insensitive' } }, where: { name: { contains: term, mode: 'insensitive' } },
take: RESULT_LIMIT, take: RESULT_LIMIT,
}), })
this.prisma.colony.findMany({ : skip<Apiary>(),
may('Colony')
? this.prisma.colony.findMany({
where: { code: { contains: term, mode: 'insensitive' } }, where: { code: { contains: term, mode: 'insensitive' } },
take: RESULT_LIMIT, take: RESULT_LIMIT,
}), })
this.prisma.queen.findMany({ : skip<Colony>(),
may('Queen')
? this.prisma.queen.findMany({
where: { code: { contains: term, mode: 'insensitive' } }, where: { code: { contains: term, mode: 'insensitive' } },
take: RESULT_LIMIT, take: RESULT_LIMIT,
}), })
this.prisma.task.findMany({ : skip<Queen>(),
may('Task')
? this.prisma.task.findMany({
where: { title: { contains: term, mode: 'insensitive' } }, where: { title: { contains: term, mode: 'insensitive' } },
take: RESULT_LIMIT, take: RESULT_LIMIT,
}), })
this.prisma.honeyBatch.findMany({ : skip<Task>(),
may('HoneyBatch')
? this.prisma.honeyBatch.findMany({
where: { batchNumber: { contains: term, mode: 'insensitive' } }, where: { batchNumber: { contains: term, mode: 'insensitive' } },
take: RESULT_LIMIT, take: RESULT_LIMIT,
}), })
: skip<HoneyBatch>(),
], ],
); );