Skip to content

Commit

Permalink
chore(server): Check activity permissions in bulk (#5775)
Browse files Browse the repository at this point in the history
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
```
  • Loading branch information
adamantike authored Dec 17, 2023
1 parent 691e205 commit c6f56d9
Show file tree
Hide file tree
Showing 6 changed files with 89 additions and 79 deletions.
45 changes: 17 additions & 28 deletions server/src/domain/access/access.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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();
}
}
}
12 changes: 5 additions & 7 deletions server/src/domain/activity/activity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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(
Expand All @@ -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([]);

Expand All @@ -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, {
Expand All @@ -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');
});
Expand Down
7 changes: 3 additions & 4 deletions server/src/domain/person/person.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
});
Expand Down
8 changes: 4 additions & 4 deletions server/src/domain/repositories/access.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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>>;
};

Expand Down
88 changes: 56 additions & 32 deletions server/src/infra/repositories/access.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
},
};

Expand Down Expand Up @@ -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();
}
Expand Down
8 changes: 4 additions & 4 deletions server/test/repositories/access.repository.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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()),
},

Expand Down

0 comments on commit c6f56d9

Please sign in to comment.