0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-21 00:52:43 -05:00

chore(server): Check more permissions in bulk (#5315)

Modify Access repository, to evaluate `authDevice`, `library`, `partner`,
`person`, and `timeline` permissions in bulk.
Queries have been validated to match what they currently generate for
single ids.

As an extra performance improvement, we now use a custom QueryBuilder
for the Partners queries, to avoid the eager relationships that add
unneeded `LEFT JOIN` clauses. We only filter based on the ids present in
the `partners` table, so those joins can be avoided.

Queries:

* `library` owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "libraries" "LibraryEntity"
  WHERE
    "LibraryEntity"."id" = $1
    AND "LibraryEntity"."ownerId" = $2
    AND "LibraryEntity"."deletedAt" IS NULL
)
LIMIT 1

-- After
SELECT "LibraryEntity"."id" AS "LibraryEntity_id"
FROM "libraries" "LibraryEntity"
WHERE
  "LibraryEntity"."id" IN ($1, $2)
  AND "LibraryEntity"."ownerId" = $3
  AND "LibraryEntity"."deletedAt" IS NULL
```

* `library` partner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "partners" "PartnerEntity"
    LEFT JOIN "users" "PartnerEntity__sharedBy"
      ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById"
      AND "PartnerEntity__sharedBy"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__sharedWith"
      ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId"
      AND "PartnerEntity__sharedWith"."deletedAt" IS NULL
  WHERE
    "PartnerEntity"."sharedWithId" = $1
    AND "PartnerEntity"."sharedById" = $2
)
LIMIT 1

-- After
SELECT
  "partner"."sharedById" AS "partner_sharedById",
  "partner"."sharedWithId" AS "partner_sharedWithId"
FROM "partners" "partner"
WHERE
  "partner"."sharedById" IN ($1, $2)
  AND "partner"."sharedWithId" = $3
```

* `authDevice` owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "user_token" "UserTokenEntity"
  WHERE
    "UserTokenEntity"."userId" = $1
    AND "UserTokenEntity"."id" = $2
)
LIMIT 1

-- After
SELECT "UserTokenEntity"."id" AS "UserTokenEntity_id"
FROM "user_token" "UserTokenEntity"
WHERE
  "UserTokenEntity"."userId" = $1
  AND "UserTokenEntity"."id" IN ($2, $3)
```

* `timeline` partner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "partners" "PartnerEntity"
    LEFT JOIN "users" "PartnerEntity__sharedBy"
      ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById"
      AND "PartnerEntity__sharedBy"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__sharedWith"
      ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId"
      AND "PartnerEntity__sharedWith"."deletedAt" IS NULL
  WHERE
    "PartnerEntity"."sharedWithId" = $1
    AND "PartnerEntity"."sharedById" = $2
)
LIMIT 1

-- After
SELECT
  "partner"."sharedById" AS "partner_sharedById",
  "partner"."sharedWithId" AS "partner_sharedWithId"
FROM "partners" "partner"
WHERE
  "partner"."sharedById" IN ($1, $2)
  AND "partner"."sharedWithId" = $3
```

* `person` owner access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "person" "PersonEntity"
  WHERE
    "PersonEntity"."id" = $1
    AND "PersonEntity"."ownerId" = $2
)
LIMIT 1

-- After
SELECT "PersonEntity"."id" AS "PersonEntity_id"
FROM "person" "PersonEntity"
WHERE
  "PersonEntity"."id" IN ($1, $2)
  AND "PersonEntity"."ownerId" = $3
```

* `partner` update access:

```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
  SELECT 1
  FROM "partners" "PartnerEntity"
    LEFT JOIN "users" "PartnerEntity__sharedBy"
      ON "PartnerEntity__sharedBy"."id"="PartnerEntity"."sharedById"
      AND "PartnerEntity__sharedBy"."deletedAt" IS NULL
    LEFT JOIN "users" "PartnerEntity__sharedWith"
      ON "PartnerEntity__sharedWith"."id"="PartnerEntity"."sharedWithId"
      AND "PartnerEntity__sharedWith"."deletedAt" IS NULL
  WHERE
    "PartnerEntity"."sharedWithId" = $1
    AND "PartnerEntity"."sharedById" = $2
)
LIMIT 1

-- After
SELECT
  "partner"."sharedById" AS "partner_sharedById",
  "partner"."sharedWithId" AS "partner_sharedWithId"
FROM "partners" "partner"
WHERE
  "partner"."sharedById" IN ($1, $2)
  AND "partner"."sharedWithId" = $3
```
This commit is contained in:
Michael Manganiello 2023-11-26 07:50:41 -05:00 committed by GitHub
parent f97dca7707
commit c04340c63e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 190 additions and 151 deletions

View file

@ -179,6 +179,48 @@ export class AccessCore {
case Permission.ALBUM_REMOVE_ASSET:
return this.repository.album.checkOwnerAccess(authUser.id, ids);
case Permission.ASSET_UPLOAD:
return this.repository.library.checkOwnerAccess(authUser.id, ids);
case Permission.ARCHIVE_READ:
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
case Permission.AUTH_DEVICE_DELETE:
return this.repository.authDevice.checkOwnerAccess(authUser.id, ids);
case Permission.TIMELINE_READ: {
const isOwner = ids.has(authUser.id) ? new Set([authUser.id]) : new Set<string>();
const isPartner = await this.repository.timeline.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.TIMELINE_DOWNLOAD:
return ids.has(authUser.id) ? new Set([authUser.id]) : new Set();
case Permission.LIBRARY_READ: {
const isOwner = await this.repository.library.checkOwnerAccess(authUser.id, ids);
const isPartner = await this.repository.library.checkPartnerAccess(authUser.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.LIBRARY_UPDATE:
return this.repository.library.checkOwnerAccess(authUser.id, ids);
case Permission.LIBRARY_DELETE:
return this.repository.library.checkOwnerAccess(authUser.id, ids);
case Permission.PERSON_READ:
return this.repository.person.checkOwnerAccess(authUser.id, ids);
case Permission.PERSON_WRITE:
return this.repository.person.checkOwnerAccess(authUser.id, ids);
case Permission.PERSON_MERGE:
return this.repository.person.checkOwnerAccess(authUser.id, ids);
case Permission.PARTNER_UPDATE:
return this.repository.partner.checkUpdateAccess(authUser.id, ids);
}
const allowedIds = new Set();
@ -240,45 +282,6 @@ export class AccessCore {
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
);
case Permission.ASSET_UPLOAD:
return this.repository.library.hasOwnerAccess(authUser.id, id);
case Permission.ARCHIVE_READ:
return authUser.id === id;
case Permission.AUTH_DEVICE_DELETE:
return this.repository.authDevice.hasOwnerAccess(authUser.id, id);
case Permission.TIMELINE_READ:
return authUser.id === id || (await this.repository.timeline.hasPartnerAccess(authUser.id, id));
case Permission.TIMELINE_DOWNLOAD:
return authUser.id === id;
case Permission.LIBRARY_READ:
return (
(await this.repository.library.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.library.hasPartnerAccess(authUser.id, id))
);
case Permission.LIBRARY_UPDATE:
return this.repository.library.hasOwnerAccess(authUser.id, id);
case Permission.LIBRARY_DELETE:
return this.repository.library.hasOwnerAccess(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);
case Permission.PARTNER_UPDATE:
return this.repository.partner.hasUpdateAccess(authUser.id, id);
default:
return false;
}

View file

@ -559,7 +559,7 @@ describe(AssetService.name, () => {
});
it('should return a list of archives (userId)', async () => {
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
assetMock.getByUserId.mockResolvedValue({
items: [assetStub.image, assetStub.video],
hasNextPage: false,
@ -575,7 +575,7 @@ describe(AssetService.name, () => {
});
it('should split archives by size', async () => {
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
assetMock.getByUserId.mockResolvedValue({
items: [

View file

@ -395,11 +395,11 @@ describe('AuthService', () => {
describe('logoutDevice', () => {
it('should logout the device', async () => {
accessMock.authDevice.hasOwnerAccess.mockResolvedValue(true);
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.logoutDevice(authStub.user1, 'token-1');
expect(accessMock.authDevice.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, new Set(['token-1']));
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
});
});

View file

@ -58,7 +58,7 @@ describe(LibraryService.name, () => {
ctime: new Date('2023-01-01'),
} as Stats);
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.id]));
sut = new LibraryService(
accessMock,

View file

@ -183,105 +183,101 @@ describe(PersonService.name, () => {
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw a bad request when person is not found', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should get a person by id', async () => {
personMock.getById.mockResolvedValue(personStub.withName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
expect(storageMock.createReadStream).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when person has no thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noThumbnail);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
expect(storageMock.createReadStream).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should serve the thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await sut.getThumbnail(authStub.admin, 'person-1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it("should return a person's assets", async () => {
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await sut.getAssets(authStub.admin, 'person-1');
expect(personMock.getAssets).toHaveBeenCalledWith('person-1');
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
describe('update', () => {
it('should require person.write permission', async () => {
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it("should update a person's name", async () => {
personMock.getById.mockResolvedValue(personStub.noName);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
@ -291,14 +287,14 @@ describe(PersonService.name, () => {
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetStub.image.id] },
});
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it("should update a person's date of birth", async () => {
personMock.getById.mockResolvedValue(personStub.noBirthDate);
personMock.update.mockResolvedValue(personStub.withBirthDate);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: 'person-1',
@ -311,14 +307,14 @@ describe(PersonService.name, () => {
expect(personMock.getById).toHaveBeenCalledWith('person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(jobMock.queue).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetStub.image]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
@ -328,7 +324,7 @@ describe(PersonService.name, () => {
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetStub.image.id] },
});
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it("should update a person's thumbnailPath", async () => {
@ -336,7 +332,7 @@ describe(PersonService.name, () => {
personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
@ -351,31 +347,31 @@ describe(PersonService.name, () => {
},
]);
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when the face feature assetId is invalid', async () => {
personMock.getById.mockResolvedValue(personStub.withName);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
BadRequestException,
);
expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
expect(personMock.update).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
@ -652,7 +648,6 @@ describe(PersonService.name, () => {
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,
@ -663,7 +658,7 @@ describe(PersonService.name, () => {
expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should merge two people', async () => {
@ -671,7 +666,8 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([]);
personMock.delete.mockResolvedValue(personStub.mergePerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
@ -691,14 +687,15 @@ describe(PersonService.name, () => {
name: JobName.PERSON_DELETE,
data: { id: personStub.mergePerson.id },
});
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should delete conflicting faces before merging', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: true },
@ -713,25 +710,26 @@ describe(PersonService.name, () => {
name: JobName.SEARCH_REMOVE_FACE,
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
});
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should throw an error when the primary person is not found', async () => {
personMock.getById.mockResolvedValue(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should handle invalid merge ids', async () => {
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
personMock.getById.mockResolvedValueOnce(null);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
@ -740,7 +738,7 @@ describe(PersonService.name, () => {
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');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should handle an error reassigning faces', async () => {
@ -748,14 +746,15 @@ describe(PersonService.name, () => {
personMock.getById.mockResolvedValue(personStub.mergePerson);
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
]);
expect(personMock.delete).not.toHaveBeenCalled();
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
@ -763,16 +762,15 @@ describe(PersonService.name, () => {
it('should get correct number of person', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1']));
});
});
});

View file

@ -14,7 +14,7 @@ export interface IAccessRepository {
};
authDevice: {
hasOwnerAccess(userId: string, deviceId: string): Promise<boolean>;
checkOwnerAccess(userId: string, deviceIds: Set<string>): Promise<Set<string>>;
};
album: {
@ -24,19 +24,19 @@ export interface IAccessRepository {
};
library: {
hasOwnerAccess(userId: string, libraryId: string): Promise<boolean>;
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
checkOwnerAccess(userId: string, libraryIds: Set<string>): Promise<Set<string>>;
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
timeline: {
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
checkPartnerAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
person: {
hasOwnerAccess(userId: string, personId: string): Promise<boolean>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
};
partner: {
hasUpdateAccess(userId: string, partnerId: string): Promise<boolean>;
checkUpdateAccess(userId: string, partnerIds: Set<string>): Promise<Set<string>>;
};
}

View file

@ -130,7 +130,7 @@ describe('AssetService', () => {
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockResolvedValue(assetEntity);
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
@ -150,7 +150,7 @@ describe('AssetService', () => {
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
@ -167,7 +167,7 @@ describe('AssetService', () => {
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
accessMock.library.hasOwnerAccess.mockResolvedValue(true);
accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([dto.libraryId!]));
await expect(
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),

View file

@ -62,33 +62,52 @@ export class AccessRepository implements IAccessRepository {
});
},
};
library = {
hasOwnerAccess: (userId: string, libraryId: string): Promise<boolean> => {
return this.libraryRepository.exist({
where: {
id: libraryId,
ownerId: userId,
},
});
checkOwnerAccess: async (userId: string, libraryIds: Set<string>): Promise<Set<string>> => {
if (libraryIds.size === 0) {
return new Set();
}
return this.libraryRepository
.find({
select: { id: true },
where: {
id: In([...libraryIds]),
ownerId: userId,
},
})
.then((libraries) => new Set(libraries.map((library) => library.id)));
},
hasPartnerAccess: (userId: string, partnerId: string): Promise<boolean> => {
return this.partnerRepository.exist({
where: {
sharedWithId: userId,
sharedById: partnerId,
},
});
checkPartnerAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
},
};
timeline = {
hasPartnerAccess: (userId: string, partnerId: string): Promise<boolean> => {
return this.partnerRepository.exist({
where: {
sharedWithId: userId,
sharedById: partnerId,
},
});
checkPartnerAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
},
};
@ -198,13 +217,20 @@ export class AccessRepository implements IAccessRepository {
};
authDevice = {
hasOwnerAccess: (userId: string, deviceId: string): Promise<boolean> => {
return this.tokenRepository.exist({
where: {
userId,
id: deviceId,
},
});
checkOwnerAccess: async (userId: string, deviceIds: Set<string>): Promise<Set<string>> => {
if (deviceIds.size === 0) {
return new Set();
}
return this.tokenRepository
.find({
select: { id: true },
where: {
userId,
id: In([...deviceIds]),
},
})
.then((tokens) => new Set(tokens.map((token) => token.id)));
},
};
@ -264,24 +290,36 @@ export class AccessRepository implements IAccessRepository {
};
person = {
hasOwnerAccess: (userId: string, personId: string): Promise<boolean> => {
return this.personRepository.exist({
where: {
id: personId,
ownerId: userId,
},
});
checkOwnerAccess: async (userId: string, personIds: Set<string>): Promise<Set<string>> => {
if (personIds.size === 0) {
return new Set();
}
return this.personRepository
.find({
select: { id: true },
where: {
id: In([...personIds]),
ownerId: userId,
},
})
.then((persons) => new Set(persons.map((person) => person.id)));
},
};
partner = {
hasUpdateAccess: (userId: string, partnerId: string): Promise<boolean> => {
return this.partnerRepository.exist({
where: {
sharedById: partnerId,
sharedWithId: userId,
},
});
checkUpdateAccess: async (userId: string, partnerIds: Set<string>): Promise<Set<string>> => {
if (partnerIds.size === 0) {
return new Set();
}
return this.partnerRepository
.createQueryBuilder('partner')
.select('partner.sharedById')
.where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] })
.andWhere('partner.sharedWithId = :userId', { userId })
.getMany()
.then((partners) => new Set(partners.map((partner) => partner.sharedById)));
},
};
}

View file

@ -36,24 +36,24 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
},
authDevice: {
hasOwnerAccess: jest.fn(),
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
},
library: {
hasOwnerAccess: jest.fn(),
hasPartnerAccess: jest.fn(),
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
},
timeline: {
hasPartnerAccess: jest.fn(),
checkPartnerAccess: jest.fn().mockResolvedValue(new Set()),
},
person: {
hasOwnerAccess: jest.fn(),
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
},
partner: {
hasUpdateAccess: jest.fn(),
checkUpdateAccess: jest.fn().mockResolvedValue(new Set()),
},
};
};