fix(server): prevent locked assets from leaking to partners (#28652)

* fix(server): prevent locked assets from leaking to partners

* fix tests
This commit is contained in:
Timon 2026-05-27 23:33:49 +02:00 committed by GitHub
parent a5ce3fc927
commit 26714f6bfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 35 additions and 12 deletions

View file

@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled');
}
const userIds = this.getUserIdsToSearch(auth);
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
@ -202,7 +202,11 @@ export class SearchService extends BaseService {
}
}
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,

View file

@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
}),
).rejects.toThrow(BadRequestException);
});
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
});
});

View file

@ -71,13 +71,14 @@ export class TimelineService extends BaseService {
}
if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
}
}

View file

@ -41,6 +41,13 @@ export const authStub = {
id: 'token-id',
} as AuthSession,
}),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({
user: authUser.admin,
sharedLink: {

View file

@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});