1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

chore(server): Use access core for person permissions (#4138)

* use access core for all person methods

* minor fixes, feedback

* reorder assignments

* remove unnecessary permission requirement

* unify naming of tests

* reorder variables
This commit is contained in:
Daniel Dietzler 2023-09-18 23:22:44 +02:00 committed by GitHub
parent dda735ec51
commit 1a633f3fca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 171 additions and 29 deletions

View File

@ -23,6 +23,10 @@ export enum Permission {
LIBRARY_READ = 'library.read', LIBRARY_READ = 'library.read',
LIBRARY_DOWNLOAD = 'library.download', LIBRARY_DOWNLOAD = 'library.download',
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
} }
export class AccessCore { export class AccessCore {
@ -167,6 +171,15 @@ export class AccessCore {
case Permission.LIBRARY_DOWNLOAD: case Permission.LIBRARY_DOWNLOAD:
return authUser.id === id; return authUser.id === id;
case Permission.PERSON_READ:
return this.repository.person.hasOwnerAccess(authUser.id, id);
case Permission.PERSON_WRITE:
return this.repository.person.hasOwnerAccess(authUser.id, id);
case Permission.PERSON_MERGE:
return this.repository.person.hasOwnerAccess(authUser.id, id);
default: default:
return false; return false;
} }

View File

@ -17,4 +17,8 @@ export interface IAccessRepository {
library: { library: {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>; hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
}; };
person: {
hasOwnerAccess(userId: string, personId: string): Promise<boolean>;
};
} }

View File

@ -17,9 +17,9 @@ export interface IPersonRepository {
getAllWithoutThumbnail(): Promise<PersonEntity[]>; getAllWithoutThumbnail(): Promise<PersonEntity[]>;
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>; getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
getAllWithoutFaces(): Promise<PersonEntity[]>; getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(userId: string, personId: string): Promise<PersonEntity | null>; getById(personId: string): Promise<PersonEntity | null>;
getAssets(userId: string, personId: string): Promise<AssetEntity[]>; getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>; prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
reassignFaces(data: UpdateFacesData): Promise<number>; reassignFaces(data: UpdateFacesData): Promise<number>;

View File

@ -1,8 +1,10 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { import {
IAccessRepositoryMock,
assetStub, assetStub,
authStub, authStub,
faceStub, faceStub,
newAccessRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newPersonRepositoryMock, newPersonRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
@ -26,18 +28,20 @@ const responseDto: PersonResponseDto = {
}; };
describe(PersonService.name, () => { describe(PersonService.name, () => {
let sut: PersonService; let accessMock: IAccessRepositoryMock;
let personMock: jest.Mocked<IPersonRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>; let configMock: jest.Mocked<ISystemConfigRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let sut: PersonService;
beforeEach(async () => { beforeEach(async () => {
accessMock = newAccessRepositoryMock();
personMock = newPersonRepositoryMock(); personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
sut = new PersonService(personMock, configMock, storageMock, jobMock); sut = new PersonService(accessMock, personMock, configMock, storageMock, jobMock);
}); });
it('should be defined', () => { it('should be defined', () => {
@ -93,74 +97,124 @@ describe(PersonService.name, () => {
}); });
describe('getById', () => { describe('getById', () => {
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.withName);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should throw a bad request when person is not found', async () => { it('should throw a bad request when person is not found', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should get a person by id', async () => { it('should get a person by id', async () => {
personMock.getById.mockResolvedValue(personStub.withName); personMock.getById.mockResolvedValue(personStub.withName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto); await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
describe('getThumbnail', () => { describe('getThumbnail', () => {
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(storageMock.createReadStream).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should throw an error when personId is invalid', async () => { it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
expect(storageMock.createReadStream).not.toHaveBeenCalled(); expect(storageMock.createReadStream).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should throw an error when person has no thumbnail', async () => { it('should throw an error when person has no thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noThumbnail); personMock.getById.mockResolvedValue(personStub.noThumbnail);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException); await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
expect(storageMock.createReadStream).not.toHaveBeenCalled(); expect(storageMock.createReadStream).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should serve the thumbnail', async () => { it('should serve the thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noName); personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await sut.getThumbnail(authStub.admin, 'person-1'); await sut.getThumbnail(authStub.admin, 'person-1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
describe('getAssets', () => { describe('getAssets', () => {
it('should require person.read permission', async () => {
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(personMock.getAssets).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it("should return a person's assets", async () => { it("should return a person's assets", async () => {
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]); personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await sut.getAssets(authStub.admin, 'person-1'); await sut.getAssets(authStub.admin, 'person-1');
expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1'); expect(personMock.getAssets).toHaveBeenCalledWith('person-1');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
describe('update', () => { describe('update', () => {
it('should throw an error when personId is invalid', async () => { it('should require person.write permission', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf( await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(personMock.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it("should update a person's name", async () => { it("should update a person's name", async () => {
personMock.getById.mockResolvedValue(personStub.noName); personMock.getById.mockResolvedValue(personStub.noName);
personMock.update.mockResolvedValue(personStub.withName); personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto); await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET, name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetStub.image.id] }, data: { ids: [assetStub.image.id] },
}); });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it("should update a person's date of birth", async () => { it("should update a person's date of birth", async () => {
personMock.getById.mockResolvedValue(personStub.noBirthDate); personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue(personStub.withBirthDate); personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: 'person-1', id: 'person-1',
@ -170,35 +224,39 @@ describe(PersonService.name, () => {
isHidden: false, isHidden: false,
}); });
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should update a person visibility', async () => { it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden); personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName); personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]); personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET, name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetStub.image.id] }, data: { ids: [assetStub.image.id] },
}); });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it("should update a person's thumbnailPath", async () => { it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName); personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1); personMock.getFaceById.mockResolvedValue(faceStub.face1);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect( await expect(
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }), sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
).resolves.toEqual(responseDto); ).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.getFaceById).toHaveBeenCalledWith({ expect(personMock.getFaceById).toHaveBeenCalledWith({
assetId: faceStub.face1.assetId, assetId: faceStub.face1.assetId,
personId: 'person-1', personId: 'person-1',
@ -218,25 +276,31 @@ describe(PersonService.name, () => {
imageWidth: faceStub.face1.imageWidth, imageWidth: faceStub.face1.imageWidth,
}, },
}); });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should throw an error when the face feature assetId is invalid', async () => { it('should throw an error when the face feature assetId is invalid', async () => {
personMock.getById.mockResolvedValue(personStub.withName); personMock.getById.mockResolvedValue(personStub.withName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow( await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
BadRequestException, BadRequestException,
); );
expect(personMock.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
describe('updateAll', () => { describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => { it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect( await expect(
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }), sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]); ).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
expect(personMock.update).not.toHaveBeenCalled(); expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
@ -255,11 +319,31 @@ describe(PersonService.name, () => {
}); });
describe('mergePerson', () => { describe('mergePerson', () => {
it('should require person.write and person.merge permission', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should merge two people', async () => { it('should merge two people', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(personStub.mergePerson); personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]); personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson); personMock.delete.mockResolvedValue(personStub.mergePerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true }, { id: 'person-2', success: true },
@ -276,12 +360,14 @@ describe(PersonService.name, () => {
}); });
expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson); expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should delete conflicting faces before merging', async () => { it('should delete conflicting faces before merging', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson); personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true }, { id: 'person-2', success: true },
@ -296,21 +382,25 @@ describe(PersonService.name, () => {
name: JobName.SEARCH_REMOVE_FACE, name: JobName.SEARCH_REMOVE_FACE,
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id }, data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
}); });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should throw an error when the primary person is not found', async () => { it('should throw an error when the primary person is not found', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
expect(personMock.delete).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should handle invalid merge ids', async () => { it('should handle invalid merge ids', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(null); personMock.getById.mockResolvedValueOnce(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
@ -319,6 +409,7 @@ describe(PersonService.name, () => {
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
it('should handle an error reassigning faces', async () => { it('should handle an error reassigning faces', async () => {
@ -326,12 +417,14 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.mergePerson); personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
personMock.reassignFaces.mockRejectedValue(new Error('update failed')); personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN }, { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
]); ]);
expect(personMock.delete).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
}); });
}); });
}); });

View File

@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
@ -18,15 +19,18 @@ import { IPersonRepository, UpdateFacesData } from './person.repository';
@Injectable() @Injectable()
export class PersonService { export class PersonService {
private access: AccessCore;
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
readonly logger = new Logger(PersonService.name); readonly logger = new Logger(PersonService.name);
constructor( constructor(
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IPersonRepository) private repository: IPersonRepository, @Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
) { ) {
this.access = new AccessCore(accessRepository);
this.configCore = new SystemConfigCore(configRepository); this.configCore = new SystemConfigCore(configRepository);
} }
@ -48,12 +52,14 @@ export class PersonService {
}; };
} }
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> { async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
return this.findOrFail(authUser, id).then(mapPerson); await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
return this.findOrFail(id).then(mapPerson);
} }
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> { async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
const person = await this.repository.getById(authUser.id, id); await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const person = await this.repository.getById(id);
if (!person || !person.thumbnailPath) { if (!person || !person.thumbnailPath) {
throw new NotFoundException(); throw new NotFoundException();
} }
@ -62,17 +68,19 @@ export class PersonService {
} }
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> { async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
const assets = await this.repository.getAssets(authUser.id, id); await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const assets = await this.repository.getAssets(id);
return assets.map(mapAsset); return assets.map(mapAsset);
} }
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id); await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
let person = await this.findOrFail(id);
if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) { if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden }); person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden });
if (this.needsSearchIndexUpdate(dto)) { if (this.needsSearchIndexUpdate(dto)) {
const assets = await this.repository.getAssets(authUser.id, id); const assets = await this.repository.getAssets(id);
const ids = assets.map((asset) => asset.id); const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
} }
@ -141,14 +149,22 @@ export class PersonService {
async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids; const mergeIds = dto.ids;
const primaryPerson = await this.findOrFail(authUser, id); await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
const primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id; const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = []; const results: BulkIdResponseDto[] = [];
for (const mergeId of mergeIds) { for (const mergeId of mergeIds) {
const hasPermission = await this.access.hasPermission(authUser, Permission.PERSON_MERGE, mergeId);
if (!hasPermission) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
continue;
}
try { try {
const mergePerson = await this.repository.getById(authUser.id, mergeId); const mergePerson = await this.repository.getById(mergeId);
if (!mergePerson) { if (!mergePerson) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND }); results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue; continue;
@ -188,8 +204,8 @@ export class PersonService {
return dto.name !== undefined || dto.isHidden !== undefined; return dto.name !== undefined || dto.isHidden !== undefined;
} }
private async findOrFail(authUser: AuthUserDto, id: string) { private async findOrFail(id: string) {
const person = await this.repository.getById(authUser.id, id); const person = await this.repository.getById(id);
if (!person) { if (!person) {
throw new BadRequestException('Person not found'); throw new BadRequestException('Person not found');
} }

View File

@ -1,13 +1,14 @@
import { IAccessRepository } from '@app/domain'; import { IAccessRepository } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { AlbumEntity, AssetEntity, PartnerEntity, SharedLinkEntity } from '../entities'; import { AlbumEntity, AssetEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities';
export class AccessRepository implements IAccessRepository { export class AccessRepository implements IAccessRepository {
constructor( constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>, @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>, @InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>, @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
) {} ) {}
@ -156,4 +157,15 @@ export class AccessRepository implements IAccessRepository {
}); });
}, },
}; };
person = {
hasOwnerAccess: (userId: string, personId: string): Promise<boolean> => {
return this.personRepository.exist({
where: {
id: personId,
ownerId: userId,
},
});
},
};
} }

View File

@ -86,14 +86,13 @@ export class PersonRepository implements IPersonRepository {
.getMany(); .getMany();
} }
getById(ownerId: string, personId: string): Promise<PersonEntity | null> { getById(personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId, ownerId } }); return this.personRepository.findOne({ where: { id: personId } });
} }
getAssets(ownerId: string, personId: string): Promise<AssetEntity[]> { getAssets(personId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({ return this.assetRepository.find({
where: { where: {
ownerId,
faces: { faces: {
personId, personId,
}, },

View File

@ -4,6 +4,7 @@ export interface IAccessRepositoryMock {
asset: jest.Mocked<IAccessRepository['asset']>; asset: jest.Mocked<IAccessRepository['asset']>;
album: jest.Mocked<IAccessRepository['album']>; album: jest.Mocked<IAccessRepository['album']>;
library: jest.Mocked<IAccessRepository['library']>; library: jest.Mocked<IAccessRepository['library']>;
person: jest.Mocked<IAccessRepository['person']>;
} }
export const newAccessRepositoryMock = (): IAccessRepositoryMock => { export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
@ -24,5 +25,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
library: { library: {
hasPartnerAccess: jest.fn(), hasPartnerAccess: jest.fn(),
}, },
person: {
hasOwnerAccess: jest.fn(),
},
}; };
}; };