Search code examples
postgresqlnestjstypeormaudit-loggingnestjs-typeorm

Audit logs with TypeORM and NestJS


I'm developing a NestJS API that requires an audit log to record all database changes, along with the user responsible for them.

The issue is that, in TypeORM hooks (before, after, etc.), I don't have access to the current request or the user making the change/request.

What would be the best approach to implement this feature in a generic way that works for all entities?

I tried adding the user ID as a temporary field in the entity class, which somewhat worked for inserts/updates. However, for delete operations, I can't call an update function every time I delete something, and that doesn't seem like a good practice.

Currently my TypeORM EventSubscriber looks like this:

import GenericEntity from "@generic/repository/generic.entity";
import { Injectable } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntitySubscriberInterface, EventSubscriber, InsertEvent, RemoveEvent, UpdateEvent } from "typeorm";
import { LogService } from "./log.service";
import { eLogType } from "./repository/log.entity";
import { AppLogger } from "@utils/logger";

interface Events {
    log: {
        type: eLogType;
        userId: string | "system";
        tabela: string;
        values: {
            id: string;
            old: Record<string, any>;
            new: Record<string, any>;
        };
    };
}

@EventSubscriber()
@Injectable()
export class LogEventService implements EntitySubscriberInterface {
    private tabelasSemLog: string[] = [];

    constructor(
        @InjectDataSource() readonly dataSource: DataSource,
        private readonly logService: LogService,
    ) {
        dataSource.subscribers.push(this);
    }

    private async saveLog(data: Events["log"]) {
        if (await this.logService.saveLog(data.type, data.userId, data.tabela, data.values))
            AppLogger.log(`Log salvo com sucesso para a tabela ${data.tabela}`);
        else throw new Error("Erro ao salvar log -> " + JSON.stringify(data));
    }

    private async generateLogData(tabela: string, type: eLogType, entity: GenericEntity) {
        const copy = { ...entity };
        const oldValue = copy.previousState;
        delete copy.previousState;

        await this.saveLog({
            tabela,
            type,
            userId: "", // Need to obtain the user id from the request
            values: {
                id: entity.id,
                new: copy,
                old: oldValue,
            },
        });
    }

    async afterInsert(event: InsertEvent<GenericEntity>): Promise<any> {
        const tabela = event.metadata.tableName;
        if (this.tabelasSemLog.includes(tabela)) return;

        event.entity.id = event.entity.id ?? event.entityId?.toString();
        await this.generateLogData(tabela, eLogType.INCLUSAO, event.entity);
    }

    async afterUpdate(event: UpdateEvent<GenericEntity>): Promise<any> {
        const tabela = event.metadata.tableName;
        if (this.tabelasSemLog.includes(tabela)) return;

        event.entity.id = event.entity.id;
        await this.generateLogData(tabela, eLogType.ALTERACAO, event.entity);
    }

    async afterRemove(event: RemoveEvent<GenericEntity>): Promise<any> {
        const tabela = event.metadata.tableName;
        if (this.tabelasSemLog.includes(tabela)) return;

        event.entity.id = event.entity.id ?? event.entityId?.toString();
        await this.generateLogData(tabela, eLogType.EXCLUSAO, event.entity);
    }
}


Solution

  • Solved my problem by using nestjs-cls, with this I could pass my user to my typeorm event listener service.

    My app now looks something like this:

    app.module.ts

    @Module({
        imports: [
            ConfigModule.forRoot({...}),
            TypeOrmModule.forRootAsync({...}),
            ClsModule.forRoot({
                global: true,
                middleware: { mount: true },
            }),
            AuthModule,
            LogModule,
        ],
        controllers: [AppController],
        providers: [
            AppService,
            {
                provide: APP_GUARD,
                useClass: AuthGuard,
            },
        ],
    })
    export class AppModule {}
    

    auth.guard.ts

    @Injectable()
    export class AuthGuard implements CanActivate {
        constructor(
            private jwtService: JwtService,
            private reflector: Reflector,
            private readonly cls: ClsService,
        ) {}
    
        async canActivate(context: ExecutionContext): Promise<boolean> {
            const request = context.switchToHttp().getRequest() as FastifyRequest;
            const token = this.extractTokenFromHeader(request);
    
            try {
                const payload = await this.jwtService.verifyAsync(token, { secret: process.env.SECRET_KEY });
    
                request.session.data = payload;
                this.cls.set("userId", payload.usuario?.id); // saves the user id in the cls context
            } catch {
                throw new UnauthorizedException("err");
            }
    
            return true;
        }
    }
    

    log.event.ts

    @EventSubscriber()
    @Injectable()
    export class LogEventService implements  EntitySubscriberInterface {
    
        constructor(
            @InjectDataSource() readonly dataSource: DataSource,
            private readonly cls: ClsService,
        ) {
            dataSource.subscribers.push(this);
        }
    
        private getUserId() {
            const userId: string | undefined = this.cls.get("userId");
            return userId;
        }
    
        private async generateLogData(userId: string, tabela: string, type: eLogType, entity: GenericEntity) {
            ...
        }
    
        async afterInsert(event: InsertEvent<GenericEntity>): Promise<any> {
            const userId = this.getUserId() ?? "sistema";
            const tabela = event.metadata.tableName;
    
            event.entity.id = event.entity.id ?? event.entityId?.toString();
            await this.generateLogData(userId, tabela, eLogType.INCLUSAO, event.entity);
        }
    
        async afterUpdate(event: UpdateEvent<GenericEntity>): Promise<any> {
            const userId = this.getUserId();
            const tabela = event.metadata.tableName;
    
            event.entity.id = event.entity.id;
            await this.generateLogData(userId, tabela, eLogType.ALTERACAO, event.entity);
        }
    
        async afterRemove(event: RemoveEvent<GenericEntity>): Promise<any> {
            const userId = this.getUserId();
            const tabela = event.metadata.tableName;
    
            event.entity.id = event.entity.id ?? event.entityId?.toString();
            await this.generateLogData(userId, tabela, eLogType.EXCLUSAO, event.entity);
        }
    }