mirror of
https://github.com/immich-app/immich.git
synced 2024-11-28 09:33:27 +02:00
chore(server): Check activity permissions in bulk (#5775)
Modify Access repository, to evaluate `asset` permissions in bulk. This is the last set of permission changes, to migrate all of them to run in bulk! Queries have been validated to match what they currently generate for single ids. Queries: * `activity` owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "activity" "ActivityEntity" WHERE "ActivityEntity"."id" = $1 AND "ActivityEntity"."userId" = $2 ) LIMIT 1 -- After SELECT "ActivityEntity"."id" AS "ActivityEntity_id" FROM "activity" "ActivityEntity" WHERE "ActivityEntity"."id" IN ($1) AND "ActivityEntity"."userId" = $2 ``` * `activity` album owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "activity" "ActivityEntity" LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album" ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId" AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL WHERE "ActivityEntity"."id" = $1 AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2 ) LIMIT 1 -- After SELECT "ActivityEntity"."id" AS "ActivityEntity_id" FROM "activity" "ActivityEntity" LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album" ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId" AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL WHERE "ActivityEntity"."id" IN ($1) AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2 ``` * `activity` create access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE ( ( "AlbumEntity"."id" = $1 AND "AlbumEntity"."isActivityEnabled" = $2 AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 ) OR ( "AlbumEntity"."id" = $4 AND "AlbumEntity"."isActivityEnabled" = $5 AND "AlbumEntity"."ownerId" = $6 ) ) AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE ( ( "AlbumEntity"."id" IN ($1) AND "AlbumEntity"."isActivityEnabled" = $2 AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 ) OR ( "AlbumEntity"."id" IN ($4) AND "AlbumEntity"."isActivityEnabled" = $5 AND "AlbumEntity"."ownerId" = $6 ) ) AND "AlbumEntity"."deletedAt" IS NULL ```
This commit is contained in:
parent
691e20521d
commit
c6f56d9591
@ -140,6 +140,20 @@ export class AccessCore {
|
||||
|
||||
private async checkAccessOther(auth: AuthDto, permission: Permission, ids: Set<string>) {
|
||||
switch (permission) {
|
||||
// uses album id
|
||||
case Permission.ACTIVITY_CREATE:
|
||||
return await this.repository.activity.checkCreateAccess(auth.user.id, ids);
|
||||
|
||||
// uses activity id
|
||||
case Permission.ACTIVITY_DELETE: {
|
||||
const isOwner = await this.repository.activity.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbumOwner = await this.repository.activity.checkAlbumOwnerAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
);
|
||||
return setUnion(isOwner, isAlbumOwner);
|
||||
}
|
||||
|
||||
case Permission.ASSET_READ: {
|
||||
const isOwner = await this.repository.asset.checkOwnerAccess(auth.user.id, ids);
|
||||
const isAlbum = await this.repository.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
|
||||
@ -249,41 +263,16 @@ export class AccessCore {
|
||||
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
|
||||
|
||||
case Permission.PERSON_CREATE:
|
||||
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
|
||||
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||
|
||||
case Permission.PERSON_REASSIGN:
|
||||
return this.repository.person.hasFaceOwnerAccess(auth.user.id, ids);
|
||||
return this.repository.person.checkFaceOwnerAccess(auth.user.id, ids);
|
||||
|
||||
case Permission.PARTNER_UPDATE:
|
||||
return await this.repository.partner.checkUpdateAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
const allowedIds = new Set();
|
||||
for (const id of ids) {
|
||||
const hasAccess = await this.hasOtherAccess(auth, permission, id);
|
||||
if (hasAccess) {
|
||||
allowedIds.add(id);
|
||||
}
|
||||
}
|
||||
return allowedIds;
|
||||
}
|
||||
|
||||
// TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk.
|
||||
private async hasOtherAccess(auth: AuthDto, permission: Permission, id: string) {
|
||||
switch (permission) {
|
||||
// uses album id
|
||||
case Permission.ACTIVITY_CREATE:
|
||||
return await this.repository.activity.hasCreateAccess(auth.user.id, id);
|
||||
|
||||
// uses activity id
|
||||
case Permission.ACTIVITY_DELETE:
|
||||
return (
|
||||
(await this.repository.activity.hasOwnerAccess(auth.user.id, id)) ||
|
||||
(await this.repository.activity.hasAlbumOwnerAccess(auth.user.id, id))
|
||||
);
|
||||
|
||||
default:
|
||||
return false;
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
@ -114,7 +114,6 @@ describe(ActivityService.name, () => {
|
||||
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(false);
|
||||
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
||||
|
||||
await expect(
|
||||
@ -128,7 +127,7 @@ describe(ActivityService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.create.mockResolvedValue(activityStub.liked);
|
||||
activityMock.search.mockResolvedValue([]);
|
||||
|
||||
@ -148,7 +147,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||
accessMock.activity.hasCreateAccess.mockResolvedValue(true);
|
||||
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
||||
activityMock.search.mockResolvedValue([activityStub.liked]);
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
@ -163,19 +162,18 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
accessMock.activity.hasOwnerAccess.mockResolvedValue(false);
|
||||
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(activityMock.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the activity owner delete a comment', async () => {
|
||||
accessMock.activity.hasOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
|
||||
it('should let the album owner delete a comment', async () => {
|
||||
accessMock.activity.hasAlbumOwnerAccess.mockResolvedValue(true);
|
||||
accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
||||
await sut.delete(authStub.admin, 'activity-id');
|
||||
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
||||
});
|
||||
|
@ -360,7 +360,7 @@ describe(PersonService.name, () => {
|
||||
it('should reassign a face', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1);
|
||||
@ -415,7 +415,7 @@ describe(PersonService.name, () => {
|
||||
describe('reassignFacesById', () => {
|
||||
it('should create a new person', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
@ -437,7 +437,6 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should fail if user has not the correct permissions on the asset', async () => {
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set());
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
personMock.reassignFace.mockResolvedValue(1);
|
||||
personMock.getById.mockResolvedValue(personStub.noName);
|
||||
@ -456,7 +455,7 @@ describe(PersonService.name, () => {
|
||||
it('should create a new person', async () => {
|
||||
personMock.create.mockResolvedValue(personStub.primaryPerson);
|
||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||
accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id]));
|
||||
|
||||
await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson);
|
||||
});
|
||||
|
@ -2,9 +2,9 @@ export const IAccessRepository = 'IAccessRepository';
|
||||
|
||||
export interface IAccessRepository {
|
||||
activity: {
|
||||
hasOwnerAccess(userId: string, activityId: string): Promise<boolean>;
|
||||
hasAlbumOwnerAccess(userId: string, activityId: string): Promise<boolean>;
|
||||
hasCreateAccess(userId: string, albumId: string): Promise<boolean>;
|
||||
checkOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
|
||||
checkAlbumOwnerAccess(userId: string, activityIds: Set<string>): Promise<Set<string>>;
|
||||
checkCreateAccess(userId: string, albumIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
asset: {
|
||||
@ -34,7 +34,7 @@ export interface IAccessRepository {
|
||||
};
|
||||
|
||||
person: {
|
||||
hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
|
||||
checkFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
|
||||
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
|
||||
};
|
||||
|
||||
|
@ -27,41 +27,64 @@ export class AccessRepository implements IAccessRepository {
|
||||
) {}
|
||||
|
||||
activity = {
|
||||
hasOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
|
||||
return this.activityRepository.exist({
|
||||
where: {
|
||||
id: activityId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
hasAlbumOwnerAccess: (userId: string, activityId: string): Promise<boolean> => {
|
||||
return this.activityRepository.exist({
|
||||
where: {
|
||||
id: activityId,
|
||||
album: {
|
||||
ownerId: userId,
|
||||
checkOwnerAccess: async (userId: string, activityIds: Set<string>): Promise<Set<string>> => {
|
||||
if (activityIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.activityRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...activityIds]),
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((activities) => new Set(activities.map((activity) => activity.id)));
|
||||
},
|
||||
hasCreateAccess: (userId: string, albumId: string): Promise<boolean> => {
|
||||
return this.albumRepository.exist({
|
||||
where: [
|
||||
{
|
||||
id: albumId,
|
||||
isActivityEnabled: true,
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
|
||||
checkAlbumOwnerAccess: async (userId: string, activityIds: Set<string>): Promise<Set<string>> => {
|
||||
if (activityIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.activityRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...activityIds]),
|
||||
album: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: albumId,
|
||||
isActivityEnabled: true,
|
||||
ownerId: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
.then((activities) => new Set(activities.map((activity) => activity.id)));
|
||||
},
|
||||
|
||||
checkCreateAccess: async (userId: string, albumIds: Set<string>): Promise<Set<string>> => {
|
||||
if (albumIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.albumRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: [
|
||||
{
|
||||
id: In([...albumIds]),
|
||||
isActivityEnabled: true,
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: In([...albumIds]),
|
||||
isActivityEnabled: true,
|
||||
ownerId: userId,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((albums) => new Set(albums.map((album) => album.id)));
|
||||
},
|
||||
};
|
||||
|
||||
@ -320,7 +343,8 @@ export class AccessRepository implements IAccessRepository {
|
||||
})
|
||||
.then((persons) => new Set(persons.map((person) => person.id)));
|
||||
},
|
||||
hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
|
||||
|
||||
checkFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
|
||||
if (assetFaceIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
@ -18,9 +18,9 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
|
||||
|
||||
return {
|
||||
activity: {
|
||||
hasOwnerAccess: jest.fn(),
|
||||
hasAlbumOwnerAccess: jest.fn(),
|
||||
hasCreateAccess: jest.fn(),
|
||||
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
||||
checkAlbumOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
||||
checkCreateAccess: jest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
asset: {
|
||||
@ -50,7 +50,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
|
||||
},
|
||||
|
||||
person: {
|
||||
hasFaceOwnerAccess: jest.fn(),
|
||||
checkFaceOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
||||
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user