feat(server): Per user asset access control (#993)

* Limit asset access to owner

* Check public albums for asset

* Clean up

* Fix test

* Rename repository method

* Simplify control flow

* Revert "Simplify control flow"

This reverts commit 7bc3cbf687.

* Revert Makefile change
This commit is contained in:
Matthias Rupp 2022-12-04 18:42:36 +01:00 committed by GitHub
parent 5f2b75997f
commit e8bbad6772
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 84 additions and 16 deletions

View file

@ -26,4 +26,4 @@ prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate
cd ./server && npm run api:generate

View file

@ -25,6 +25,7 @@ export interface IAlbumRepository {
updateAlbum(album: AlbumEntity, updateAlbumDto: UpdateAlbumDto): Promise<AlbumEntity>;
getListByAssetId(userId: string, assetId: string): Promise<AlbumEntity[]>;
getCountByUserId(userId: string): Promise<AlbumCountResponseDto>;
getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number>;
}
export const ALBUM_REPOSITORY = 'ALBUM_REPOSITORY';
@ -283,4 +284,17 @@ export class AlbumRepository implements IAlbumRepository {
return this.albumRepository.save(album);
}
async getSharedWithUserAlbumCount(userId: string, assetId: string): Promise<number> {
const result = await this
.userAlbumRepository
.createQueryBuilder('usa')
.select('count(aa)', 'count')
.innerJoin('asset_album', 'aa', 'aa.albumId = usa.albumId')
.where('aa.assetId = :assetId', { assetId })
.andWhere('usa.sharedUserId = :userId', { userId })
.getRawOne();
return result.count;
}
}

View file

@ -123,6 +123,7 @@ describe('Album service', () => {
updateAlbum: jest.fn(),
getListByAssetId: jest.fn(),
getCountByUserId: jest.fn(),
getSharedWithUserAlbumCount: jest.fn(),
};
assetRepositoryMock = {
@ -142,6 +143,7 @@ describe('Album service', () => {
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
downloadServiceMock = {

View file

@ -43,6 +43,7 @@ export interface IAssetRepository {
userId: string,
checkDuplicateAssetDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto>;
countByIdAndUser(assetId: string, userId: string): Promise<number>;
}
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
@ -343,4 +344,13 @@ export class AssetRepository implements IAssetRepository {
});
return new CheckExistingAssetsResponseDto(existingAssets.map((a) => a.deviceAssetId));
}
async countByIdAndUser(assetId: string, userId: string): Promise<number> {
return await this.assetRepository.count({
where: {
id: assetId,
userId
}
});
}
}

View file

@ -21,7 +21,7 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { assetUploadOption } from '../../config/asset-upload.config';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { ServeFileDto } from './dto/serve-file.dto';
import { Response as Res} from 'express';
import { Response as Res } from 'express';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
@ -86,10 +86,12 @@ export class AssetController {
@Get('/download/:assetId')
async downloadFile(
@GetAuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.downloadFile(query, assetId, res);
}
@ -110,22 +112,26 @@ export class AssetController {
@Get('/file/:assetId')
@Header('Cache-Control', 'max-age=3600')
async serveFile(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param('assetId') assetId: string,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.serveFile(assetId, query, res, headers);
}
@Get('/thumbnail/:assetId')
@Header('Cache-Control', 'max-age=3600')
async getAssetThumbnail(
@GetAuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@Response({ passthrough: true }) res: Res,
@Param('assetId') assetId: string,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
): Promise<any> {
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return this.assetService.getAssetThumbnail(assetId, query, res, headers);
}
@ -203,7 +209,8 @@ export class AssetController {
@GetAuthUser() authUser: AuthUserDto,
@Param('assetId') assetId: string,
): Promise<AssetResponseDto> {
return await this.assetService.getAssetById(authUser, assetId);
await this.assetService.checkAssetsAccess(authUser, [assetId]);
return await this.assetService.getAssetById(assetId);
}
/**
@ -215,7 +222,8 @@ export class AssetController {
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetDto,
): Promise<AssetResponseDto> {
return await this.assetService.updateAssetById(authUser, assetId, dto);
await this.assetService.checkAssetsAccess(authUser, [assetId], true);
return await this.assetService.updateAssetById(assetId, dto);
}
@Delete('/')
@ -223,17 +231,19 @@ export class AssetController {
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) assetIds: DeleteAssetDto,
): Promise<DeleteAssetResponseDto[]> {
await this.assetService.checkAssetsAccess(authUser, assetIds.ids, true);
const deleteAssetList: AssetResponseDto[] = [];
for (const id of assetIds.ids) {
const assets = await this.assetService.getAssetById(authUser, id);
const assets = await this.assetService.getAssetById(id);
if (!assets) {
continue;
}
deleteAssetList.push(assets);
if (assets.livePhotoVideoId) {
const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
const livePhotoVideo = await this.assetService.getAssetById(assets.livePhotoVideoId);
if (livePhotoVideo) {
deleteAssetList.push(livePhotoVideo);
assetIds.ids = [...assetIds.ids, livePhotoVideo.id];
@ -241,7 +251,7 @@ export class AssetController {
}
}
const result = await this.assetService.deleteAssetById(authUser, assetIds);
const result = await this.assetService.deleteAssetById(assetIds);
result.forEach((res) => {
deleteAssetList.filter((a) => a.id == res.id && res.status == DeleteAssetStatusEnum.SUCCESS);

View file

@ -10,13 +10,18 @@ import { CommunicationModule } from '../communication/communication.module';
import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { ALBUM_REPOSITORY, AlbumRepository } from '../album/album-repository';
import { AlbumEntity } from '@app/database/entities/album.entity';
import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
import { UserEntity } from '@app/database/entities/user.entity';
import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
@Module({
imports: [
CommunicationModule,
BackgroundTaskModule,
DownloadModule,
TypeOrmModule.forFeature([AssetEntity]),
TypeOrmModule.forFeature([AssetEntity, AlbumEntity, UserAlbumEntity, UserEntity, AssetAlbumEntity]),
BullModule.registerQueue({
name: QueueNameEnum.ASSET_UPLOADED,
defaultJobOptions: {
@ -42,6 +47,10 @@ import { DownloadModule } from '../../modules/download/download.module';
provide: ASSET_REPOSITORY,
useClass: AssetRepository,
},
{
provide: ALBUM_REPOSITORY,
useClass: AlbumRepository,
},
],
exports: [AssetService],
})

View file

@ -11,11 +11,13 @@ import { DownloadService } from '../../modules/download/download.service';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull';
import { IAlbumRepository } from "../album/album-repository";
describe('AssetService', () => {
let sui: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
@ -122,6 +124,7 @@ describe('AssetService', () => {
getAssetWithNoThumbnail: jest.fn(),
getAssetWithNoSmartInfo: jest.fn(),
getExistingAssets: jest.fn(),
countByIdAndUser: jest.fn(),
};
downloadServiceMock = {
@ -130,6 +133,7 @@ describe('AssetService', () => {
sui = new AssetService(
assetRepositoryMock,
albumRepositoryMock,
a,
backgroundTaskServiceMock,
assetUploadedQueueMock,

View file

@ -54,6 +54,7 @@ import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
const fileInfo = promisify(stat);
@ -63,6 +64,9 @@ export class AssetService {
@Inject(ASSET_REPOSITORY)
private _assetRepository: IAssetRepository,
@Inject(ALBUM_REPOSITORY)
private _albumRepository: IAlbumRepository,
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
@ -221,22 +225,18 @@ export class AssetService {
return assets.map((asset) => mapAsset(asset));
}
public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
public async getAssetById(assetId: string): Promise<AssetResponseDto> {
const asset = await this._assetRepository.getById(assetId);
return mapAsset(asset);
}
public async updateAssetById(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
public async updateAssetById(assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
const asset = await this._assetRepository.getById(assetId);
if (!asset) {
throw new BadRequestException('Asset not found');
}
if (authUser.id !== asset.userId) {
throw new ForbiddenException('Not the owner');
}
const updatedAsset = await this._assetRepository.update(asset, dto);
return mapAsset(updatedAsset);
@ -496,14 +496,13 @@ export class AssetService {
}
}
public async deleteAssetById(authUser: AuthUserDto, assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
public async deleteAssetById(assetIds: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
const result: DeleteAssetResponseDto[] = [];
const target = assetIds.ids;
for (const assetId of target) {
const res = await this.assetRepository.delete({
id: assetId,
userId: authUser.id,
});
if (res.affected) {
@ -642,6 +641,26 @@ export class AssetService {
getAssetCountByUserId(authUser: AuthUserDto): Promise<AssetCountByUserIdResponseDto> {
return this._assetRepository.getAssetCountByUserId(authUser.id);
}
async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
for (const assetId of assetIds) {
// Step 1: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue;
}
//TODO: Step 3: Check if asset is part of a public album
}
throw new ForbiddenException();
}
}
}
async function processETag(path: string, res: Res, headers: Record<string, string>): Promise<boolean> {