refactor(server): access permissions (#2910)
* refactor: access repo interface * feat: access core * fix: allow shared links to add to a shared link * chore: comment out unused code * fix: pr feedback --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
|
||||
import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
assetEntityStub,
|
||||
authStub,
|
||||
fileStub,
|
||||
IAccessRepositoryMock,
|
||||
newAccessRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
@@ -120,7 +121,7 @@ const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
|
||||
describe('AssetService', () => {
|
||||
let sut: AssetService;
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
let accessMock: jest.Mocked<IAccessRepository>;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
@@ -293,7 +294,7 @@ describe('AssetService', () => {
|
||||
describe('deleteAll', () => {
|
||||
it('should return failed status when an asset is missing', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue(null);
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
@@ -305,7 +306,7 @@ describe('AssetService', () => {
|
||||
it('should return failed status a delete fails', async () => {
|
||||
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
|
||||
assetRepositoryMock.remove.mockRejectedValue('delete failed');
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'FAILED' },
|
||||
@@ -315,7 +316,7 @@ describe('AssetService', () => {
|
||||
});
|
||||
|
||||
it('should delete a live photo', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
|
||||
{ id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
|
||||
@@ -364,7 +365,7 @@ describe('AssetService', () => {
|
||||
.calledWith(asset2.id)
|
||||
.mockResolvedValue(asset2 as AssetEntity);
|
||||
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
|
||||
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
|
||||
{ id: 'asset1', status: 'SUCCESS' },
|
||||
@@ -409,7 +410,7 @@ describe('AssetService', () => {
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should download a single file', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
|
||||
|
||||
await sut.downloadFile(authStub.admin, 'id_1');
|
||||
@@ -485,56 +486,56 @@ describe('AssetService', () => {
|
||||
|
||||
describe('getAssetById', () => {
|
||||
it('should allow owner access', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
|
||||
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
|
||||
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
|
||||
expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
});
|
||||
|
||||
it('should allow shared link access', async () => {
|
||||
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
|
||||
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
|
||||
await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
|
||||
expect(accessMock.hasSharedLinkAssetAccess).toHaveBeenCalledWith(
|
||||
expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLinkId,
|
||||
assetEntityStub.image.id,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow partner sharing access', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasPartnerAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
|
||||
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
|
||||
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
|
||||
expect(accessMock.hasPartnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
});
|
||||
|
||||
it('should allow shared album access', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasAlbumAssetAccess.mockResolvedValue(true);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
|
||||
assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
|
||||
await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
|
||||
expect(accessMock.hasAlbumAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
|
||||
accessMock.hasAlbumAssetAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
|
||||
await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for an invalid shared link', async () => {
|
||||
accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
|
||||
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
|
||||
await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
expect(accessMock.hasOwnerAssetAccess).not.toHaveBeenCalled();
|
||||
expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();
|
||||
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AccessCore,
|
||||
AssetResponseDto,
|
||||
AuthUserDto,
|
||||
getLivePhotoMotionFilename,
|
||||
@@ -12,11 +13,11 @@ import {
|
||||
JobName,
|
||||
mapAsset,
|
||||
mapAssetWithoutExif,
|
||||
Permission,
|
||||
} from '@app/domain';
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
@@ -79,9 +80,10 @@ interface ServableFile {
|
||||
export class AssetService {
|
||||
readonly logger = new Logger(AssetService.name);
|
||||
private assetCore: AssetCore;
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@@ -90,6 +92,7 @@ export class AssetService {
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(_assetRepository, jobRepository);
|
||||
this.access = new AccessCore(accessRepository);
|
||||
}
|
||||
|
||||
public async uploadFile(
|
||||
@@ -208,32 +211,21 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
|
||||
if (dto.userId && dto.userId !== authUser.id) {
|
||||
await this.checkUserAccess(authUser, dto.userId);
|
||||
}
|
||||
const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
|
||||
|
||||
const userId = dto.userId || authUser.id;
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
|
||||
const assets = await this._assetRepository.getAllByUserId(userId, dto);
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
public async getAssetByTimeBucket(
|
||||
authUser: AuthUserDto,
|
||||
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
|
||||
): Promise<AssetResponseDto[]> {
|
||||
if (getAssetByTimeBucketDto.userId) {
|
||||
await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
|
||||
}
|
||||
|
||||
const assets = await this._assetRepository.getAssetByTimeBucket(
|
||||
getAssetByTimeBucketDto.userId || authUser.id,
|
||||
getAssetByTimeBucketDto,
|
||||
);
|
||||
|
||||
public async getAssetByTimeBucket(authUser: AuthUserDto, dto: GetAssetByTimeBucketDto): Promise<AssetResponseDto[]> {
|
||||
const userId = dto.userId || authUser.id;
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
|
||||
const assets = await this._assetRepository.getAssetByTimeBucket(userId, dto);
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
|
||||
await this.checkAssetsAccess(authUser, [assetId]);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
|
||||
|
||||
const allowExif = this.getExifPermission(authUser);
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
@@ -246,7 +238,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||
await this.checkAssetsAccess(authUser, [assetId], true);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, assetId);
|
||||
|
||||
const asset = await this._assetRepository.getById(assetId);
|
||||
if (!asset) {
|
||||
@@ -261,15 +253,15 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) {
|
||||
this.checkDownloadAccess(authUser);
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id);
|
||||
|
||||
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
|
||||
|
||||
return this.downloadService.downloadArchive(dto.name || `library`, assets);
|
||||
}
|
||||
|
||||
public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) {
|
||||
this.checkDownloadAccess(authUser);
|
||||
await this.checkAssetsAccess(authUser, [...dto.assetIds]);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
|
||||
|
||||
const assetToDownload = [];
|
||||
|
||||
@@ -289,8 +281,7 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
|
||||
this.checkDownloadAccess(authUser);
|
||||
await this.checkAssetsAccess(authUser, [assetId]);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId);
|
||||
|
||||
try {
|
||||
const asset = await this._assetRepository.get(assetId);
|
||||
@@ -312,7 +303,8 @@ export class AssetService {
|
||||
res: Res,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
await this.checkAssetsAccess(authUser, [assetId]);
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
|
||||
|
||||
const asset = await this._assetRepository.get(assetId);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
@@ -338,7 +330,8 @@ export class AssetService {
|
||||
res: Res,
|
||||
headers: Record<string, string>,
|
||||
) {
|
||||
await this.checkAssetsAccess(authUser, [assetId]);
|
||||
// this is not quite right as sometimes this returns the original still
|
||||
await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
|
||||
|
||||
const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload);
|
||||
|
||||
@@ -421,13 +414,17 @@ export class AssetService {
|
||||
}
|
||||
|
||||
public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
|
||||
await this.checkAssetsAccess(authUser, dto.ids, true);
|
||||
|
||||
const deleteQueue: Array<string | null> = [];
|
||||
const result: DeleteAssetResponseDto[] = [];
|
||||
|
||||
const ids = dto.ids.slice();
|
||||
for (const id of ids) {
|
||||
const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_DELETE, id);
|
||||
if (!hasAccess) {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
continue;
|
||||
}
|
||||
|
||||
const asset = await this._assetRepository.get(id);
|
||||
if (!asset) {
|
||||
result.push({ id, status: DeleteAssetStatusEnum.FAILED });
|
||||
@@ -605,17 +602,11 @@ export class AssetService {
|
||||
|
||||
async getAssetCountByTimeBucket(
|
||||
authUser: AuthUserDto,
|
||||
getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
|
||||
dto: GetAssetCountByTimeBucketDto,
|
||||
): Promise<AssetCountByTimeBucketResponseDto> {
|
||||
if (getAssetCountByTimeBucketDto.userId !== undefined) {
|
||||
await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
|
||||
}
|
||||
|
||||
const result = await this._assetRepository.getAssetCountByTimeBucket(
|
||||
getAssetCountByTimeBucketDto.userId || authUser.id,
|
||||
getAssetCountByTimeBucketDto,
|
||||
);
|
||||
|
||||
const userId = dto.userId || authUser.id;
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
|
||||
const result = await this._assetRepository.getAssetCountByTimeBucket(userId, dto);
|
||||
return mapAssetCountByTimeBucket(result);
|
||||
}
|
||||
|
||||
@@ -627,56 +618,6 @@ export class AssetService {
|
||||
return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
|
||||
}
|
||||
|
||||
private async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
|
||||
const sharedLinkId = authUser.sharedLinkId;
|
||||
|
||||
for (const assetId of assetIds) {
|
||||
if (sharedLinkId) {
|
||||
const canAccess = await this.accessRepository.hasSharedLinkAssetAccess(sharedLinkId, assetId);
|
||||
if (canAccess) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const isOwner = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
|
||||
if (isOwner) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mustBeOwner) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const isPartnerShared = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
|
||||
if (isPartnerShared) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isAlbumShared = await this.accessRepository.hasAlbumAssetAccess(authUser.id, assetId);
|
||||
if (isAlbumShared) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
private async checkUserAccess(authUser: AuthUserDto, userId: string) {
|
||||
// Check if userId shares assets with authUser
|
||||
const canAccess = await this.accessRepository.hasPartnerAccess(authUser.id, userId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
private checkDownloadAccess(authUser: AuthUserDto) {
|
||||
if (authUser.isPublicUser && !authUser.isAllowDownload) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
|
||||
getExifPermission(authUser: AuthUserDto) {
|
||||
return !authUser.isPublicUser || authUser.isShowExif;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user