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:
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 { 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { SearchService } from './search.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', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<T>(): Promise<T[]> {
|
||||
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<SearchResult['type'], string> = {
|
||||
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<SearchResult[]> {
|
||||
async search(
|
||||
q: string,
|
||||
permissions: ReadonlySet<string>,
|
||||
): Promise<SearchResult[]> {
|
||||
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({
|
||||
may('Apiary')
|
||||
? this.prisma.apiary.findMany({
|
||||
where: { name: { contains: term, mode: 'insensitive' } },
|
||||
take: RESULT_LIMIT,
|
||||
}),
|
||||
this.prisma.colony.findMany({
|
||||
})
|
||||
: skip<Apiary>(),
|
||||
may('Colony')
|
||||
? this.prisma.colony.findMany({
|
||||
where: { code: { contains: term, mode: 'insensitive' } },
|
||||
take: RESULT_LIMIT,
|
||||
}),
|
||||
this.prisma.queen.findMany({
|
||||
})
|
||||
: skip<Colony>(),
|
||||
may('Queen')
|
||||
? this.prisma.queen.findMany({
|
||||
where: { code: { contains: term, mode: 'insensitive' } },
|
||||
take: RESULT_LIMIT,
|
||||
}),
|
||||
this.prisma.task.findMany({
|
||||
})
|
||||
: skip<Queen>(),
|
||||
may('Task')
|
||||
? this.prisma.task.findMany({
|
||||
where: { title: { contains: term, mode: 'insensitive' } },
|
||||
take: RESULT_LIMIT,
|
||||
}),
|
||||
this.prisma.honeyBatch.findMany({
|
||||
})
|
||||
: skip<Task>(),
|
||||
may('HoneyBatch')
|
||||
? this.prisma.honeyBatch.findMany({
|
||||
where: { batchNumber: { contains: term, mode: 'insensitive' } },
|
||||
take: RESULT_LIMIT,
|
||||
}),
|
||||
})
|
||||
: skip<HoneyBatch>(),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user