NestboltNestbolt

@nestbolt/audit-log

Actor Resolution

Track which user or system made each change by implementing the ActorResolver interface.

Actor resolution determines who performed each audited action. The module resolves actors automatically for both subscriber-based (automatic) and manual audit logging when no explicit actor is provided.

How Actor Resolution Works

When an audit log entry is created, the module resolves the actor through the following priority chain:

  1. Explicit actor -- if actor is provided directly to AuditLogService.log(), it is used as-is. This only applies to manual logging.
  2. ActorResolver -- if an actorResolver class is configured in the module options, its resolve() method is called.
  3. defaultActor -- if no resolver is configured, or the resolver returns null, the defaultActor from module options is used.
  4. null -- if none of the above produce a result, actorType and actorId are both null.

The ActorResolver Interface

interface AuditActor {
  type: string;
  id: string;
}

interface ActorResolver {
  resolve(): AuditActor | null | Promise<AuditActor | null>;
}

The resolve() method can be synchronous or asynchronous. It should return an AuditActor object with a type (describing the kind of actor, such as "User", "Admin", or "System") and an id (the actor's unique identifier), or null if the actor cannot be determined.

Implementing an Actor Resolver

The most common approach uses nestjs-cls (Continuation-Local Storage) to access request-scoped data from within the resolver. This works because nestjs-cls maintains a per-request store that is accessible anywhere in the call chain, including TypeORM subscribers.

import { Injectable } from "@nestjs/common";
import { ClsService } from "nestjs-cls";
import { ActorResolver, AuditActor } from "@nestbolt/audit-log";

@Injectable()
export class RequestActorResolver implements ActorResolver {
  constructor(private readonly cls: ClsService) {}

  resolve(): AuditActor | null {
    const userId = this.cls.get("userId");
    const userRole = this.cls.get("userRole");

    if (!userId) {
      return null;
    }

    return {
      type: userRole === "admin" ? "Admin" : "User",
      id: userId,
    };
  }
}

Set up the CLS context in a middleware or guard:

import { Injectable, NestMiddleware } from "@nestjs/common";
import { ClsService } from "nestjs-cls";
import { Request, Response, NextFunction } from "express";

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  constructor(private readonly cls: ClsService) {}

  use(req: Request, res: Response, next: NextFunction): void {
    // Assuming req.user is set by a prior auth guard/middleware
    if (req.user) {
      this.cls.set("userId", req.user.id);
      this.cls.set("userRole", req.user.role);
    }
    next();
  }
}

Register the module and resolver:

import { Module, MiddlewareConsumer, NestModule } from "@nestjs/common";
import { ClsModule } from "nestjs-cls";
import { AuditLogModule } from "@nestbolt/audit-log";
import { RequestActorResolver } from "./audit/request-actor.resolver";
import { AuthMiddleware } from "./auth/auth.middleware";

@Module({
  imports: [
    ClsModule.forRoot({ middleware: { mount: true } }),
    AuditLogModule.forRoot({
      actorResolver: RequestActorResolver,
    }),
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(AuthMiddleware).forRoutes("*");
  }
}

Using AsyncLocalStorage Directly

If you prefer not to use nestjs-cls, you can use Node.js AsyncLocalStorage directly:

import { Injectable } from "@nestjs/common";
import { AsyncLocalStorage } from "async_hooks";
import { ActorResolver, AuditActor } from "@nestbolt/audit-log";

export interface RequestContext {
  userId?: string;
  userType?: string;
}

export const requestContext = new AsyncLocalStorage<RequestContext>();

@Injectable()
export class AsyncLocalStorageActorResolver implements ActorResolver {
  resolve(): AuditActor | null {
    const ctx = requestContext.getStore();
    if (!ctx?.userId) {
      return null;
    }
    return { type: ctx.userType ?? "User", id: ctx.userId };
  }
}

Wrap your request handler in the AsyncLocalStorage.run() call:

import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { requestContext } from "./async-local-storage-actor.resolver";

@Injectable()
export class ContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    const store = {
      userId: req.user?.id,
      userType: req.user?.role === "admin" ? "Admin" : "User",
    };
    requestContext.run(store, () => next());
  }
}

Resolver with Multiple Actor Types

import { Injectable } from "@nestjs/common";
import { ClsService } from "nestjs-cls";
import { ActorResolver, AuditActor } from "@nestbolt/audit-log";

@Injectable()
export class MultiActorResolver implements ActorResolver {
  constructor(private readonly cls: ClsService) {}

  resolve(): AuditActor | null {
    // Check for API key authentication
    const apiKeyId = this.cls.get("apiKeyId");
    if (apiKeyId) {
      return { type: "ApiKey", id: apiKeyId };
    }

    // Check for service-to-service authentication
    const serviceId = this.cls.get("serviceId");
    if (serviceId) {
      return { type: "Service", id: serviceId };
    }

    // Check for user authentication
    const userId = this.cls.get("userId");
    if (userId) {
      const isAdmin = this.cls.get("isAdmin");
      return { type: isAdmin ? "Admin" : "User", id: userId };
    }

    return null;
  }
}

Async Resolver (Database Lookup)

The resolver can be asynchronous if you need to look up actor information from a database or external service:

import { Injectable } from "@nestjs/common";
import { ClsService } from "nestjs-cls";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { ActorResolver, AuditActor } from "@nestbolt/audit-log";
import { User } from "../entities/user.entity";

@Injectable()
export class DatabaseActorResolver implements ActorResolver {
  constructor(
    private readonly cls: ClsService,
    @InjectRepository(User)
    private readonly userRepo: Repository<User>,
  ) {}

  async resolve(): Promise<AuditActor | null> {
    const userId = this.cls.get("userId");
    if (!userId) return null;

    const user = await this.userRepo.findOne({ where: { id: userId } });
    if (!user) return null;

    return { type: user.role, id: user.id };
  }
}

Using defaultActor Without a Resolver

For applications that do not need per-request actor resolution (such as CLI tools, background workers, or single-tenant systems), use defaultActor instead:

AuditLogModule.forRoot({
  defaultActor: { type: "System", id: "background-worker" },
});

All audit log entries will have actorType: "System" and actorId: "background-worker".

Combining defaultActor and actorResolver

When both are configured, the resolver is called first. If it returns null, the defaultActor is used as a fallback:

AuditLogModule.forRoot({
  actorResolver: RequestActorResolver,
  defaultActor: { type: "System", id: "system" },
});

This is useful when:

  • HTTP requests have an authenticated user (resolved by RequestActorResolver).
  • Background jobs and CLI commands have no request context, so the resolver returns null and defaultActor is used.

Overriding the Actor in Manual Logging

When calling AuditLogService.log() with an explicit actor, the resolver and defaultActor are both bypassed:

await this.auditLogService.log({
  action: "updated",
  entityType: "User",
  entityId: userId,
  oldValues: { status: "active" },
  newValues: { status: "suspended" },
  actor: { type: "CronJob", id: "daily-cleanup" },
});

Programmatic Actor Resolution

You can also call the resolver programmatically via the service:

const actor = await this.auditLogService.resolveActor();
// Returns the result of actorResolver.resolve(), or defaultActor, or null

This is useful when you need the current actor for purposes other than audit logging.

Next Steps

  • Events -- listen for audit log events and trigger downstream actions.
  • Configuration -- review all module configuration options.