feat(server): granular permissions for api keys (#11824)

feat(server): api auth permissions
This commit is contained in:
Jason Rasmussen
2024-08-16 09:48:43 -04:00
committed by GitHub
parent a372b56d44
commit f230b3aa42
43 changed files with 817 additions and 135 deletions

View File

@@ -9,6 +9,7 @@ import {
ActivityStatisticsResponseDto,
} from 'src/dtos/activity.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ActivityService } from 'src/services/activity.service';
import { UUIDParamDto } from 'src/validation';
@@ -19,19 +20,19 @@ export class ActivityController {
constructor(private service: ActivityService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_READ })
getActivities(@Auth() auth: AuthDto, @Query() dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Get('statistics')
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_STATISTICS })
getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
return this.service.getStatistics(auth, dto);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_CREATE })
async createActivity(
@Auth() auth: AuthDto,
@Body() dto: ActivityCreateDto,
@@ -46,7 +47,7 @@ export class ActivityController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.ACTIVITY_DELETE })
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -12,6 +12,7 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
@@ -22,24 +23,24 @@ export class AlbumController {
constructor(private service: AlbumService) {}
@Get('count')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_STATISTICS })
getAlbumCount(@Auth() auth: AuthDto): Promise<AlbumCountResponseDto> {
return this.service.getCount(auth);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_READ })
getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(auth, query);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_CREATE })
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {
return this.service.create(auth, dto);
}
@Authenticated({ sharedLink: true })
@Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true })
@Get(':id')
getAlbumInfo(
@Auth() auth: AuthDto,
@@ -50,7 +51,7 @@ export class AlbumController {
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_UPDATE })
updateAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -60,7 +61,7 @@ export class AlbumController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.ALBUM_DELETE })
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put }
import { ApiTags } from '@nestjs/swagger';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { APIKeyService } from 'src/services/api-key.service';
import { UUIDParamDto } from 'src/validation';
@@ -12,25 +13,25 @@ export class APIKeyController {
constructor(private service: APIKeyService) {}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_CREATE })
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_READ })
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_READ })
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_UPDATE })
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -41,7 +42,7 @@ export class APIKeyController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.API_KEY_DELETE })
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
import { UUIDParamDto } from 'src/validation';
@@ -12,13 +13,13 @@ export class FaceController {
constructor(private service: PersonService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.FACE_READ })
getFaces(@Auth() auth: AuthDto, @Query() dto: FaceDto): Promise<AssetFaceResponseDto[]> {
return this.service.getFacesById(auth, dto);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.FACE_UPDATE })
reassignFacesById(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -9,6 +9,7 @@ import {
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { LibraryService } from 'src/services/library.service';
import { UUIDParamDto } from 'src/validation';
@@ -19,25 +20,25 @@ export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_READ, admin: true })
getAllLibraries(): Promise<LibraryResponseDto[]> {
return this.service.getAll();
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true })
createLibrary(@Body() dto: CreateLibraryDto): Promise<LibraryResponseDto> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_UPDATE, admin: true })
updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
return this.service.update(id, dto);
}
@Get(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_READ, admin: true })
getLibrary(@Param() { id }: UUIDParamDto): Promise<LibraryResponseDto> {
return this.service.get(id);
}
@@ -52,13 +53,13 @@ export class LibraryController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_DELETE, admin: true })
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Get(':id/statistics')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true })
getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise<LibraryStatsResponseDto> {
return this.service.getStatistics(id);
}

View File

@@ -3,6 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MemoryService } from 'src/services/memory.service';
import { UUIDParamDto } from 'src/validation';
@@ -13,25 +14,25 @@ export class MemoryController {
constructor(private service: MemoryService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_READ })
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
return this.service.search(auth);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_CREATE })
createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise<MemoryResponseDto> {
return this.service.create(auth, dto);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_READ })
getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MemoryResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_UPDATE })
updateMemory(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -42,7 +43,7 @@ export class MemoryController {
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated()
@Authenticated({ permission: Permission.MEMORY_DELETE })
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { Permission } from 'src/enum';
import { PartnerDirection } from 'src/interfaces/partner.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PartnerService } from 'src/services/partner.service';
@@ -14,20 +15,20 @@ export class PartnerController {
@Get()
@ApiQuery({ name: 'direction', type: 'string', enum: PartnerDirection, required: true })
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_READ })
// TODO: remove 'direction' and convert to full query dto
getPartners(@Auth() auth: AuthDto, @Query() dto: PartnerSearchDto): Promise<PartnerResponseDto[]> {
return this.service.search(auth, dto);
}
@Post(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_CREATE })
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
return this.service.create(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_UPDATE })
updatePartner(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -37,7 +38,7 @@ export class PartnerController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.PARTNER_DELETE })
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -16,6 +16,7 @@ import {
PersonStatisticsResponseDto,
PersonUpdateDto,
} from 'src/dtos/person.dto';
import { Permission } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { PersonService } from 'src/services/person.service';
@@ -31,31 +32,31 @@ export class PersonController {
) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
getAllPeople(@Auth() auth: AuthDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
return this.service.getAll(auth, withHidden);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_CREATE })
createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.service.create(auth, dto);
}
@Put()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_UPDATE })
updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updateAll(auth, dto);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(auth, id);
}
@Put(':id')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_UPDATE })
updatePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -65,14 +66,14 @@ export class PersonController {
}
@Get(':id/statistics')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_STATISTICS })
getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated()
@Authenticated({ permission: Permission.PERSON_READ })
async getPersonThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@@ -90,7 +91,7 @@ export class PersonController {
}
@Put(':id/reassign')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_REASSIGN })
reassignFaces(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -100,7 +101,7 @@ export class PersonController {
}
@Post(':id/merge')
@Authenticated()
@Authenticated({ permission: Permission.PERSON_MERGE })
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -10,6 +10,7 @@ import {
SharedLinkPasswordDto,
SharedLinkResponseDto,
} from 'src/dtos/shared-link.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
@@ -22,7 +23,7 @@ export class SharedLinkController {
constructor(private service: SharedLinkService) {}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_READ })
getAllSharedLinks(@Auth() auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.service.getAll(auth);
}
@@ -48,19 +49,19 @@ export class SharedLinkController {
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_READ })
getSharedLinkById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<SharedLinkResponseDto> {
return this.service.get(auth, id);
}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_CREATE })
createSharedLink(@Auth() auth: AuthDto, @Body() dto: SharedLinkCreateDto) {
return this.service.create(auth, dto);
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_UPDATE })
updateSharedLink(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -70,7 +71,7 @@ export class SharedLinkController {
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.SHARED_LINK_DELETE })
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemConfigService } from 'src/services/system-config.service';
@@ -10,25 +11,25 @@ export class SystemConfigController {
constructor(private service: SystemConfigService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getConfig(): Promise<SystemConfigDto> {
return this.service.getConfig();
}
@Get('defaults')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getConfigDefaults(): SystemConfigDto {
return this.service.getDefaults();
}
@Put()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_UPDATE, admin: true })
updateConfig(@Body() dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.service.updateConfig(dto);
}
@Get('storage-template-options')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_CONFIG_READ, admin: true })
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.service.getStorageTemplateOptions();
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@@ -10,20 +11,20 @@ export class SystemMetadataController {
constructor(private service: SystemMetadataService) {}
@Get('admin-onboarding')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true })
getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
return this.service.getAdminOnboarding();
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_UPDATE, admin: true })
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
@Get('reverse-geocoding-state')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true })
getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
return this.service.getReverseGeocodingState();
}

View File

@@ -5,6 +5,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
import { UUIDParamDto } from 'src/validation';
@@ -15,31 +16,31 @@ export class TagController {
constructor(private service: TagService) {}
@Post()
@Authenticated()
@Authenticated({ permission: Permission.TAG_CREATE })
createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated()
@Authenticated({ permission: Permission.TAG_READ })
getAllTags(@Auth() auth: AuthDto): Promise<TagResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_READ })
getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
return this.service.getById(auth, id);
}
@Patch(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_UPDATE })
updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated()
@Authenticated({ permission: Permission.TAG_DELETE })
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -9,6 +9,7 @@ import {
UserAdminSearchDto,
UserAdminUpdateDto,
} from 'src/dtos/user.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { UserAdminService } from 'src/services/user-admin.service';
import { UUIDParamDto } from 'src/validation';
@@ -19,25 +20,25 @@ export class UserAdminController {
constructor(private service: UserAdminService) {}
@Get()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
searchUsersAdmin(@Auth() auth: AuthDto, @Query() dto: UserAdminSearchDto): Promise<UserAdminResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true })
createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise<UserAdminResponseDto> {
return this.service.create(createUserDto);
}
@Get(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true })
updateUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -47,7 +48,7 @@ export class UserAdminController {
}
@Delete(':id')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true })
deleteUserAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -57,13 +58,13 @@ export class UserAdminController {
}
@Get(':id/preferences')
@Authenticated()
@Authenticated({ permission: Permission.ADMIN_USER_READ, admin: true })
getUserPreferencesAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserPreferencesResponseDto> {
return this.service.getPreferences(auth, id);
}
@Put(':id/preferences')
@Authenticated()
@Authenticated({ permission: Permission.ADMIN_USER_UPDATE, admin: true })
updateUserPreferencesAdmin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -73,7 +74,7 @@ export class UserAdminController {
}
@Post(':id/restore')
@Authenticated({ admin: true })
@Authenticated({ permission: Permission.ADMIN_USER_DELETE, admin: true })
restoreUserAdmin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserAdminResponseDto> {
return this.service.restore(auth, id);
}

View File

@@ -256,7 +256,7 @@ export class AccessCore {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
case Permission.MEMORY_WRITE: {
case Permission.MEMORY_UPDATE: {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids);
}
@@ -272,7 +272,7 @@ export class AccessCore {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PERSON_WRITE: {
case Permission.PERSON_UPDATE: {
return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
}

View File

@@ -1,10 +1,17 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Permission } from 'src/enum';
import { Optional } from 'src/validation';
export class APIKeyCreateDto {
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@IsEnum(Permission, { each: true })
@ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true })
@ArrayMinSize(1)
permissions!: Permission[];
}
export class APIKeyUpdateDto {
@@ -23,4 +30,6 @@ export class APIKeyResponseDto {
name!: string;
createdAt!: Date;
updatedAt!: Date;
@ApiProperty({ enum: Permission, enumName: 'Permission', isArray: true })
permissions!: Permission[];
}

View File

@@ -1,4 +1,5 @@
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('api_keys')
@@ -18,6 +19,9 @@ export class APIKeyEntity {
@Column()
userId!: string;
@Column({ array: true, type: 'varchar' })
permissions!: Permission[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;

View File

@@ -32,8 +32,18 @@ export enum MemoryType {
}
export enum Permission {
ALL = 'all',
ACTIVITY_CREATE = 'activity.create',
ACTIVITY_READ = 'activity.read',
ACTIVITY_UPDATE = 'activity.update',
ACTIVITY_DELETE = 'activity.delete',
ACTIVITY_STATISTICS = 'activity.statistics',
API_KEY_CREATE = 'apiKey.create',
API_KEY_READ = 'apiKey.read',
API_KEY_UPDATE = 'apiKey.update',
API_KEY_DELETE = 'apiKey.delete',
// ASSET_CREATE = 'asset.create',
ASSET_READ = 'asset.read',
@@ -45,10 +55,12 @@ export enum Permission {
ASSET_DOWNLOAD = 'asset.download',
ASSET_UPLOAD = 'asset.upload',
// ALBUM_CREATE = 'album.create',
ALBUM_CREATE = 'album.create',
ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ALBUM_STATISTICS = 'album.statistics',
ALBUM_ADD_ASSET = 'album.addAsset',
ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_SHARE = 'album.share',
@@ -58,20 +70,58 @@ export enum Permission {
ARCHIVE_READ = 'archive.read',
FACE_CREATE = 'face.create',
FACE_READ = 'face.read',
FACE_UPDATE = 'face.update',
FACE_DELETE = 'face.delete',
LIBRARY_CREATE = 'library.create',
LIBRARY_READ = 'library.read',
LIBRARY_UPDATE = 'library.update',
LIBRARY_DELETE = 'library.delete',
LIBRARY_STATISTICS = 'library.statistics',
TIMELINE_READ = 'timeline.read',
TIMELINE_DOWNLOAD = 'timeline.download',
MEMORY_CREATE = 'memory.create',
MEMORY_READ = 'memory.read',
MEMORY_WRITE = 'memory.write',
MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete',
PERSON_READ = 'person.read',
PERSON_WRITE = 'person.write',
PERSON_MERGE = 'person.merge',
PARTNER_CREATE = 'partner.create',
PARTNER_READ = 'partner.read',
PARTNER_UPDATE = 'partner.update',
PARTNER_DELETE = 'partner.delete',
PERSON_CREATE = 'person.create',
PERSON_READ = 'person.read',
PERSON_UPDATE = 'person.update',
PERSON_DELETE = 'person.delete',
PERSON_STATISTICS = 'person.statistics',
PERSON_MERGE = 'person.merge',
PERSON_REASSIGN = 'person.reassign',
PARTNER_UPDATE = 'partner.update',
SHARED_LINK_CREATE = 'sharedLink.create',
SHARED_LINK_READ = 'sharedLink.read',
SHARED_LINK_UPDATE = 'sharedLink.update',
SHARED_LINK_DELETE = 'sharedLink.delete',
SYSTEM_CONFIG_READ = 'systemConfig.read',
SYSTEM_CONFIG_UPDATE = 'systemConfig.update',
SYSTEM_METADATA_READ = 'systemMetadata.read',
SYSTEM_METADATA_UPDATE = 'systemMetadata.update',
TAG_CREATE = 'tag.create',
TAG_READ = 'tag.read',
TAG_UPDATE = 'tag.update',
TAG_DELETE = 'tag.delete',
ADMIN_USER_CREATE = 'admin.user.create',
ADMIN_USER_READ = 'admin.user.read',
ADMIN_USER_UPDATE = 'admin.user.update',
ADMIN_USER_DELETE = 'admin.user.delete',
}
export enum SharedLinkType {

View File

@@ -11,6 +11,7 @@ import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UAParser } from 'ua-parser-js';
@@ -25,7 +26,7 @@ export enum Metadata {
type AdminRoute = { admin?: true };
type SharedLinkRoute = { sharedLink?: true };
type AuthenticatedOptions = AdminRoute | SharedLinkRoute;
type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute);
export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator => {
const decorators: MethodDecorator[] = [
@@ -89,13 +90,17 @@ export class AuthGuard implements CanActivate {
return true;
}
const { admin: adminRoute, sharedLink: sharedLinkRoute } = { sharedLink: false, admin: false, ...options };
const {
admin: adminRoute,
sharedLink: sharedLinkRoute,
permission,
} = { sharedLink: false, admin: false, ...options };
const request = context.switchToHttp().getRequest<AuthRequest>();
request.user = await this.authService.authenticate({
headers: request.headers,
queryParams: request.query as Record<string, string>,
metadata: { adminRoute, sharedLinkRoute, uri: request.path },
metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path },
});
return true;

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddApiKeyPermissions1723719333525 implements MigrationInterface {
name = 'AddApiKeyPermissions1723719333525';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "api_keys" ADD "permissions" character varying array NOT NULL DEFAULT '{all}'`);
await queryRunner.query(`ALTER TABLE "api_keys" ALTER COLUMN "permissions" DROP DEFAULT`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "permissions"`);
}
}

View File

@@ -9,6 +9,7 @@ FROM
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."key" AS "APIKeyEntity_key",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity__APIKeyEntity_user"."id" AS "APIKeyEntity__APIKeyEntity_user_id",
"APIKeyEntity__APIKeyEntity_user"."name" AS "APIKeyEntity__APIKeyEntity_user_name",
"APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin",
@@ -46,6 +47,7 @@ SELECT
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."name" AS "APIKeyEntity_name",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt",
"APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt"
FROM
@@ -63,6 +65,7 @@ SELECT
"APIKeyEntity"."id" AS "APIKeyEntity_id",
"APIKeyEntity"."name" AS "APIKeyEntity_name",
"APIKeyEntity"."userId" AS "APIKeyEntity_userId",
"APIKeyEntity"."permissions" AS "APIKeyEntity_permissions",
"APIKeyEntity"."createdAt" AS "APIKeyEntity_createdAt",
"APIKeyEntity"."updatedAt" AS "APIKeyEntity_updatedAt"
FROM

View File

@@ -31,6 +31,7 @@ export class ApiKeyRepository implements IKeyRepository {
id: true,
key: true,
userId: true,
permissions: true,
},
where: { key: hashedToken },
relations: {

View File

@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service';
@@ -22,10 +23,11 @@ describe(APIKeyService.name, () => {
describe('create', () => {
it('should create a new key', async () => {
keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, { name: 'Test Key' });
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] });
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'Test Key',
permissions: [Permission.ALL],
userId: authStub.admin.user.id,
});
expect(cryptoMock.newPassword).toHaveBeenCalled();
@@ -35,11 +37,12 @@ describe(APIKeyService.name, () => {
it('should not require a name', async () => {
keyMock.create.mockResolvedValue(keyStub.admin);
await sut.create(authStub.admin, {});
await sut.create(authStub.admin, { permissions: [Permission.ALL] });
expect(keyMock.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)',
name: 'API Key',
permissions: [Permission.ALL],
userId: authStub.admin.user.id,
});
expect(cryptoMock.newPassword).toHaveBeenCalled();

View File

@@ -1,9 +1,10 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto } from 'src/dtos/api-key.dto';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { isGranted } from 'src/utils/access';
@Injectable()
export class APIKeyService {
@@ -14,16 +15,22 @@ export class APIKeyService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.crypto.newPassword(32);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.repository.create({
key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
});
return { secret, apiKey: this.map(entity) };
}
async update(auth: AuthDto, id: string, dto: APIKeyCreateDto): Promise<APIKeyResponseDto> {
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
const exists = await this.repository.getById(auth.user.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
@@ -62,6 +69,7 @@ export class APIKeyService {
name: entity.name,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
permissions: entity.permissions,
};
}
}

View File

@@ -31,6 +31,7 @@ import {
} from 'src/dtos/auth.dto';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@@ -38,6 +39,7 @@ import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
export interface LoginDetails {
@@ -61,6 +63,7 @@ export type ValidateRequest = {
metadata: {
sharedLinkRoute: boolean;
adminRoute: boolean;
permission?: Permission;
uri: string;
};
};
@@ -157,7 +160,7 @@ export class AuthService {
async authenticate({ headers, queryParams, metadata }: ValidateRequest): Promise<AuthDto> {
const authDto = await this.validate({ headers, queryParams });
const { adminRoute, sharedLinkRoute, uri } = metadata;
const { adminRoute, sharedLinkRoute, permission, uri } = metadata;
if (!authDto.user.isAdmin && adminRoute) {
this.logger.warn(`Denied access to admin only route: ${uri}`);
@@ -169,6 +172,10 @@ export class AuthService {
throw new ForbiddenException('Forbidden');
}
if (authDto.apiKey && permission && !isGranted({ requested: [permission], current: authDto.apiKey.permissions })) {
throw new ForbiddenException(`Missing required permission: ${permission}`);
}
return authDto;
}

View File

@@ -50,7 +50,7 @@ export class MemoryService {
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
const memory = await this.repository.update({
id,
@@ -82,7 +82,7 @@ export class MemoryService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await this.access.requirePermission(auth, Permission.MEMORY_WRITE, id);
await this.access.requirePermission(auth, Permission.MEMORY_UPDATE, id);
const repos = { accessRepository: this.accessRepository, repository: this.repository };
const results = await removeAssets(auth, repos, {

View File

@@ -113,7 +113,7 @@ export class PersonService {
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
@@ -142,7 +142,7 @@ export class PersonService {
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, personId);
await this.access.requirePermission(auth, Permission.PERSON_CREATE, dto.id);
const face = await this.repository.getFaceById(dto.id);
@@ -226,7 +226,7 @@ export class PersonService {
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly
@@ -581,7 +581,7 @@ export class PersonService {
throw new BadRequestException('Cannot merge a person into themselves');
}
await this.access.requirePermission(auth, Permission.PERSON_WRITE, id);
await this.access.requirePermission(auth, Permission.PERSON_UPDATE, id);
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;

View File

@@ -0,0 +1,15 @@
import { Permission } from 'src/enum';
import { setIsSuperset } from 'src/utils/set';
export type GrantedRequest = {
requested: Permission[];
current: Permission[];
};
export const isGranted = ({ requested, current }: GrantedRequest) => {
if (current.includes(Permission.ALL)) {
return true;
}
return setIsSuperset(new Set(current), new Set(requested));
};