diff --git a/server/src/utils/search-filter.spec.ts b/server/src/utils/search-filter.spec.ts new file mode 100644 index 0000000000..98556d8f55 --- /dev/null +++ b/server/src/utils/search-filter.spec.ts @@ -0,0 +1,52 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { AssetVisibility } from 'src/enum'; +import { applyLockedVisibilityPolicy } from 'src/utils/search-filter'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { describe, expect, it } from 'vitest'; + +const { Locked, Timeline, Archive } = AssetVisibility; + +const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build(); +const unelevatedAuth = () => AuthFactory.from().session().build(); + +describe(applyLockedVisibilityPolicy.name, () => { + it('should let an elevated session query locked assets', () => { + const filter = { visibility: { eq: Locked } }; + expect(applyLockedVisibilityPolicy(elevatedAuth(), filter)).toBe(filter); + }); + + it('should reject an unelevated session when any operator permits locked', () => { + for (const visibility of [{ eq: Locked }, { ne: Timeline }, { in: [Locked, Timeline] }, { notIn: [Timeline] }]) { + expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), { visibility })).toThrow(UnauthorizedException); + } + }); + + it('should keep a filter whose top-level visibility excludes locked', () => { + for (const visibility of [{ eq: Timeline }, { ne: Locked }, { in: [Timeline, Archive] }, { notIn: [Locked] }]) { + const filter = { visibility }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter); + } + }); + + it('should let a safe top-level visibility decide over could-match branches', () => { + const filter = { visibility: { ne: Locked }, or: [{ visibility: { eq: Locked } }] }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter); + }); + + it('should reject a branch that permits locked when the top level has no visibility', () => { + const filter = { or: [{ city: { eq: 'Oslo' } }, { visibility: { in: [Locked, Timeline] } }] }; + expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toThrow(UnauthorizedException); + }); + + it('should otherwise inject visibility != locked without mutating the input', () => { + const filter = { city: { eq: 'Oslo' }, visibility: undefined }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toEqual({ + city: { eq: 'Oslo' }, + visibility: { ne: Locked }, + }); + expect(filter.visibility).toBeUndefined(); + + const branched = { or: [{ isFavorite: { eq: true } }, { visibility: { eq: Timeline } }] }; + expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: Locked }); + }); +}); diff --git a/server/src/utils/search-filter.ts b/server/src/utils/search-filter.ts new file mode 100644 index 0000000000..f1de995653 --- /dev/null +++ b/server/src/utils/search-filter.ts @@ -0,0 +1,47 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { SearchFilter, SearchFilterBranch } from 'src/dtos/search.dto'; +import { AssetVisibility } from 'src/enum'; +import { requireElevatedPermission } from 'src/utils/access'; + +type EnumField = 'type' | 'visibility'; +type EnumValue = NonNullable['eq']>; +type EnumCondition = { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] }; + +/** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */ +const canMatch = (condition: EnumCondition, value: EnumValue): boolean => + (condition.eq === undefined || condition.eq === value) && + (condition.ne === undefined || condition.ne !== value) && + (condition.in === undefined || condition.in.includes(value)) && + (condition.notIn === undefined || !condition.notIn.includes(value)); + +/** + * The conditions that decide which `field` values the filter can return. A top-level condition + * decides alone, otherwise each branch itself. + */ +const decidingConditions = (filter: SearchFilter, field: EnumField): EnumCondition[] => { + if (filter[field] !== undefined) { + return [filter[field]]; + } + + return (filter.or ?? []).map((branch) => branch[field]).filter((condition) => condition !== undefined); +}; + +/** + * Keeps locked assets out of search results unless the session is elevated: a filter that asks for + * them is rejected with 401, and any other filter gets `visibility != locked` ANDed in. + */ +export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => { + if (auth.session?.hasElevatedPermission) { + return filter; + } + + if (decidingConditions(filter, 'visibility').some((condition) => canMatch(condition, AssetVisibility.Locked))) { + requireElevatedPermission(auth); + } + + if (filter.visibility !== undefined) { + return filter; + } + + return { ...filter, visibility: { ne: AssetVisibility.Locked } }; +};