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>
This commit is contained in:
Fynn Petersen-Frey
2023-08-24 21:28:50 +02:00
committed by GitHub
parent d6887117ac
commit cf9e04c8ec
57 changed files with 1381 additions and 36 deletions

View File

@@ -17,6 +17,7 @@ export const databaseConfig: PostgresConnectionOptions = {
entities: [__dirname + '/entities/*.entity.{js,ts}'],
synchronize: false,
migrations: [__dirname + '/migrations/*.{js,ts}'],
subscribers: [__dirname + '/subscribers/*.{js,ts}'],
migrationsRun: true,
connectTimeoutMS: 10000, // 10 seconds
...urlOrParts,

View File

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

View File

@@ -2,6 +2,7 @@ import { AlbumEntity } from './album.entity';
import { APIKeyEntity } from './api-key.entity';
import { AssetFaceEntity } from './asset-face.entity';
import { AssetEntity } from './asset.entity';
import { AuditEntity } from './audit.entity';
import { PartnerEntity } from './partner.entity';
import { PersonEntity } from './person.entity';
import { SharedLinkEntity } from './shared-link.entity';
@@ -15,6 +16,7 @@ export * from './album.entity';
export * from './api-key.entity';
export * from './asset-face.entity';
export * from './asset.entity';
export * from './audit.entity';
export * from './exif.entity';
export * from './partner.entity';
export * from './person.entity';
@@ -30,6 +32,7 @@ export const databaseEntities = [
APIKeyEntity,
AssetEntity,
AssetFaceEntity,
AuditEntity,
PartnerEntity,
PersonEntity,
SharedLinkEntity,

View File

@@ -2,6 +2,7 @@ import {
IAccessRepository,
IAlbumRepository,
IAssetRepository,
IAuditRepository,
ICommunicationRepository,
ICryptoRepository,
IFaceRepository,
@@ -35,6 +36,7 @@ import {
AlbumRepository,
APIKeyRepository,
AssetRepository,
AuditRepository,
CommunicationRepository,
CryptoRepository,
FaceRepository,
@@ -58,6 +60,7 @@ const providers: Provider[] = [
{ provide: IAccessRepository, useClass: AccessRepository },
{ provide: IAlbumRepository, useClass: AlbumRepository },
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: IAuditRepository, useClass: AuditRepository },
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IFaceRepository, useClass: FaceRepository },

View File

@@ -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"`);
}
}

View File

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

View File

@@ -2,6 +2,7 @@ export * from './access.repository';
export * from './album.repository';
export * from './api-key.repository';
export * from './asset.repository';
export * from './audit.repository';
export * from './communication.repository';
export * from './crypto.repository';
export * from './face.repository';

View File

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