diff --git a/docs/docs/features/shared-albums.md b/docs/docs/features/shared-albums.md index d7995d284b626..2684acfd9c5be 100644 --- a/docs/docs/features/shared-albums.md +++ b/docs/docs/features/shared-albums.md @@ -73,14 +73,14 @@ You can edit the link properties, options include; - **Allow public user to download -** whether to allow whoever has the link to download all the images or a certain image (optional). - **Allow public user to upload -** whether to allow whoever has the link to upload assets to the album (optional). :::info - whoever has the link and have uploaded files cannot delete them. + Whoever has the link and have uploaded files cannot delete them. ::: - **Expire after -** adding an expiration date to the link (optional). ## Share Specific Assets A user can share specific assets without linking them to a specific album. -in order to do so; +In order to do this: 1. Go to the timeline 2. Select the assets (Shift can be used for multiple selection) diff --git a/docs/docs/install/requirements.md b/docs/docs/install/requirements.md index 8944336ec7664..88d85c7bee8cc 100644 --- a/docs/docs/install/requirements.md +++ b/docs/docs/install/requirements.md @@ -4,7 +4,7 @@ sidebar_position: 10 # Requirements -Hardware and software requirements for Immich +Hardware and software requirements for Immich: ## Software diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index 67de980186872..b17ed28295f70 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -45,7 +45,7 @@ width="70%" alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" /> -3. Select the cog ⚙️ next to Immich then click "**Edit Stack**" +3. Select the cogwheel ⚙️ next to Immich and click "**Edit Stack**" 4. Click "**Compose File**" and then paste the entire contents of the [Immich Docker Compose](https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml) file into the Unraid editor. Remove any text that may be in the text area by default. Note that Unraid v6.12.10 uses version 24.0.9 of the Docker Engine, which does not support healthcheck `start_interval` as defined in the `database` service of the Docker compose file (version 25 or higher is needed). This parameter defines an initial waiting period before starting health checks, to give the container time to start up. Commenting out the `start_interval` and `start_period` parameters will allow the containers to start up normally. The only downside to this is that the database container will not receive an initial health check until `interval` time has passed.
@@ -130,7 +130,7 @@ For more information on how to use the application once installed, please refer ## Updating Steps -Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman ui, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager. +Updating is extremely easy however it's important to be aware that containers managed via the Docker Compose Manager plugin do not integrate with Unraid's native dockerman UI, the label "_update ready_" will always be present on containers installed via the Docker Compose Manager. - 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/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 9ef2a3e5991a3..f9dd86513d8e2 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -55,13 +55,13 @@ "asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_title": "Photo Grid", "asset_restored_successfully": "Asset restored successfully", + "asset_viewer_settings_title": "Asset Viewer", "assets_deleted_permanently": "{} asset(s) deleted permanently", "assets_deleted_permanently_from_server": "{} asset(s) deleted permanently from the Immich server", "assets_removed_permanently_from_device": "{} asset(s) removed permanently from your device", "assets_restored_successfully": "{} asset(s) restored successfully", "assets_trashed": "{} asset(s) trashed", "assets_trashed_from_server": "{} asset(s) trashed from the Immich server", - "asset_viewer_settings_title": "Asset Viewer", "backup_album_selection_page_albums_device": "Albums on device ({})", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", @@ -173,6 +173,7 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_local": "Delete from device", + "control_bottom_app_bar_download": "Download", "control_bottom_app_bar_edit": "Edit", "control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_time": "Edit Date & Time", @@ -455,15 +456,18 @@ "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)", "setting_notifications_total_progress_title": "Show background backup total progress", "setting_pages_app_bar_settings": "Settings", - "settings_require_restart": "Please restart Immich to apply this setting", "setting_video_viewer_looping_subtitle": "Enable to automatically loop a video in the detail viewer.", "setting_video_viewer_looping_title": "Looping", "setting_video_viewer_title": "Videos", + "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_assets_selected": "{} selected", "share_create_album": "Create album", + "share_dialog_preparing": "Preparing...", + "share_done": "Done", + "share_invite": "Invite to album", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_hint": "Say something", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -475,7 +479,6 @@ "shared_album_section_people_action_remove_user": "Remove user from album", "shared_album_section_people_owner_label": "Owner", "shared_album_section_people_title": "PEOPLE", - "share_dialog_preparing": "Preparing...", "shared_link_app_bar_title": "Shared Links", "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {}\nPassword: {}", @@ -521,14 +524,12 @@ "shared_link_info_chip_upload": "Upload", "shared_link_manage_links": "Manage Shared links", "shared_link_public_album": "Public album", - "share_done": "Done", - "share_invite": "Invite to album", "sharing_page_album": "Shared albums", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_empty_list": "EMPTY LIST", "sharing_silver_appbar_create_shared_album": "New shared album", - "sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_share_partner": "Share with partner", + "sharing_silver_appbar_shared_links": "Shared links", "tab_controller_nav_library": "Library", "tab_controller_nav_photos": "Photos", "tab_controller_nav_search": "Search", diff --git a/mobile/lib/pages/backup/backup_controller.page.dart b/mobile/lib/pages/backup/backup_controller.page.dart index 7b86f3225c203..bb9d462e50bc4 100644 --- a/mobile/lib/pages/backup/backup_controller.page.dart +++ b/mobile/lib/pages/backup/backup_controller.page.dart @@ -51,8 +51,8 @@ class BackupControllerPage extends HookConsumerWidget { } void stopScreenDarkenTimer() { - isScreenDarkened.value = false; darkenScreenTimer.value?.cancel(); + isScreenDarkened.value = false; SystemChrome.setEnabledSystemUIMode( SystemUiMode.manual, overlays: [ @@ -75,8 +75,6 @@ class BackupControllerPage extends HookConsumerWidget { .watch(websocketProvider.notifier) .stopListenToEvent('on_upload_success'); - WakelockPlus.enable(); - return () { WakelockPlus.disable(); darkenScreenTimer.value?.cancel(); @@ -102,8 +100,10 @@ class BackupControllerPage extends HookConsumerWidget { () { if (backupState.backupProgress == BackUpProgressEnum.inProgress) { startScreenDarkenTimer(); + WakelockPlus.enable(); } else { stopScreenDarkenTimer(); + WakelockPlus.disable(); } return null; 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/lib/services/local_notification.service.dart b/mobile/lib/services/local_notification.service.dart index 24637773311dd..b47ee280b8728 100644 --- a/mobile/lib/services/local_notification.service.dart +++ b/mobile/lib/services/local_notification.service.dart @@ -29,7 +29,8 @@ class LocalNotificationService { static const cancelUploadActionID = 'cancel_upload'; Future setup() async { - const androidSetting = AndroidInitializationSettings('notification_icon'); + const androidSetting = + AndroidInitializationSettings('@drawable/notification_icon'); const iosSetting = DarwinInitializationSettings(); const initSettings = diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index d78b10270e06c..fb70ac309ed7f 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -366,8 +366,8 @@ class BottomGalleryBar extends ConsumerWidget { { BottomNavigationBarItem( icon: const Icon(Icons.download_outlined), - label: 'download'.tr(), - tooltip: 'download'.tr(), + label: 'control_bottom_app_bar_download'.tr(), + tooltip: 'control_bottom_app_bar_download'.tr(), ): (_) => handleDownload(), }, if (isInAlbum) 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 @@