forked from Cutlery/immich
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 ```
290 lines
9.7 KiB
TypeScript
290 lines
9.7 KiB
TypeScript
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
import { AuthUserDto } from '../auth';
|
|
import { setDifference, setUnion } from '../domain.util';
|
|
import { IAccessRepository } from '../repositories';
|
|
|
|
export enum Permission {
|
|
ACTIVITY_CREATE = 'activity.create',
|
|
ACTIVITY_DELETE = 'activity.delete',
|
|
|
|
// ASSET_CREATE = 'asset.create',
|
|
ASSET_READ = 'asset.read',
|
|
ASSET_UPDATE = 'asset.update',
|
|
ASSET_DELETE = 'asset.delete',
|
|
ASSET_RESTORE = 'asset.restore',
|
|
ASSET_SHARE = 'asset.share',
|
|
ASSET_VIEW = 'asset.view',
|
|
ASSET_DOWNLOAD = 'asset.download',
|
|
ASSET_UPLOAD = 'asset.upload',
|
|
|
|
// ALBUM_CREATE = 'album.create',
|
|
ALBUM_READ = 'album.read',
|
|
ALBUM_UPDATE = 'album.update',
|
|
ALBUM_DELETE = 'album.delete',
|
|
ALBUM_REMOVE_ASSET = 'album.removeAsset',
|
|
ALBUM_SHARE = 'album.share',
|
|
ALBUM_DOWNLOAD = 'album.download',
|
|
|
|
AUTH_DEVICE_DELETE = 'authDevice.delete',
|
|
|
|
ARCHIVE_READ = 'archive.read',
|
|
|
|
TIMELINE_READ = 'timeline.read',
|
|
TIMELINE_DOWNLOAD = 'timeline.download',
|
|
|
|
LIBRARY_CREATE = 'library.create',
|
|
LIBRARY_READ = 'library.read',
|
|
LIBRARY_UPDATE = 'library.update',
|
|
LIBRARY_DELETE = 'library.delete',
|
|
LIBRARY_DOWNLOAD = 'library.download',
|
|
|
|
PERSON_READ = 'person.read',
|
|
PERSON_WRITE = 'person.write',
|
|
PERSON_MERGE = 'person.merge',
|
|
|
|
PARTNER_UPDATE = 'partner.update',
|
|
}
|
|
|
|
let instance: AccessCore | null;
|
|
|
|
export class AccessCore {
|
|
private constructor(private repository: IAccessRepository) {}
|
|
|
|
static create(repository: IAccessRepository) {
|
|
if (!instance) {
|
|
instance = new AccessCore(repository);
|
|
}
|
|
|
|
return instance;
|
|
}
|
|
|
|
static reset() {
|
|
instance = null;
|
|
}
|
|
|
|
requireUploadAccess(authUser: AuthUserDto | null): AuthUserDto {
|
|
if (!authUser || (authUser.isPublicUser && !authUser.isAllowUpload)) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
return authUser;
|
|
}
|
|
|
|
/**
|
|
* Check if user has access to all ids, for the given permission.
|
|
* Throws error if user does not have access to any of the ids.
|
|
*/
|
|
async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
|
|
ids = Array.isArray(ids) ? ids : [ids];
|
|
const allowedIds = await this.checkAccess(authUser, permission, ids);
|
|
if (new Set(ids).size !== allowedIds.size) {
|
|
throw new BadRequestException(`Not found or no ${permission} access`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return ids that user has access to, for the given permission.
|
|
* Check is done for each id, and only allowed ids are returned.
|
|
*
|
|
* @returns Set<string>
|
|
*/
|
|
async checkAccess(authUser: AuthUserDto, permission: Permission, ids: Set<string> | string[]) {
|
|
const idSet = Array.isArray(ids) ? new Set(ids) : ids;
|
|
if (idSet.size === 0) {
|
|
return new Set();
|
|
}
|
|
|
|
const isSharedLink = authUser.isPublicUser ?? false;
|
|
return isSharedLink
|
|
? await this.checkAccessSharedLink(authUser, permission, idSet)
|
|
: await this.checkAccessOther(authUser, permission, idSet);
|
|
}
|
|
|
|
private async checkAccessSharedLink(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
|
|
const sharedLinkId = authUser.sharedLinkId;
|
|
if (!sharedLinkId) {
|
|
return new Set();
|
|
}
|
|
|
|
switch (permission) {
|
|
case Permission.ASSET_UPLOAD:
|
|
return authUser.isAllowUpload ? ids : new Set();
|
|
|
|
case Permission.ALBUM_READ:
|
|
return await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids);
|
|
|
|
case Permission.ALBUM_DOWNLOAD:
|
|
return !!authUser.isAllowDownload
|
|
? await this.repository.album.checkSharedLinkAccess(sharedLinkId, ids)
|
|
: new Set();
|
|
}
|
|
|
|
const allowedIds = new Set();
|
|
for (const id of ids) {
|
|
const hasAccess = await this.hasSharedLinkAccess(authUser, permission, id);
|
|
if (hasAccess) {
|
|
allowedIds.add(id);
|
|
}
|
|
}
|
|
return allowedIds;
|
|
}
|
|
|
|
// TODO: Migrate logic to checkAccessSharedLink to evaluate permissions in bulk.
|
|
private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
|
const sharedLinkId = authUser.sharedLinkId;
|
|
if (!sharedLinkId) {
|
|
return false;
|
|
}
|
|
|
|
switch (permission) {
|
|
case Permission.ASSET_READ:
|
|
return this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
|
|
|
|
case Permission.ASSET_VIEW:
|
|
return await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
|
|
|
|
case Permission.ASSET_DOWNLOAD:
|
|
return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id));
|
|
|
|
case Permission.ASSET_SHARE:
|
|
// TODO: fix this to not use authUser.id for shared link access control
|
|
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async checkAccessOther(authUser: AuthUserDto, permission: Permission, ids: Set<string>) {
|
|
switch (permission) {
|
|
case Permission.ALBUM_READ: {
|
|
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
|
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
|
return setUnion(isOwner, isShared);
|
|
}
|
|
|
|
case Permission.ALBUM_UPDATE:
|
|
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
|
|
|
case Permission.ALBUM_DELETE:
|
|
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
|
|
|
case Permission.ALBUM_SHARE:
|
|
return this.repository.album.checkOwnerAccess(authUser.id, ids);
|
|
|
|
case Permission.ALBUM_DOWNLOAD: {
|
|
const isOwner = await this.repository.album.checkOwnerAccess(authUser.id, ids);
|
|
const isShared = await this.repository.album.checkSharedAlbumAccess(authUser.id, setDifference(ids, isOwner));
|
|
return setUnion(isOwner, isShared);
|
|
}
|
|
|
|
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();
|
|
for (const id of ids) {
|
|
const hasAccess = await this.hasOtherAccess(authUser, permission, id);
|
|
if (hasAccess) {
|
|
allowedIds.add(id);
|
|
}
|
|
}
|
|
return allowedIds;
|
|
}
|
|
|
|
// TODO: Migrate logic to checkAccessOther to evaluate permissions in bulk.
|
|
private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
|
|
switch (permission) {
|
|
// uses album id
|
|
case Permission.ACTIVITY_CREATE:
|
|
return await this.repository.activity.hasCreateAccess(authUser.id, id);
|
|
|
|
// uses activity id
|
|
case Permission.ACTIVITY_DELETE:
|
|
return (
|
|
(await this.repository.activity.hasOwnerAccess(authUser.id, id)) ||
|
|
(await this.repository.activity.hasAlbumOwnerAccess(authUser.id, id))
|
|
);
|
|
|
|
case Permission.ASSET_READ:
|
|
return (
|
|
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
|
);
|
|
case Permission.ASSET_UPDATE:
|
|
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
|
|
|
case Permission.ASSET_DELETE:
|
|
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
|
|
|
case Permission.ASSET_RESTORE:
|
|
return this.repository.asset.hasOwnerAccess(authUser.id, id);
|
|
|
|
case Permission.ASSET_SHARE:
|
|
return (
|
|
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
|
);
|
|
|
|
case Permission.ASSET_VIEW:
|
|
return (
|
|
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
|
);
|
|
|
|
case Permission.ASSET_DOWNLOAD:
|
|
return (
|
|
(await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
|
|
(await this.repository.asset.hasPartnerAccess(authUser.id, id))
|
|
);
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
}
|