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

feat(server): api auth permissions
This commit is contained in:
Jason Rasmussen 2024-08-16 09:48:43 -04:00 committed by GitHub
parent a372b56d44
commit f230b3aa42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 817 additions and 135 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -363,6 +363,7 @@ Class | Method | HTTP request | Description
- [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- [PeopleUpdateItem](doc//PeopleUpdateItem.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [Permission](doc//Permission.md)
- [PersonCreateDto](doc//PersonCreateDto.md) - [PersonCreateDto](doc//PersonCreateDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md) - [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)

View File

@ -175,6 +175,7 @@ part 'model/path_type.dart';
part 'model/people_response_dto.dart'; part 'model/people_response_dto.dart';
part 'model/people_update_dto.dart'; part 'model/people_update_dto.dart';
part 'model/people_update_item.dart'; part 'model/people_update_item.dart';
part 'model/permission.dart';
part 'model/person_create_dto.dart'; part 'model/person_create_dto.dart';
part 'model/person_response_dto.dart'; part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart'; part 'model/person_statistics_response_dto.dart';

View File

@ -407,6 +407,8 @@ class ApiClient {
return PeopleUpdateDto.fromJson(value); return PeopleUpdateDto.fromJson(value);
case 'PeopleUpdateItem': case 'PeopleUpdateItem':
return PeopleUpdateItem.fromJson(value); return PeopleUpdateItem.fromJson(value);
case 'Permission':
return PermissionTypeTransformer().decode(value);
case 'PersonCreateDto': case 'PersonCreateDto':
return PersonCreateDto.fromJson(value); return PersonCreateDto.fromJson(value);
case 'PersonResponseDto': case 'PersonResponseDto':

View File

@ -112,6 +112,9 @@ String parameterToString(dynamic value) {
if (value is PathType) { if (value is PathType) {
return PathTypeTypeTransformer().encode(value).toString(); return PathTypeTypeTransformer().encode(value).toString();
} }
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is ReactionLevel) { if (value is ReactionLevel) {
return ReactionLevelTypeTransformer().encode(value).toString(); return ReactionLevelTypeTransformer().encode(value).toString();
} }

View File

@ -14,6 +14,7 @@ class APIKeyCreateDto {
/// Returns a new [APIKeyCreateDto] instance. /// Returns a new [APIKeyCreateDto] instance.
APIKeyCreateDto({ APIKeyCreateDto({
this.name, this.name,
this.permissions = const [],
}); });
/// ///
@ -24,17 +25,21 @@ class APIKeyCreateDto {
/// ///
String? name; String? name;
List<Permission> permissions;
@override @override
bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto && bool operator ==(Object other) => identical(this, other) || other is APIKeyCreateDto &&
other.name == name; other.name == name &&
_deepEquality.equals(other.permissions, permissions);
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(name == null ? 0 : name!.hashCode); (name == null ? 0 : name!.hashCode) +
(permissions.hashCode);
@override @override
String toString() => 'APIKeyCreateDto[name=$name]'; String toString() => 'APIKeyCreateDto[name=$name, permissions=$permissions]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -43,6 +48,7 @@ class APIKeyCreateDto {
} else { } else {
// json[r'name'] = null; // json[r'name'] = null;
} }
json[r'permissions'] = this.permissions;
return json; return json;
} }
@ -55,6 +61,7 @@ class APIKeyCreateDto {
return APIKeyCreateDto( return APIKeyCreateDto(
name: mapValueOfType<String>(json, r'name'), name: mapValueOfType<String>(json, r'name'),
permissions: Permission.listFromJson(json[r'permissions']),
); );
} }
return null; return null;
@ -102,6 +109,7 @@ class APIKeyCreateDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'permissions',
}; };
} }

View File

@ -16,6 +16,7 @@ class APIKeyResponseDto {
required this.createdAt, required this.createdAt,
required this.id, required this.id,
required this.name, required this.name,
this.permissions = const [],
required this.updatedAt, required this.updatedAt,
}); });
@ -25,6 +26,8 @@ class APIKeyResponseDto {
String name; String name;
List<Permission> permissions;
DateTime updatedAt; DateTime updatedAt;
@override @override
@ -32,6 +35,7 @@ class APIKeyResponseDto {
other.createdAt == createdAt && other.createdAt == createdAt &&
other.id == id && other.id == id &&
other.name == name && other.name == name &&
_deepEquality.equals(other.permissions, permissions) &&
other.updatedAt == updatedAt; other.updatedAt == updatedAt;
@override @override
@ -40,16 +44,18 @@ class APIKeyResponseDto {
(createdAt.hashCode) + (createdAt.hashCode) +
(id.hashCode) + (id.hashCode) +
(name.hashCode) + (name.hashCode) +
(permissions.hashCode) +
(updatedAt.hashCode); (updatedAt.hashCode);
@override @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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id; json[r'id'] = this.id;
json[r'name'] = this.name; json[r'name'] = this.name;
json[r'permissions'] = this.permissions;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
return json; return json;
} }
@ -65,6 +71,7 @@ class APIKeyResponseDto {
createdAt: mapDateTime(json, r'createdAt', r'')!, createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!, id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!, name: mapValueOfType<String>(json, r'name')!,
permissions: Permission.listFromJson(json[r'permissions']),
updatedAt: mapDateTime(json, r'updatedAt', r'')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!,
); );
} }
@ -116,6 +123,7 @@ class APIKeyResponseDto {
'createdAt', 'createdAt',
'id', 'id',
'name', 'name',
'permissions',
'updatedAt', 'updatedAt',
}; };
} }

292
mobile/openapi/lib/model/permission.dart generated Normal file
View File

@ -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 = <Permission>[
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<Permission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <Permission>[];
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;
}

View File

@ -7135,8 +7135,17 @@
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
} }
}, },
"required": [
"permissions"
],
"type": "object" "type": "object"
}, },
"APIKeyCreateResponseDto": { "APIKeyCreateResponseDto": {
@ -7166,6 +7175,12 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"permissions": {
"items": {
"$ref": "#/components/schemas/Permission"
},
"type": "array"
},
"updatedAt": { "updatedAt": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -7175,6 +7190,7 @@
"createdAt", "createdAt",
"id", "id",
"name", "name",
"permissions",
"updatedAt" "updatedAt"
], ],
"type": "object" "type": "object"
@ -9729,6 +9745,82 @@
], ],
"type": "object" "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": { "PersonCreateDto": {
"properties": { "properties": {
"birthDate": { "birthDate": {

View File

@ -299,10 +299,12 @@ export type ApiKeyResponseDto = {
createdAt: string; createdAt: string;
id: string; id: string;
name: string; name: string;
permissions: Permission[];
updatedAt: string; updatedAt: string;
}; };
export type ApiKeyCreateDto = { export type ApiKeyCreateDto = {
name?: string; name?: string;
permissions: Permission[];
}; };
export type ApiKeyCreateResponseDto = { export type ApiKeyCreateResponseDto = {
apiKey: ApiKeyResponseDto; apiKey: ApiKeyResponseDto;
@ -3125,6 +3127,79 @@ export enum Error {
NotFound = "not_found", NotFound = "not_found",
Unknown = "unknown" 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 { export enum AssetMediaStatus {
Created = "created", Created = "created",
Replaced = "replaced", Replaced = "replaced",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -256,7 +256,7 @@ export class AccessCore {
return this.repository.memory.checkOwnerAccess(auth.user.id, ids); 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); 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); 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); return await this.repository.person.checkOwnerAccess(auth.user.id, ids);
} }

View File

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

View File

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

View File

@ -32,8 +32,18 @@ export enum MemoryType {
} }
export enum Permission { export enum Permission {
ALL = 'all',
ACTIVITY_CREATE = 'activity.create', ACTIVITY_CREATE = 'activity.create',
ACTIVITY_READ = 'activity.read',
ACTIVITY_UPDATE = 'activity.update',
ACTIVITY_DELETE = 'activity.delete', 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_CREATE = 'asset.create',
ASSET_READ = 'asset.read', ASSET_READ = 'asset.read',
@ -45,10 +55,12 @@ export enum Permission {
ASSET_DOWNLOAD = 'asset.download', ASSET_DOWNLOAD = 'asset.download',
ASSET_UPLOAD = 'asset.upload', ASSET_UPLOAD = 'asset.upload',
// ALBUM_CREATE = 'album.create', ALBUM_CREATE = 'album.create',
ALBUM_READ = 'album.read', ALBUM_READ = 'album.read',
ALBUM_UPDATE = 'album.update', ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete', ALBUM_DELETE = 'album.delete',
ALBUM_STATISTICS = 'album.statistics',
ALBUM_ADD_ASSET = 'album.addAsset', ALBUM_ADD_ASSET = 'album.addAsset',
ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_REMOVE_ASSET = 'album.removeAsset',
ALBUM_SHARE = 'album.share', ALBUM_SHARE = 'album.share',
@ -58,20 +70,58 @@ export enum Permission {
ARCHIVE_READ = 'archive.read', 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_READ = 'timeline.read',
TIMELINE_DOWNLOAD = 'timeline.download', TIMELINE_DOWNLOAD = 'timeline.download',
MEMORY_CREATE = 'memory.create',
MEMORY_READ = 'memory.read', MEMORY_READ = 'memory.read',
MEMORY_WRITE = 'memory.write', MEMORY_UPDATE = 'memory.update',
MEMORY_DELETE = 'memory.delete', MEMORY_DELETE = 'memory.delete',
PERSON_READ = 'person.read', PARTNER_CREATE = 'partner.create',
PERSON_WRITE = 'person.write', PARTNER_READ = 'partner.read',
PERSON_MERGE = 'person.merge', PARTNER_UPDATE = 'partner.update',
PARTNER_DELETE = 'partner.delete',
PERSON_CREATE = 'person.create', 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', 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 { export enum SharedLinkType {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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