From f230b3aa426931d2d6d3c38c02aecbe353c13127 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 Aug 2024 09:48:43 -0400 Subject: [PATCH] feat(server): granular permissions for api keys (#11824) feat(server): api auth permissions --- e2e/src/api/specs/api-key.e2e-spec.ts | 82 ++++- e2e/src/cli/specs/login.e2e-spec.ts | 5 +- e2e/src/responses.ts | 6 + e2e/src/utils.ts | 7 +- mobile/lib/services/backup.service.dart | 4 +- mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../openapi/lib/model/api_key_create_dto.dart | 14 +- .../lib/model/api_key_response_dto.dart | 10 +- mobile/openapi/lib/model/permission.dart | 292 ++++++++++++++++++ open-api/immich-openapi-specs.json | 92 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 75 +++++ server/src/controllers/activity.controller.ts | 9 +- server/src/controllers/album.controller.ts | 13 +- server/src/controllers/api-key.controller.ts | 11 +- server/src/controllers/face.controller.ts | 5 +- server/src/controllers/library.controller.ts | 13 +- server/src/controllers/memory.controller.ts | 11 +- server/src/controllers/partner.controller.ts | 9 +- server/src/controllers/person.controller.ts | 19 +- .../src/controllers/shared-link.controller.ts | 11 +- .../controllers/system-config.controller.ts | 9 +- .../controllers/system-metadata.controller.ts | 7 +- server/src/controllers/tag.controller.ts | 11 +- .../src/controllers/user-admin.controller.ts | 17 +- server/src/cores/access.core.ts | 4 +- server/src/dtos/api-key.dto.ts | 11 +- server/src/entities/api-key.entity.ts | 4 + server/src/enum.ts | 62 +++- server/src/middleware/auth.guard.ts | 11 +- .../1723719333525-AddApiKeyPermissions.ts | 14 + server/src/queries/api.key.repository.sql | 3 + server/src/repositories/api-key.repository.ts | 1 + server/src/services/api-key.service.spec.ts | 7 +- server/src/services/api-key.service.ts | 12 +- server/src/services/auth.service.ts | 9 +- server/src/services/memory.service.ts | 4 +- server/src/services/person.service.ts | 8 +- server/src/utils/access.ts | 15 + .../lib/components/forms/api-key-form.svelte | 20 +- .../user-api-key-list.svelte | 28 +- 43 files changed, 817 insertions(+), 135 deletions(-) create mode 100644 mobile/openapi/lib/model/permission.dart create mode 100644 server/src/migrations/1723719333525-AddApiKeyPermissions.ts create mode 100644 server/src/utils/access.ts diff --git a/e2e/src/api/specs/api-key.e2e-spec.ts b/e2e/src/api/specs/api-key.e2e-spec.ts index 32d18f612d7c5..1748276625359 100644 --- a/e2e/src/api/specs/api-key.e2e-spec.ts +++ b/e2e/src/api/specs/api-key.e2e-spec.ts @@ -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); diff --git a/e2e/src/cli/specs/login.e2e-spec.ts b/e2e/src/cli/specs/login.e2e-spec.ts index 0fb48188a2c6a..fc3e8175957c0 100644 --- a/e2e/src/cli/specs/login.e2e-spec.ts +++ b/e2e/src/cli/specs/login.e2e-spec.ts @@ -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', diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 80e4f76f4f192..6ca2225180de6 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -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, diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 9e397d03edf90..30e2497b514d1 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -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; }, diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index a42c587435b1d..64d683dc2ae83 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -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. " diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e747db37b0c97..657dad9d5b33b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -363,6 +363,7 @@ Class | Method | HTTP request | Description - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [Permission](doc//Permission.md) - [PersonCreateDto](doc//PersonCreateDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index bbe680731e2db..4d33f1018cb52 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -175,6 +175,7 @@ part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/permission.dart'; part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 01c646d393cfc..b5b79be8b143c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -407,6 +407,8 @@ class ApiClient { return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'Permission': + return PermissionTypeTransformer().decode(value); case 'PersonCreateDto': return PersonCreateDto.fromJson(value); case 'PersonResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 04fcaa3463e48..7f46e145b15eb 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -112,6 +112,9 @@ String parameterToString(dynamic value) { if (value is PathType) { return PathTypeTypeTransformer().encode(value).toString(); } + if (value is Permission) { + return PermissionTypeTransformer().encode(value).toString(); + } if (value is ReactionLevel) { return ReactionLevelTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/api_key_create_dto.dart b/mobile/openapi/lib/model/api_key_create_dto.dart index f6ff8e5f97706..433855c4cfe17 100644 --- a/mobile/openapi/lib/model/api_key_create_dto.dart +++ b/mobile/openapi/lib/model/api_key_create_dto.dart @@ -14,6 +14,7 @@ class APIKeyCreateDto { /// Returns a new [APIKeyCreateDto] instance. APIKeyCreateDto({ this.name, + this.permissions = const [], }); /// @@ -24,17 +25,21 @@ class APIKeyCreateDto { /// String? name; + List permissions; + @override bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto && - other.name == name; + other.name == name && + _deepEquality.equals(other.permissions, permissions); @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (name == null ? 0 : name!.hashCode) + + (permissions.hashCode); @override - String toString() => 'APIKeyCreateDto[name=$name]'; + String toString() => 'APIKeyCreateDto[name=$name, permissions=$permissions]'; Map toJson() { final json = {}; @@ -43,6 +48,7 @@ class APIKeyCreateDto { } else { // json[r'name'] = null; } + json[r'permissions'] = this.permissions; return json; } @@ -55,6 +61,7 @@ class APIKeyCreateDto { return APIKeyCreateDto( name: mapValueOfType(json, r'name'), + permissions: Permission.listFromJson(json[r'permissions']), ); } return null; @@ -102,6 +109,7 @@ class APIKeyCreateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'permissions', }; } diff --git a/mobile/openapi/lib/model/api_key_response_dto.dart b/mobile/openapi/lib/model/api_key_response_dto.dart index 764d5ec9737d1..b6ca86c050944 100644 --- a/mobile/openapi/lib/model/api_key_response_dto.dart +++ b/mobile/openapi/lib/model/api_key_response_dto.dart @@ -16,6 +16,7 @@ class APIKeyResponseDto { required this.createdAt, required this.id, required this.name, + this.permissions = const [], required this.updatedAt, }); @@ -25,6 +26,8 @@ class APIKeyResponseDto { String name; + List permissions; + DateTime updatedAt; @override @@ -32,6 +35,7 @@ class APIKeyResponseDto { other.createdAt == createdAt && other.id == id && other.name == name && + _deepEquality.equals(other.permissions, permissions) && other.updatedAt == updatedAt; @override @@ -40,16 +44,18 @@ class APIKeyResponseDto { (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + + (permissions.hashCode) + (updatedAt.hashCode); @override - String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt]'; + String toString() => 'APIKeyResponseDto[createdAt=$createdAt, id=$id, name=$name, permissions=$permissions, updatedAt=$updatedAt]'; Map toJson() { final json = {}; json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; + json[r'permissions'] = this.permissions; json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); return json; } @@ -65,6 +71,7 @@ class APIKeyResponseDto { createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, + permissions: Permission.listFromJson(json[r'permissions']), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); } @@ -116,6 +123,7 @@ class APIKeyResponseDto { 'createdAt', 'id', 'name', + 'permissions', 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart new file mode 100644 index 0000000000000..30dc89a47ca45 --- /dev/null +++ b/mobile/openapi/lib/model/permission.dart @@ -0,0 +1,292 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class Permission { + /// Instantiate a new enum with the provided [value]. + const Permission._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = Permission._(r'all'); + static const activityPeriodCreate = Permission._(r'activity.create'); + static const activityPeriodRead = Permission._(r'activity.read'); + static const activityPeriodUpdate = Permission._(r'activity.update'); + static const activityPeriodDelete = Permission._(r'activity.delete'); + static const activityPeriodStatistics = Permission._(r'activity.statistics'); + static const apiKeyPeriodCreate = Permission._(r'apiKey.create'); + static const apiKeyPeriodRead = Permission._(r'apiKey.read'); + static const apiKeyPeriodUpdate = Permission._(r'apiKey.update'); + static const apiKeyPeriodDelete = Permission._(r'apiKey.delete'); + static const assetPeriodRead = Permission._(r'asset.read'); + static const assetPeriodUpdate = Permission._(r'asset.update'); + static const assetPeriodDelete = Permission._(r'asset.delete'); + static const assetPeriodRestore = Permission._(r'asset.restore'); + static const assetPeriodShare = Permission._(r'asset.share'); + static const assetPeriodView = Permission._(r'asset.view'); + static const assetPeriodDownload = Permission._(r'asset.download'); + static const assetPeriodUpload = Permission._(r'asset.upload'); + static const albumPeriodCreate = Permission._(r'album.create'); + static const albumPeriodRead = Permission._(r'album.read'); + static const albumPeriodUpdate = Permission._(r'album.update'); + static const albumPeriodDelete = Permission._(r'album.delete'); + static const albumPeriodStatistics = Permission._(r'album.statistics'); + static const albumPeriodAddAsset = Permission._(r'album.addAsset'); + static const albumPeriodRemoveAsset = Permission._(r'album.removeAsset'); + static const albumPeriodShare = Permission._(r'album.share'); + static const albumPeriodDownload = Permission._(r'album.download'); + static const authDevicePeriodDelete = Permission._(r'authDevice.delete'); + static const archivePeriodRead = Permission._(r'archive.read'); + static const facePeriodCreate = Permission._(r'face.create'); + static const facePeriodRead = Permission._(r'face.read'); + static const facePeriodUpdate = Permission._(r'face.update'); + static const facePeriodDelete = Permission._(r'face.delete'); + static const libraryPeriodCreate = Permission._(r'library.create'); + static const libraryPeriodRead = Permission._(r'library.read'); + static const libraryPeriodUpdate = Permission._(r'library.update'); + static const libraryPeriodDelete = Permission._(r'library.delete'); + static const libraryPeriodStatistics = Permission._(r'library.statistics'); + static const timelinePeriodRead = Permission._(r'timeline.read'); + static const timelinePeriodDownload = Permission._(r'timeline.download'); + static const memoryPeriodCreate = Permission._(r'memory.create'); + static const memoryPeriodRead = Permission._(r'memory.read'); + static const memoryPeriodUpdate = Permission._(r'memory.update'); + static const memoryPeriodDelete = Permission._(r'memory.delete'); + static const partnerPeriodCreate = Permission._(r'partner.create'); + static const partnerPeriodRead = Permission._(r'partner.read'); + static const partnerPeriodUpdate = Permission._(r'partner.update'); + static const partnerPeriodDelete = Permission._(r'partner.delete'); + static const personPeriodCreate = Permission._(r'person.create'); + static const personPeriodRead = Permission._(r'person.read'); + static const personPeriodUpdate = Permission._(r'person.update'); + static const personPeriodDelete = Permission._(r'person.delete'); + static const personPeriodStatistics = Permission._(r'person.statistics'); + static const personPeriodMerge = Permission._(r'person.merge'); + static const personPeriodReassign = Permission._(r'person.reassign'); + static const sharedLinkPeriodCreate = Permission._(r'sharedLink.create'); + static const sharedLinkPeriodRead = Permission._(r'sharedLink.read'); + static const sharedLinkPeriodUpdate = Permission._(r'sharedLink.update'); + static const sharedLinkPeriodDelete = Permission._(r'sharedLink.delete'); + static const systemConfigPeriodRead = Permission._(r'systemConfig.read'); + static const systemConfigPeriodUpdate = Permission._(r'systemConfig.update'); + static const systemMetadataPeriodRead = Permission._(r'systemMetadata.read'); + static const systemMetadataPeriodUpdate = Permission._(r'systemMetadata.update'); + static const tagPeriodCreate = Permission._(r'tag.create'); + static const tagPeriodRead = Permission._(r'tag.read'); + static const tagPeriodUpdate = Permission._(r'tag.update'); + static const tagPeriodDelete = Permission._(r'tag.delete'); + static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); + static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); + static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); + static const adminPeriodUserPeriodDelete = Permission._(r'admin.user.delete'); + + /// List of all possible values in this [enum][Permission]. + static const values = [ + all, + activityPeriodCreate, + activityPeriodRead, + activityPeriodUpdate, + activityPeriodDelete, + activityPeriodStatistics, + apiKeyPeriodCreate, + apiKeyPeriodRead, + apiKeyPeriodUpdate, + apiKeyPeriodDelete, + assetPeriodRead, + assetPeriodUpdate, + assetPeriodDelete, + assetPeriodRestore, + assetPeriodShare, + assetPeriodView, + assetPeriodDownload, + assetPeriodUpload, + albumPeriodCreate, + albumPeriodRead, + albumPeriodUpdate, + albumPeriodDelete, + albumPeriodStatistics, + albumPeriodAddAsset, + albumPeriodRemoveAsset, + albumPeriodShare, + albumPeriodDownload, + authDevicePeriodDelete, + archivePeriodRead, + facePeriodCreate, + facePeriodRead, + facePeriodUpdate, + facePeriodDelete, + libraryPeriodCreate, + libraryPeriodRead, + libraryPeriodUpdate, + libraryPeriodDelete, + libraryPeriodStatistics, + timelinePeriodRead, + timelinePeriodDownload, + memoryPeriodCreate, + memoryPeriodRead, + memoryPeriodUpdate, + memoryPeriodDelete, + partnerPeriodCreate, + partnerPeriodRead, + partnerPeriodUpdate, + partnerPeriodDelete, + personPeriodCreate, + personPeriodRead, + personPeriodUpdate, + personPeriodDelete, + personPeriodStatistics, + personPeriodMerge, + personPeriodReassign, + sharedLinkPeriodCreate, + sharedLinkPeriodRead, + sharedLinkPeriodUpdate, + sharedLinkPeriodDelete, + systemConfigPeriodRead, + systemConfigPeriodUpdate, + systemMetadataPeriodRead, + systemMetadataPeriodUpdate, + tagPeriodCreate, + tagPeriodRead, + tagPeriodUpdate, + tagPeriodDelete, + adminPeriodUserPeriodCreate, + adminPeriodUserPeriodRead, + adminPeriodUserPeriodUpdate, + adminPeriodUserPeriodDelete, + ]; + + static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = Permission.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [Permission] to String, +/// and [decode] dynamic data back to [Permission]. +class PermissionTypeTransformer { + factory PermissionTypeTransformer() => _instance ??= const PermissionTypeTransformer._(); + + const PermissionTypeTransformer._(); + + String encode(Permission data) => data.value; + + /// Decodes a [dynamic value][data] to a Permission. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + Permission? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return Permission.all; + case r'activity.create': return Permission.activityPeriodCreate; + case r'activity.read': return Permission.activityPeriodRead; + case r'activity.update': return Permission.activityPeriodUpdate; + case r'activity.delete': return Permission.activityPeriodDelete; + case r'activity.statistics': return Permission.activityPeriodStatistics; + case r'apiKey.create': return Permission.apiKeyPeriodCreate; + case r'apiKey.read': return Permission.apiKeyPeriodRead; + case r'apiKey.update': return Permission.apiKeyPeriodUpdate; + case r'apiKey.delete': return Permission.apiKeyPeriodDelete; + case r'asset.read': return Permission.assetPeriodRead; + case r'asset.update': return Permission.assetPeriodUpdate; + case r'asset.delete': return Permission.assetPeriodDelete; + case r'asset.restore': return Permission.assetPeriodRestore; + case r'asset.share': return Permission.assetPeriodShare; + case r'asset.view': return Permission.assetPeriodView; + case r'asset.download': return Permission.assetPeriodDownload; + case r'asset.upload': return Permission.assetPeriodUpload; + case r'album.create': return Permission.albumPeriodCreate; + case r'album.read': return Permission.albumPeriodRead; + case r'album.update': return Permission.albumPeriodUpdate; + case r'album.delete': return Permission.albumPeriodDelete; + case r'album.statistics': return Permission.albumPeriodStatistics; + case r'album.addAsset': return Permission.albumPeriodAddAsset; + case r'album.removeAsset': return Permission.albumPeriodRemoveAsset; + case r'album.share': return Permission.albumPeriodShare; + case r'album.download': return Permission.albumPeriodDownload; + case r'authDevice.delete': return Permission.authDevicePeriodDelete; + case r'archive.read': return Permission.archivePeriodRead; + case r'face.create': return Permission.facePeriodCreate; + case r'face.read': return Permission.facePeriodRead; + case r'face.update': return Permission.facePeriodUpdate; + case r'face.delete': return Permission.facePeriodDelete; + case r'library.create': return Permission.libraryPeriodCreate; + case r'library.read': return Permission.libraryPeriodRead; + case r'library.update': return Permission.libraryPeriodUpdate; + case r'library.delete': return Permission.libraryPeriodDelete; + case r'library.statistics': return Permission.libraryPeriodStatistics; + case r'timeline.read': return Permission.timelinePeriodRead; + case r'timeline.download': return Permission.timelinePeriodDownload; + case r'memory.create': return Permission.memoryPeriodCreate; + case r'memory.read': return Permission.memoryPeriodRead; + case r'memory.update': return Permission.memoryPeriodUpdate; + case r'memory.delete': return Permission.memoryPeriodDelete; + case r'partner.create': return Permission.partnerPeriodCreate; + case r'partner.read': return Permission.partnerPeriodRead; + case r'partner.update': return Permission.partnerPeriodUpdate; + case r'partner.delete': return Permission.partnerPeriodDelete; + case r'person.create': return Permission.personPeriodCreate; + case r'person.read': return Permission.personPeriodRead; + case r'person.update': return Permission.personPeriodUpdate; + case r'person.delete': return Permission.personPeriodDelete; + case r'person.statistics': return Permission.personPeriodStatistics; + case r'person.merge': return Permission.personPeriodMerge; + case r'person.reassign': return Permission.personPeriodReassign; + case r'sharedLink.create': return Permission.sharedLinkPeriodCreate; + case r'sharedLink.read': return Permission.sharedLinkPeriodRead; + case r'sharedLink.update': return Permission.sharedLinkPeriodUpdate; + case r'sharedLink.delete': return Permission.sharedLinkPeriodDelete; + case r'systemConfig.read': return Permission.systemConfigPeriodRead; + case r'systemConfig.update': return Permission.systemConfigPeriodUpdate; + case r'systemMetadata.read': return Permission.systemMetadataPeriodRead; + case r'systemMetadata.update': return Permission.systemMetadataPeriodUpdate; + case r'tag.create': return Permission.tagPeriodCreate; + case r'tag.read': return Permission.tagPeriodRead; + case r'tag.update': return Permission.tagPeriodUpdate; + case r'tag.delete': return Permission.tagPeriodDelete; + case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; + case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; + case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; + case r'admin.user.delete': return Permission.adminPeriodUserPeriodDelete; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PermissionTypeTransformer] instance. + static PermissionTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 63d22aa4f9dc6..0d0793c263aae 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 077e802b8c580..89e03603689a8 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/controllers/activity.controller.ts b/server/src/controllers/activity.controller.ts index 76b58a56cea3b..9b06f82f3a890 100644 --- a/server/src/controllers/activity.controller.ts +++ b/server/src/controllers/activity.controller.ts @@ -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 { return this.service.getAll(auth, dto); } @Get('statistics') - @Authenticated() + @Authenticated({ permission: Permission.ACTIVITY_STATISTICS }) getActivityStatistics(@Auth() auth: AuthDto, @Query() dto: ActivityDto): Promise { 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 { return this.service.delete(auth, id); } diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 1455aeec4bef7..06f2066c29f85 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -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 { return this.service.getCount(auth); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_READ }) getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise { return this.service.getAll(auth, query); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.ALBUM_CREATE }) createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise { 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); } diff --git a/server/src/controllers/api-key.controller.ts b/server/src/controllers/api-key.controller.ts index feba7cccbb962..4691ce05ef93f 100644 --- a/server/src/controllers/api-key.controller.ts +++ b/server/src/controllers/api-key.controller.ts @@ -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 { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKeys(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.API_KEY_READ }) getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { 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 { return this.service.delete(auth, id); } diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index e3330e9563617..7d93bfd34dffa 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -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 { return this.service.getFacesById(auth, dto); } @Put(':id') - @Authenticated() + @Authenticated({ permission: Permission.FACE_UPDATE }) reassignFacesById( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index fd7a88b074b43..18ba43c0a61fe 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -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 { return this.service.getAll(); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_CREATE, admin: true }) createLibrary(@Body() dto: CreateLibraryDto): Promise { 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 { return this.service.update(id, dto); } @Get(':id') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_READ, admin: true }) getLibrary(@Param() { id }: UUIDParamDto): Promise { 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 { return this.service.delete(id); } @Get(':id/statistics') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { return this.service.getStatistics(id); } diff --git a/server/src/controllers/memory.controller.ts b/server/src/controllers/memory.controller.ts index 9c5c22de4316d..710ca9f2f8103 100644 --- a/server/src/controllers/memory.controller.ts +++ b/server/src/controllers/memory.controller.ts @@ -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 { return this.service.search(auth); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_CREATE }) createMemory(@Auth() auth: AuthDto, @Body() dto: MemoryCreateDto): Promise { return this.service.create(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.MEMORY_READ }) getMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { 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 { return this.service.remove(auth, id); } diff --git a/server/src/controllers/partner.controller.ts b/server/src/controllers/partner.controller.ts index 208d57146422a..0662243d61e5b 100644 --- a/server/src/controllers/partner.controller.ts +++ b/server/src/controllers/partner.controller.ts @@ -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 { return this.service.search(auth, dto); } @Post(':id') - @Authenticated() + @Authenticated({ permission: Permission.PARTNER_CREATE }) createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { 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 { return this.service.remove(auth, id); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 082d5ca46c5b7..5462305d9f94e 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -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 { return this.service.getAll(auth, withHidden); } @Post() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_CREATE }) createPerson(@Auth() auth: AuthDto, @Body() dto: PersonCreateDto): Promise { return this.service.create(auth, dto); } @Put() - @Authenticated() + @Authenticated({ permission: Permission.PERSON_UPDATE }) updatePeople(@Auth() auth: AuthDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updateAll(auth, dto); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.PERSON_READ }) getPerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { 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 { 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, diff --git a/server/src/controllers/shared-link.controller.ts b/server/src/controllers/shared-link.controller.ts index ffd6e0c969bed..065e578ec562c 100644 --- a/server/src/controllers/shared-link.controller.ts +++ b/server/src/controllers/shared-link.controller.ts @@ -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 { 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 { 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 { return this.service.remove(auth, id); } diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index e88f3dcb3929e..804c19500facd 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -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 { 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 { 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(); } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index 90e9f5b6a8aab..bca5c65d8e45c 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -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 { 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 { return this.service.updateAdminOnboarding(dto); } @Get('reverse-geocoding-state') - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 71d826fcc5aa3..8b646400cc960 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -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 { return this.service.create(auth, dto); } @Get() - @Authenticated() + @Authenticated({ permission: Permission.TAG_READ }) getAllTags(@Auth() auth: AuthDto): Promise { return this.service.getAll(auth); } @Get(':id') - @Authenticated() + @Authenticated({ permission: Permission.TAG_READ }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { 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 { return this.service.update(auth, id, dto); } @Delete(':id') - @Authenticated() + @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } diff --git a/server/src/controllers/user-admin.controller.ts b/server/src/controllers/user-admin.controller.ts index a4f3b3198cdd3..d44115be2fbee 100644 --- a/server/src/controllers/user-admin.controller.ts +++ b/server/src/controllers/user-admin.controller.ts @@ -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 { return this.service.search(auth, dto); } @Post() - @Authenticated({ admin: true }) + @Authenticated({ permission: Permission.ADMIN_USER_CREATE, admin: true }) createUserAdmin(@Body() createUserDto: UserAdminCreateDto): Promise { 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 { 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 { 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 { return this.service.restore(auth, id); } diff --git a/server/src/cores/access.core.ts b/server/src/cores/access.core.ts index aba13e5acf177..b8ba88b59d388 100644 --- a/server/src/cores/access.core.ts +++ b/server/src/cores/access.core.ts @@ -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); } diff --git a/server/src/dtos/api-key.dto.ts b/server/src/dtos/api-key.dto.ts index 1f4f85521670f..7e81ce8c608d1 100644 --- a/server/src/dtos/api-key.dto.ts +++ b/server/src/dtos/api-key.dto.ts @@ -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[]; } diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts index 18aaa83041f37..998ee4f8ef897 100644 --- a/server/src/entities/api-key.entity.ts +++ b/server/src/entities/api-key.entity.ts @@ -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; diff --git a/server/src/enum.ts b/server/src/enum.ts index 04f59e5a98a37..da4b2d76fc580 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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 { diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index beab484950d48..d6138f2d3ae24 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -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(); request.user = await this.authService.authenticate({ headers: request.headers, queryParams: request.query as Record, - metadata: { adminRoute, sharedLinkRoute, uri: request.path }, + metadata: { adminRoute, sharedLinkRoute, permission, uri: request.path }, }); return true; diff --git a/server/src/migrations/1723719333525-AddApiKeyPermissions.ts b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts new file mode 100644 index 0000000000000..d585d98bcb773 --- /dev/null +++ b/server/src/migrations/1723719333525-AddApiKeyPermissions.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddApiKeyPermissions1723719333525 implements MigrationInterface { + name = 'AddApiKeyPermissions1723719333525'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`ALTER TABLE "api_keys" DROP COLUMN "permissions"`); + } +} diff --git a/server/src/queries/api.key.repository.sql b/server/src/queries/api.key.repository.sql index ba54a6e67ce7b..e5f389ac4d017 100644 --- a/server/src/queries/api.key.repository.sql +++ b/server/src/queries/api.key.repository.sql @@ -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 diff --git a/server/src/repositories/api-key.repository.ts b/server/src/repositories/api-key.repository.ts index c5cdb805514b1..5178039177058 100644 --- a/server/src/repositories/api-key.repository.ts +++ b/server/src/repositories/api-key.repository.ts @@ -31,6 +31,7 @@ export class ApiKeyRepository implements IKeyRepository { id: true, key: true, userId: true, + permissions: true, }, where: { key: hashedToken }, relations: { diff --git a/server/src/services/api-key.service.spec.ts b/server/src/services/api-key.service.spec.ts index 2b5efc674fc1a..4d13eead575fc 100644 --- a/server/src/services/api-key.service.spec.ts +++ b/server/src/services/api-key.service.spec.ts @@ -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(); diff --git a/server/src/services/api-key.service.ts b/server/src/services/api-key.service.ts index 24a57d3651261..7dd1ed5c268ba 100644 --- a/server/src/services/api-key.service.ts +++ b/server/src/services/api-key.service.ts @@ -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 { 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 { + async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise { 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, }; } } diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 0ba44601b90a0..18b4268292dba 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -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 { 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; } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 02fdacc355949..c8c44d04b3793 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -50,7 +50,7 @@ export class MemoryService { } async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise { - 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 { - 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, { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8ffae5bf05451..6d536f4bf84d7 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -113,7 +113,7 @@ export class PersonService { } async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { - 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 { - 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 { - 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; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts new file mode 100644 index 0000000000000..cd24087d9bd2b --- /dev/null +++ b/server/src/utils/access.ts @@ -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)); +}; diff --git a/web/src/lib/components/forms/api-key-form.svelte b/web/src/lib/components/forms/api-key-form.svelte index 55ec258b40f30..5b1341db44add 100644 --- a/web/src/lib/components/forms/api-key-form.svelte +++ b/web/src/lib/components/forms/api-key-form.svelte @@ -1,25 +1,21 @@ - + onCancel()}>
@@ -37,7 +33,7 @@
- +
diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 1cc89ad30d090..13ec440082e91 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -1,6 +1,13 @@