Lucas Gardini Dias
Lucas Gardini Dias

Reputation: 600

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

Upvotes: 0

Views: 574

Answers (1)

Lucas Gardini Dias
Lucas Gardini Dias

Reputation: 600

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

Upvotes: 1

Related Questions