mirror of https://github.com/immich-app/immich.git
feat(server): asset entity audit (#3824)
* feat(server): audit log * feedback * Insert to database * migration * test * controller/repository/service * test * module * feat(server): implement audit endpoint * directly return changed assets * add daily cleanup of audit table * fix tests * review feedback * ci * refactor(server): audit implementation * chore: open api --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com> Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>pull/3855/head
parent
d6887117ac
commit
cf9e04c8ec
@ -0,0 +1,73 @@
|
||||
# openapi.api.AuditApi
|
||||
|
||||
## Load the API package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**getAuditDeletes**](AuditApi.md#getauditdeletes) | **GET** /audit/deletes |
|
||||
|
||||
|
||||
# **getAuditDeletes**
|
||||
> AuditDeletesResponseDto getAuditDeletes(entityType, after, userId)
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = AuditApi();
|
||||
final entityType = ; // EntityType |
|
||||
final after = 2013-10-20T19:20:30+01:00; // DateTime |
|
||||
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
|
||||
|
||||
try {
|
||||
final result = api_instance.getAuditDeletes(entityType, after, userId);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling AuditApi->getAuditDeletes: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**entityType** | [**EntityType**](.md)| |
|
||||
**after** | **DateTime**| |
|
||||
**userId** | **String**| | [optional]
|
||||
|
||||
### Return type
|
||||
|
||||
[**AuditDeletesResponseDto**](AuditDeletesResponseDto.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
# openapi.model.AuditDeletesResponseDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**ids** | **List<String>** | | [default to const []]
|
||||
**needsFullSync** | **bool** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
# openapi.model.EntityType
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 AuditApi {
|
||||
AuditApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [EntityType] entityType (required):
|
||||
///
|
||||
/// * [DateTime] after (required):
|
||||
///
|
||||
/// * [String] userId:
|
||||
Future<Response> getAuditDeletesWithHttpInfo(EntityType entityType, DateTime after, { String? userId, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/audit/deletes';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('', 'entityType', entityType));
|
||||
if (userId != null) {
|
||||
queryParams.addAll(_queryParams('', 'userId', userId));
|
||||
}
|
||||
queryParams.addAll(_queryParams('', 'after', after));
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
path,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [EntityType] entityType (required):
|
||||
///
|
||||
/// * [DateTime] after (required):
|
||||
///
|
||||
/// * [String] userId:
|
||||
Future<AuditDeletesResponseDto?> getAuditDeletes(EntityType entityType, DateTime after, { String? userId, }) async {
|
||||
final response = await getAuditDeletesWithHttpInfo(entityType, after, userId: userId, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AuditDeletesResponseDto',) as AuditDeletesResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 AuditDeletesResponseDto {
|
||||
/// Returns a new [AuditDeletesResponseDto] instance.
|
||||
AuditDeletesResponseDto({
|
||||
this.ids = const [],
|
||||
required this.needsFullSync,
|
||||
});
|
||||
|
||||
List<String> ids;
|
||||
|
||||
bool needsFullSync;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto &&
|
||||
other.ids == ids &&
|
||||
other.needsFullSync == needsFullSync;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(ids.hashCode) +
|
||||
(needsFullSync.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'ids'] = this.ids;
|
||||
json[r'needsFullSync'] = this.needsFullSync;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AuditDeletesResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AuditDeletesResponseDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AuditDeletesResponseDto(
|
||||
ids: json[r'ids'] is List
|
||||
? (json[r'ids'] as List).cast<String>()
|
||||
: const [],
|
||||
needsFullSync: mapValueOfType<bool>(json, r'needsFullSync')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AuditDeletesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AuditDeletesResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AuditDeletesResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AuditDeletesResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AuditDeletesResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AuditDeletesResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AuditDeletesResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'ids',
|
||||
'needsFullSync',
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,85 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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 EntityType {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const EntityType._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const ASSET = EntityType._(r'ASSET');
|
||||
static const ALBUM = EntityType._(r'ALBUM');
|
||||
|
||||
/// List of all possible values in this [enum][EntityType].
|
||||
static const values = <EntityType>[
|
||||
ASSET,
|
||||
ALBUM,
|
||||
];
|
||||
|
||||
static EntityType? fromJson(dynamic value) => EntityTypeTypeTransformer().decode(value);
|
||||
|
||||
static List<EntityType>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <EntityType>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = EntityType.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [EntityType] to String,
|
||||
/// and [decode] dynamic data back to [EntityType].
|
||||
class EntityTypeTypeTransformer {
|
||||
factory EntityTypeTypeTransformer() => _instance ??= const EntityTypeTypeTransformer._();
|
||||
|
||||
const EntityTypeTypeTransformer._();
|
||||
|
||||
String encode(EntityType data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a EntityType.
|
||||
///
|
||||
/// 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.
|
||||
EntityType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'ASSET': return EntityType.ASSET;
|
||||
case r'ALBUM': return EntityType.ALBUM;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [EntityTypeTypeTransformer] instance.
|
||||
static EntityTypeTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
||||
/// tests for AuditApi
|
||||
void main() {
|
||||
// final instance = AuditApi();
|
||||
|
||||
group('tests for AuditApi', () {
|
||||
//Future<AuditDeletesResponseDto> getAuditDeletes(EntityType entityType, DateTime after, { String userId }) async
|
||||
test('test getAuditDeletes', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for AuditDeletesResponseDto
|
||||
void main() {
|
||||
// final instance = AuditDeletesResponseDto();
|
||||
|
||||
group('test AuditDeletesResponseDto', () {
|
||||
// List<String> ids (default value: const [])
|
||||
test('to test the property `ids`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool needsFullSync
|
||||
test('to test the property `needsFullSync`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// 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
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for EntityType
|
||||
void main() {
|
||||
|
||||
group('test EntityType', () {
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
import { AssetResponseDto } from './asset-response.dto';
|
||||
|
||||
export class MemoryLaneResponseDto {
|
||||
title!: string;
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import { DatabaseAction, EntityType } from '@app/infra/entities';
|
||||
import { auditStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, newAuditRepositoryMock } from '@test';
|
||||
import { IAuditRepository } from './audit.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe(AuditService.name, () => {
|
||||
let sut: AuditService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let auditMock: jest.Mocked<IAuditRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
auditMock = newAuditRepositoryMock();
|
||||
sut = new AuditService(accessMock, auditMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleCleanup', () => {
|
||||
it('should delete old audit entries', async () => {
|
||||
await expect(sut.handleCleanup()).resolves.toBe(true);
|
||||
expect(auditMock.removeBefore).toBeCalledWith(expect.any(Date));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDeletes', () => {
|
||||
it('should require full sync if the request is older than 100 days', async () => {
|
||||
auditMock.getAfter.mockResolvedValue([]);
|
||||
|
||||
const date = new Date(2022, 0, 1);
|
||||
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
|
||||
needsFullSync: true,
|
||||
ids: [],
|
||||
});
|
||||
|
||||
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
|
||||
action: DatabaseAction.DELETE,
|
||||
ownerId: authStub.admin.id,
|
||||
entityType: EntityType.ASSET,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get any new or updated assets and deleted ids', async () => {
|
||||
auditMock.getAfter.mockResolvedValue([auditStub.delete]);
|
||||
|
||||
const date = new Date();
|
||||
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
|
||||
needsFullSync: false,
|
||||
ids: ['asset-deleted'],
|
||||
});
|
||||
|
||||
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
|
||||
action: DatabaseAction.DELETE,
|
||||
ownerId: authStub.admin.id,
|
||||
entityType: EntityType.ASSET,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,24 @@
|
||||
import { EntityType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDate, IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AuditDeletesDto {
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
after!: Date;
|
||||
|
||||
@ApiProperty({ enum: EntityType, enumName: 'EntityType' })
|
||||
@IsEnum(EntityType)
|
||||
entityType!: EntityType;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class AuditDeletesResponseDto {
|
||||
needsFullSync!: boolean;
|
||||
ids!: string[];
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
import { AuditEntity, DatabaseAction, EntityType } from '@app/infra/entities';
|
||||
|
||||
export const IAuditRepository = 'IAuditRepository';
|
||||
|
||||
export interface AuditSearch {
|
||||
action?: DatabaseAction;
|
||||
entityType?: EntityType;
|
||||
ownerId?: string;
|
||||
}
|
||||
|
||||
export interface IAuditRepository {
|
||||
getAfter(since: Date, options: AuditSearch): Promise<AuditEntity[]>;
|
||||
removeBefore(before: Date): Promise<void>;
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { DatabaseAction } from '@app/infra/entities';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
|
||||
import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto';
|
||||
import { IAuditRepository } from './audit.repository';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAuditRepository) private repository: IAuditRepository,
|
||||
) {
|
||||
this.access = new AccessCore(accessRepository);
|
||||
}
|
||||
|
||||
async handleCleanup(): Promise<boolean> {
|
||||
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
||||
return true;
|
||||
}
|
||||
|
||||
async getDeletes(authUser: AuthUserDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
|
||||
const userId = dto.userId || authUser.id;
|
||||
await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
|
||||
|
||||
const audits = await this.repository.getAfter(dto.after, {
|
||||
ownerId: userId,
|
||||
entityType: dto.entityType,
|
||||
action: DatabaseAction.DELETE,
|
||||
});
|
||||
|
||||
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.after));
|
||||
|
||||
return {
|
||||
needsFullSync: duration > AUDIT_LOG_MAX_DURATION,
|
||||
ids: audits.map(({ entityId }) => entityId),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export * from './audit.dto';
|
||||
export * from './audit.repository';
|
||||
export * from './audit.service';
|
||||
@ -0,0 +1,18 @@
|
||||
import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Authenticated, AuthUser } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@Controller('audit')
|
||||
@Authenticated()
|
||||
@UseValidation()
|
||||
export class AuditController {
|
||||
constructor(private service: AuditService) {}
|
||||
|
||||
@Get('deletes')
|
||||
getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
|
||||
return this.service.getDeletes(authUser, dto);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
|
||||
export enum DatabaseAction {
|
||||
CREATE = 'CREATE',
|
||||
UPDATE = 'UPDATE',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export enum EntityType {
|
||||
ASSET = 'ASSET',
|
||||
ALBUM = 'ALBUM',
|
||||
}
|
||||
|
||||
@Entity('audit')
|
||||
@Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt'])
|
||||
export class AuditEntity {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id!: number;
|
||||
|
||||
@Column()
|
||||
entityType!: EntityType;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
entityId!: string;
|
||||
|
||||
@Column()
|
||||
action!: DatabaseAction;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
ownerId!: string;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddAuditTable1692804658140 implements MigrationInterface {
|
||||
name = 'AddAuditTable1692804658140'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "audit" ("id" SERIAL NOT NULL, "entityType" character varying NOT NULL, "entityId" uuid NOT NULL, "action" character varying NOT NULL, "ownerId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_1d3d120ddaf7bc9b1ed68ed463a" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ownerId_createdAt" ON "audit" ("ownerId", "createdAt") `);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ownerId_createdAt"`);
|
||||
await queryRunner.query(`DROP TABLE "audit"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import { AuditSearch, IAuditRepository } from '@app/domain';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThan, MoreThan, Repository } from 'typeorm';
|
||||
import { AuditEntity } from '../entities';
|
||||
|
||||
export class AuditRepository implements IAuditRepository {
|
||||
constructor(@InjectRepository(AuditEntity) private repository: Repository<AuditEntity>) {}
|
||||
|
||||
getAfter(since: Date, options: AuditSearch): Promise<AuditEntity[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('audit')
|
||||
.where({
|
||||
createdAt: MoreThan(since),
|
||||
action: options.action,
|
||||
entityType: options.entityType,
|
||||
ownerId: options.ownerId,
|
||||
})
|
||||
.distinctOn(['audit.entityId', 'audit.entityType'])
|
||||
.orderBy('audit.entityId, audit.entityType, audit.createdAt', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async removeBefore(before: Date): Promise<void> {
|
||||
await this.repository.delete({ createdAt: LessThan(before) });
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm';
|
||||
import { AlbumEntity, AssetEntity, AuditEntity, DatabaseAction, EntityType } from '../entities';
|
||||
|
||||
@EventSubscriber()
|
||||
export class AuditSubscriber implements EntitySubscriberInterface<AssetEntity | AlbumEntity> {
|
||||
async afterRemove(event: RemoveEvent<AssetEntity>): Promise<void> {
|
||||
await this.onEvent(DatabaseAction.DELETE, event);
|
||||
}
|
||||
|
||||
private async onEvent<T>(action: DatabaseAction, event: RemoveEvent<T>): Promise<any> {
|
||||
const audit = this.getAudit(event.metadata.name, { ...event.entity, id: event.entityId });
|
||||
if (audit && audit.entityId && audit.ownerId) {
|
||||
await event.manager.getRepository(AuditEntity).save({ ...audit, action });
|
||||
}
|
||||
}
|
||||
|
||||
private getAudit(entityName: string, entity: any): Partial<AuditEntity> | null {
|
||||
switch (entityName) {
|
||||
case AssetEntity.name:
|
||||
const asset = entity as AssetEntity;
|
||||
return {
|
||||
entityType: EntityType.ASSET,
|
||||
entityId: asset.id,
|
||||
ownerId: asset.ownerId,
|
||||
};
|
||||
|
||||
case AlbumEntity.name:
|
||||
const album = entity as AlbumEntity;
|
||||
return {
|
||||
entityType: EntityType.ALBUM,
|
||||
entityId: album.id,
|
||||
ownerId: album.ownerId,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { AuditEntity, DatabaseAction, EntityType } from '@app/infra/entities';
|
||||
import { authStub } from './auth.stub';
|
||||
|
||||
export const auditStub = {
|
||||
create: Object.freeze<AuditEntity>({
|
||||
id: 1,
|
||||
entityId: 'asset-created',
|
||||
action: DatabaseAction.CREATE,
|
||||
entityType: EntityType.ASSET,
|
||||
ownerId: authStub.admin.id,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
update: Object.freeze<AuditEntity>({
|
||||
id: 2,
|
||||
entityId: 'asset-updated',
|
||||
action: DatabaseAction.UPDATE,
|
||||
entityType: EntityType.ASSET,
|
||||
ownerId: authStub.admin.id,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
delete: Object.freeze<AuditEntity>({
|
||||
id: 3,
|
||||
entityId: 'asset-deleted',
|
||||
action: DatabaseAction.DELETE,
|
||||
entityType: EntityType.ASSET,
|
||||
ownerId: authStub.admin.id,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { IAuditRepository } from '@app/domain';
|
||||
|
||||
export const newAuditRepositoryMock = (): jest.Mocked<IAuditRepository> => {
|
||||
return {
|
||||
getAfter: jest.fn(),
|
||||
removeBefore: jest.fn(),
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue