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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 498 additions and 131 deletions

View file

@ -1,12 +1,12 @@
import { LoginResponseDto, createApiKey } from '@immich/sdk';
import { LoginResponseDto, Permission, createApiKey } from '@immich/sdk';
import { createUserDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
const create = (accessToken: string) =>
createApiKey({ apiKeyCreateDto: { name: 'api key' } }, { headers: asBearerAuth(accessToken) });
const create = (accessToken: string, permissions: Permission[]) =>
createApiKey({ apiKeyCreateDto: { name: 'api key', permissions } }, { headers: asBearerAuth(accessToken) });
describe('/api-keys', () => {
let admin: LoginResponseDto;
@ -30,15 +30,65 @@ describe('/api-keys', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should not work without permission', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]);
const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.missingPermission('apiKey.create'));
});
it('should work with apiKey.create', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate, Permission.ApiKeyRead]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({
name: 'API Key',
permissions: [Permission.ApiKeyRead],
});
expect(body).toEqual({
secret: expect.any(String),
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.ApiKeyRead],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
});
expect(status).toBe(201);
});
it('should not create an api key with all permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.All] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should not create an api key with more permissions', async () => {
const { secret } = await create(user.accessToken, [Permission.ApiKeyCreate]);
const { status, body } = await request(app)
.post('/api-keys')
.set('x-api-key', secret)
.send({ name: 'API Key', permissions: [Permission.ApiKeyRead] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Cannot grant permissions you do not have'));
});
it('should create an api key', async () => {
const { status, body } = await request(app)
.post('/api-keys')
.send({ name: 'API Key' })
.send({ name: 'API Key', permissions: [Permission.All] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual({
apiKey: {
id: expect.any(String),
name: 'API Key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
},
@ -63,9 +113,9 @@ describe('/api-keys', () => {
it('should return a list of api keys', async () => {
const [{ apiKey: apiKey1 }, { apiKey: apiKey2 }, { apiKey: apiKey3 }] = await Promise.all([
create(admin.accessToken),
create(admin.accessToken),
create(admin.accessToken),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
create(admin.accessToken, [Permission.All]),
]);
const { status, body } = await request(app).get('/api-keys').set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(3);
@ -82,7 +132,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -99,7 +149,7 @@ describe('/api-keys', () => {
});
it('should get api key details', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.get(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
@ -107,6 +157,7 @@ describe('/api-keys', () => {
expect(body).toEqual({
id: expect.any(String),
name: 'api key',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
@ -121,7 +172,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
@ -140,7 +191,7 @@ describe('/api-keys', () => {
});
it('should update api key details', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.put(`/api-keys/${apiKey.id}`)
.send({ name: 'new name' })
@ -149,6 +200,7 @@ describe('/api-keys', () => {
expect(body).toEqual({
id: expect.any(String),
name: 'new name',
permissions: [Permission.All],
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
@ -163,7 +215,7 @@ describe('/api-keys', () => {
});
it('should require authorization', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
@ -180,7 +232,7 @@ describe('/api-keys', () => {
});
it('should delete an api key', async () => {
const { apiKey } = await create(user.accessToken);
const { apiKey } = await create(user.accessToken, [Permission.All]);
const { status } = await request(app)
.delete(`/api-keys/${apiKey.id}`)
.set('Authorization', `Bearer ${user.accessToken}`);
@ -190,14 +242,14 @@ describe('/api-keys', () => {
describe('authentication', () => {
it('should work as a header', async () => {
const { secret } = await create(admin.accessToken);
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get('/api-keys').set('x-api-key', secret);
expect(body).toHaveLength(1);
expect(status).toBe(200);
});
it('should work as a query param', async () => {
const { secret } = await create(admin.accessToken);
const { secret } = await create(user.accessToken, [Permission.All]);
const { status, body } = await request(app).get(`/api-keys?apiKey=${secret}`);
expect(body).toHaveLength(1);
expect(status).toBe(200);

View file

@ -1,3 +1,4 @@
import { Permission } from '@immich/sdk';
import { stat } from 'node:fs/promises';
import { app, immichCli, utils } from 'src/utils';
import { beforeEach, describe, expect, it } from 'vitest';
@ -29,7 +30,7 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283/api',
@ -46,7 +47,7 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken);
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2283',

View file

@ -13,6 +13,12 @@ export const errorDto = {
message: expect.any(String),
correlationId: expect.any(String),
},
missingPermission: (permission: string) => ({
error: 'Forbidden',
statusCode: 403,
message: `Missing required permission: ${permission}`,
correlationId: expect.any(String),
}),
wrongPassword: {
error: 'Bad Request',
statusCode: 400,

View file

@ -7,6 +7,7 @@ import {
CreateAlbumDto,
CreateLibraryDto,
MetadataSearchDto,
Permission,
PersonCreateDto,
SharedLinkCreateDto,
UserAdminCreateDto,
@ -279,8 +280,8 @@ export const utils = {
});
},
createApiKey: (accessToken: string) => {
return createApiKey({ apiKeyCreateDto: { name: 'e2e' } }, { headers: asBearerAuth(accessToken) });
createApiKey: (accessToken: string, permissions: Permission[]) => {
return createApiKey({ apiKeyCreateDto: { name: 'e2e', permissions } }, { headers: asBearerAuth(accessToken) });
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
@ -492,7 +493,7 @@ export const utils = {
},
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken);
const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, `${key.secret}`]);
return key.secret;
},

View file

@ -20,7 +20,7 @@ import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart';
import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart';
final backupServiceProvider = Provider(
@ -213,7 +213,7 @@ class BackupService {
_appSetting.getSetting(AppSettingsEnum.ignoreIcloudAssets);
if (Platform.isAndroid &&
!(await Permission.accessMediaLocation.status).isGranted) {
!(await pm.Permission.accessMediaLocation.status).isGranted) {
// double check that permission is granted here, to guard against
// uploading corrupt assets without EXIF information
_log.warning("Media location permission is not granted. "

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/permission.dart generated Normal file

Binary file not shown.

View file

@ -7135,8 +7135,17 @@
"properties": {
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
}
},
"required": [
"permissions"
],
"type": "object"
},
"APIKeyCreateResponseDto": {
@ -7166,6 +7175,12 @@
"name": {
"type": "string"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
},
"updatedAt": {
"format": "date-time",
"type": "string"
@ -7175,6 +7190,7 @@
"createdAt",
"id",
"name",
"permissions",
"updatedAt"
],
"type": "object"
@ -9729,6 +9745,82 @@
],
"type": "object"
},
"Permission": {
"enum": [
"all",
"activity.create",
"activity.read",
"activity.update",
"activity.delete",
"activity.statistics",
"apiKey.create",
"apiKey.read",
"apiKey.update",
"apiKey.delete",
"asset.read",
"asset.update",
"asset.delete",
"asset.restore",
"asset.share",
"asset.view",
"asset.download",
"asset.upload",
"album.create",
"album.read",
"album.update",
"album.delete",
"album.statistics",
"album.addAsset",
"album.removeAsset",
"album.share",
"album.download",
"authDevice.delete",
"archive.read",
"face.create",
"face.read",
"face.update",
"face.delete",
"library.create",
"library.read",
"library.update",
"library.delete",
"library.statistics",
"timeline.read",
"timeline.download",
"memory.create",
"memory.read",
"memory.update",
"memory.delete",
"partner.create",
"partner.read",
"partner.update",
"partner.delete",
"person.create",
"person.read",
"person.update",
"person.delete",
"person.statistics",
"person.merge",
"person.reassign",
"sharedLink.create",
"sharedLink.read",
"sharedLink.update",
"sharedLink.delete",
"systemConfig.read",
"systemConfig.update",
"systemMetadata.read",
"systemMetadata.update",
"tag.create",
"tag.read",
"tag.update",
"tag.delete",
"admin.user.create",
"admin.user.read",
"admin.user.update",
"admin.user.delete"
],
"type": "string"
},
"PersonCreateDto": {
"properties": {
"birthDate": {

View file

@ -299,10 +299,12 @@ export type ApiKeyResponseDto = {
createdAt: string;
id: string;
name: string;
permissions: Permission[];
updatedAt: string;
};
export type ApiKeyCreateDto = {
name?: string;
permissions: Permission[];
};
export type ApiKeyCreateResponseDto = {
apiKey: ApiKeyResponseDto;
@ -3125,6 +3127,79 @@ export enum Error {
NotFound = "not_found",
Unknown = "unknown"
}
export enum Permission {
All = "all",
ActivityCreate = "activity.create",
ActivityRead = "activity.read",
ActivityUpdate = "activity.update",
ActivityDelete = "activity.delete",
ActivityStatistics = "activity.statistics",
ApiKeyCreate = "apiKey.create",
ApiKeyRead = "apiKey.read",
ApiKeyUpdate = "apiKey.update",
ApiKeyDelete = "apiKey.delete",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetDelete = "asset.delete",
AssetRestore = "asset.restore",
AssetShare = "asset.share",
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AlbumCreate = "album.create",
AlbumRead = "album.read",
AlbumUpdate = "album.update",
AlbumDelete = "album.delete",
AlbumStatistics = "album.statistics",
AlbumAddAsset = "album.addAsset",
AlbumRemoveAsset = "album.removeAsset",
AlbumShare = "album.share",
AlbumDownload = "album.download",
AuthDeviceDelete = "authDevice.delete",
ArchiveRead = "archive.read",
FaceCreate = "face.create",
FaceRead = "face.read",
FaceUpdate = "face.update",
FaceDelete = "face.delete",
LibraryCreate = "library.create",
LibraryRead = "library.read",
LibraryUpdate = "library.update",
LibraryDelete = "library.delete",
LibraryStatistics = "library.statistics",
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",
MemoryDelete = "memory.delete",
PartnerCreate = "partner.create",
PartnerRead = "partner.read",
PartnerUpdate = "partner.update",
PartnerDelete = "partner.delete",
PersonCreate = "person.create",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonDelete = "person.delete",
PersonStatistics = "person.statistics",
PersonMerge = "person.merge",
PersonReassign = "person.reassign",
SharedLinkCreate = "sharedLink.create",
SharedLinkRead = "sharedLink.read",
SharedLinkUpdate = "sharedLink.update",
SharedLinkDelete = "sharedLink.delete",
SystemConfigRead = "systemConfig.read",
SystemConfigUpdate = "systemConfig.update",
SystemMetadataRead = "systemMetadata.read",
SystemMetadataUpdate = "systemMetadata.update",
TagCreate = "tag.create",
TagRead = "tag.read",
TagUpdate = "tag.update",
TagDelete = "tag.delete",
AdminUserCreate = "admin.user.create",
AdminUserRead = "admin.user.read",
AdminUserUpdate = "admin.user.update",
AdminUserDelete = "admin.user.delete"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",

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));
};

View file

@ -1,25 +1,21 @@
<script lang="ts">
import type { ApiKeyResponseDto } from '@immich/sdk';
import { mdiKeyVariant } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { t } from 'svelte-i18n';
export let apiKey: Partial<ApiKeyResponseDto>;
export let apiKey: { name: string };
export let title: string;
export let cancelText = $t('cancel');
export let submitText = $t('save');
const dispatch = createEventDispatcher<{
cancel: void;
submit: Partial<ApiKeyResponseDto>;
}>();
const handleCancel = () => dispatch('cancel');
export let onSubmit: (apiKey: { name: string }) => void;
export let onCancel: () => void;
const handleSubmit = () => {
if (apiKey.name) {
dispatch('submit', apiKey);
onSubmit({ name: apiKey.name });
} else {
notificationController.show({
message: $t('api_key_empty'),
@ -29,7 +25,7 @@
};
</script>
<FullScreenModal {title} icon={mdiKeyVariant} onClose={handleCancel}>
<FullScreenModal {title} icon={mdiKeyVariant} onClose={() => onCancel()}>
<form on:submit|preventDefault={handleSubmit} autocomplete="off" id="api-key-form">
<div class="mb-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">{$t('name')}</label>
@ -37,7 +33,7 @@
</div>
</form>
<svelte:fragment slot="sticky-bottom">
<Button color="gray" fullwidth on:click={handleCancel}>{cancelText}</Button>
<Button color="gray" fullwidth on:click={() => onCancel()}>{cancelText}</Button>
<Button type="submit" fullwidth form="api-key-form">{submitText}</Button>
</svelte:fragment>
</FullScreenModal>

View file

@ -1,6 +1,13 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { createApiKey, deleteApiKey, getApiKeys, updateApiKey, type ApiKeyResponseDto } from '@immich/sdk';
import {
createApiKey,
deleteApiKey,
getApiKeys,
Permission,
updateApiKey,
type ApiKeyResponseDto,
} from '@immich/sdk';
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { fade } from 'svelte/transition';
import { handleError } from '../../utils/handle-error';
@ -14,7 +21,7 @@
export let keys: ApiKeyResponseDto[];
let newKey: Partial<ApiKeyResponseDto> | null = null;
let newKey: { name: string } | null = null;
let editKey: ApiKeyResponseDto | null = null;
let secret = '';
@ -28,9 +35,14 @@
keys = await getApiKeys();
}
const handleCreate = async (detail: Partial<ApiKeyResponseDto>) => {
const handleCreate = async ({ name }: { name: string }) => {
try {
const data = await createApiKey({ apiKeyCreateDto: detail });
const data = await createApiKey({
apiKeyCreateDto: {
name,
permissions: [Permission.All],
},
});
secret = data.secret;
} catch (error) {
handleError(error, $t('errors.unable_to_create_api_key'));
@ -84,8 +96,8 @@
title={$t('new_api_key')}
submitText={$t('create')}
apiKey={newKey}
on:submit={({ detail }) => handleCreate(detail)}
on:cancel={() => (newKey = null)}
onSubmit={(key) => handleCreate(key)}
onCancel={() => (newKey = null)}
/>
{/if}
@ -98,8 +110,8 @@
title={$t('api_key')}
submitText={$t('save')}
apiKey={editKey}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (editKey = null)}
onSubmit={(key) => handleUpdate(key)}
onCancel={() => (editKey = null)}
/>
{/if}