Skip to content

Sistema de Auditoria

Visao Geral

O sistema de auditoria do ProCases implementa um log imutavel inspirado em blockchain. Cada registro de auditoria possui um hash SHA-256 que encadeia com o registro anterior, formando uma cadeia verificavel. Qualquer alteracao em um registro intermediario quebra a cadeia, tornando a adulteracao detectavel.

O sistema opera em duas frentes:

  1. AuditInterceptor: captura automaticamente acoes em endpoints decorados com @Audit()
  2. AuditService: API programatica para registro manual de acoes de usuario e sistema

Arquitetura de Arquivos

CamadaArquivoResponsabilidade
Presentationsrc/presentation/controllers/audit.controller.tsEndpoints HTTP
Presentationsrc/presentation/modules/audit.module.tsModulo de apresentacao
Presentationsrc/presentation/interceptors/audit.interceptor.tsInterceptor automatico
Presentationsrc/presentation/decorators/audit.decorator.tsDecorator @Audit()
Infrastructuresrc/infrastructure/audit/audit.service.tsServico principal
Infrastructuresrc/infrastructure/audit/audit.module.tsModulo de infraestrutura
Applicationsrc/application/use-cases/audit/verify-chain.use-case.tsVerificacao de cadeia
Applicationsrc/application/use-cases/audit/get-user-audit-logs.use-case.tsLogs do usuario
Applicationsrc/application/use-cases/audit/get-entity-audit-logs.use-case.tsLogs por entidade
Applicationsrc/application/dto/audit.dto.tsDTOs

Modelo de Banco de Dados

AuditLog

prisma
model AuditLog {
  id              BigInt   @id
  userId          BigInt?  @map("user_id")

  action          String
  entityType      String   @map("entity_type")
  entityId        String   @map("entity_id")

  previousHash    String?  @map("previous_hash")
  currentHash     String   @map("current_hash")

  metadata        Json?

  ipAddress       String   @map("ip_address")
  userAgent       String?  @map("user_agent")

  createdAt       DateTime @default(now()) @map("created_at")

  @@map("audit_logs")
  @@index([userId])
  @@index([action])
  @@index([entityType, entityId])
  @@index([createdAt])
}

Campos:

CampoTipoDescricao
idBigInt (PK)Snowflake ID
userIdBigInt?Usuario que executou a acao (null para acoes de sistema)
actionStringTipo da acao (ex: CREATE, UPDATE, DELETE, LOGIN)
entityTypeStringTipo da entidade (ex: case, user, battle)
entityIdStringID da entidade afetada
previousHashString?Hash SHA-256 do registro anterior (null para o primeiro)
currentHashStringHash SHA-256 deste registro
metadataJson?Dados adicionais (body, params, query, etc.)
ipAddressStringIP do requisitante (system para acoes automaticas)
userAgentString?User-Agent do navegador
createdAtDateTimeTimestamp de criacao

Indices:

  • userId - busca rapida de logs por usuario
  • action - filtro por tipo de acao
  • (entityType, entityId) - busca de historico de uma entidade especifica
  • createdAt - ordenacao temporal

Calculo do Hash (Blockchain-like)

O hash de cada registro e calculado encadeando com o hash anterior:

typescript
private calculateHash(data: string, previousHash: string | null): string {
  const input = previousHash ? `${previousHash}:${data}` : data;
  return createHash('sha256').update(input).digest('hex');
}

Estrutura do dado hashado:

typescript
const currentData = JSON.stringify({
  userId: data.userId?.toString(),
  action: data.action,
  entityType: data.entityType,
  entityId: data.entityId,
  metadata: data.metadata,
  timestamp: Date.now(),
});

Formula:

Se previousHash existe:
  hash = SHA-256(previousHash + ":" + JSON.stringify(data))

Se previousHash nao existe (primeiro registro):
  hash = SHA-256(JSON.stringify(data))

Diagrama da Cadeia:

Log #1: currentHash = SHA-256(data1)                    previousHash = null
Log #2: currentHash = SHA-256(hash1 + ":" + data2)      previousHash = hash1
Log #3: currentHash = SHA-256(hash2 + ":" + data3)      previousHash = hash2
Log #4: currentHash = SHA-256(hash3 + ":" + data4)      previousHash = hash3

Se alguem alterar o data2 do Log #2, o currentHash do Log #2 muda. Isso faz com que o previousHash do Log #3 nao bata mais, quebrando a cadeia de verificacao.


AuditService

Metodo log()

Metodo principal que registra uma acao de auditoria:

typescript
async log(data: {
  userId?: bigint;
  action: string;
  entityType: string;
  entityId: string;
  metadata?: any;
  ipAddress: string;
  userAgent?: string;
}) {
  const previousLog = await this.getLastAuditLog();

  const currentData = JSON.stringify({
    userId: data.userId?.toString(),
    action: data.action,
    entityType: data.entityType,
    entityId: data.entityId,
    metadata: data.metadata,
    timestamp: Date.now(),
  });

  const currentHash = this.calculateHash(
    currentData,
    previousLog?.currentHash ?? null
  );

  return this.prisma.auditLog.create({
    data: {
      id: this.snowflake.generate(),
      userId: data.userId,
      action: data.action,
      entityType: data.entityType,
      entityId: data.entityId,
      previousHash: previousLog?.currentHash || null,
      currentHash,
      metadata: data.metadata,
      ipAddress: data.ipAddress,
      userAgent: data.userAgent,
    },
  });
}

Fluxo:

  1. Busca o ultimo registro de auditoria (ORDER BY id DESC LIMIT 1)
  2. Serializa os dados atuais como JSON
  3. Calcula o hash encadeando com o hash do registro anterior
  4. Persiste o novo registro com Snowflake ID

Metodo logUserAction()

Wrapper para acoes de usuario (com userId obrigatorio):

typescript
async logUserAction(
  userId: bigint,
  action: string,
  entityType: string,
  entityId: string,
  metadata?: any,
  ipAddress?: string,
  userAgent?: string,
) {
  return this.log({
    userId,
    action,
    entityType,
    entityId,
    metadata,
    ipAddress: ipAddress || 'unknown',
    userAgent,
  });
}

Metodo logSystemAction()

Wrapper para acoes automaticas do sistema (sem userId):

typescript
async logSystemAction(
  action: string,
  entityType: string,
  entityId: string,
  metadata?: any,
) {
  return this.log({
    action,
    entityType,
    entityId,
    metadata,
    ipAddress: 'system',
    userAgent: 'system',
  });
}

Metodo verifyChainIntegrity()

Verifica a integridade de toda a cadeia de hashes:

typescript
async verifyChainIntegrity(startId?: bigint, endId?: bigint) {
  const logs = await this.prisma.auditLog.findMany({
    where: {
      ...(startId && { id: { gte: startId } }),
      ...(endId && { id: { lte: endId } }),
    },
    orderBy: { id: 'asc' },
  });

  for (let i = 0; i < logs.length; i++) {
    const log = logs[i];
    const previousLog = i > 0 ? logs[i - 1] : null;

    // Verificacao 1: previousHash bate com o currentHash do anterior
    if (log.previousHash !== (previousLog?.currentHash || null)) {
      return {
        valid: false,
        corruptedLogId: log.id,
        message: `Chain integrity broken at log ${log.id}`,
      };
    }

    // Verificacao 2: currentHash e recalculado e comparado
    const expectedHash = this.calculateHash(
      JSON.stringify({
        userId: log.userId?.toString(),
        action: log.action,
        entityType: log.entityType,
        entityId: log.entityId,
        metadata: log.metadata,
        timestamp: log.createdAt.getTime(),
      }),
      log.previousHash,
    );

    if (log.currentHash !== expectedHash) {
      return {
        valid: false,
        corruptedLogId: log.id,
        message: `Hash mismatch at log ${log.id}`,
      };
    }
  }

  return {
    valid: true,
    logsVerified: logs.length,
    message: 'Chain integrity verified',
  };
}

Duas verificacoes por registro:

  1. O previousHash do registro atual e igual ao currentHash do registro anterior?
  2. O currentHash pode ser reproduzido a partir dos dados do registro?

Se qualquer verificacao falhar, retorna o ID do registro corrompido.

ATENCAO - divergencia real de timestamp: existe uma divergencia entre log() e verifyChainIntegrity(). No log(), o hash e calculado com timestamp: Date.now() (instante capturado na aplicacao no momento da escrita). Na verificacao, o hash esperado usa timestamp: log.createdAt.getTime(). Como createdAt nao e setado pela aplicacao no create() — vem do @default(now()) do banco, um instante diferente do Date.now() original — os dois valores divergem sistematicamente. Consequencia: verifyChainIntegrity() tende a acusar "Hash mismatch" para os logs. Trate isso como um bug latente a validar/corrigir antes de confiar no verify-chain (ex.: persistir explicitamente o mesmo timestamp usado no hash).

Metodo getUserLogs()

Busca logs de um usuario especifico com filtros:

typescript
async getUserLogs(
  userId: bigint,
  filters?: {
    action?: string;
    entityType?: string;
    limit?: number;
    offset?: number;
  },
) {
  return this.prisma.auditLog.findMany({
    where: {
      userId,
      ...(filters?.action && { action: filters.action }),
      ...(filters?.entityType && { entityType: filters.entityType }),
    },
    orderBy: { createdAt: 'desc' },
    take: filters?.limit,
    skip: filters?.offset,
  });
}

Metodo getEntityLogs()

Busca todos os logs de uma entidade especifica:

typescript
async getEntityLogs(entityType: string, entityId: string) {
  return this.prisma.auditLog.findMany({
    where: { entityType, entityId },
    orderBy: { createdAt: 'desc' },
  });
}

Decorator @Audit()

typescript
import { SetMetadata } from '@nestjs/common';

export const AUDIT_KEY = 'audit';

export interface AuditMetadata {
  action: string;
  entityType: string;
}

export const Audit = (action: string, entityType: string) =>
  SetMetadata(AUDIT_KEY, { action, entityType });

Uso em controllers:

typescript
@Post()
@Audit('CREATE', 'case')
async createCase(@Body() dto: CreateCaseDto) {
  return this.createCaseUseCase.execute(dto);
}

@Patch(':id')
@Audit('UPDATE', 'case')
async updateCase(@Param('id') id: string, @Body() dto: UpdateCaseDto) {
  return this.updateCaseUseCase.execute(BigInt(id), dto);
}

O decorator armazena metadata que sera lida pelo AuditInterceptor.


AuditInterceptor

O interceptor automaticamente registra acoes em endpoints decorados com @Audit():

typescript
@Injectable()
export class AuditInterceptor implements NestInterceptor {
  constructor(
    private readonly reflector: Reflector,
    private readonly auditService: AuditService,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const auditMetadata = this.reflector.get<AuditMetadata>(
      AUDIT_KEY, context.getHandler()
    );

    if (!auditMetadata) {
      return next.handle(); // sem decorator, nao audita
    }

    const request = context.switchToHttp().getRequest();
    const userId = request.user?.id;
    const ipAddress = request.ip || request.connection.remoteAddress;
    const userAgent = request.headers['user-agent'];

    return next.handle().pipe(
      tap((response) => {
        const entityId = this.extractEntityId(request, response);

        if (userId) {
          this.auditService.logUserAction(
            BigInt(userId),
            auditMetadata.action,
            auditMetadata.entityType,
            entityId,
            {
              method: request.method,
              url: request.url,
              body: request.body,
              params: request.params,
              query: request.query,
            },
            ipAddress,
            userAgent,
          );
        }
      }),
    );
  }

  private extractEntityId(request: any, response: any): string {
    if (response?.id) return response.id.toString();
    if (request.params?.id) return request.params.id;
    if (request.body?.id) return request.body.id.toString();
    return 'unknown';
  }
}

Comportamento:

  1. Verifica se o handler tem o decorator @Audit()
  2. Se nao tiver, executa normalmente sem auditoria
  3. Se tiver, extrai dados do request (user, IP, user-agent)
  4. Executa o handler normalmente via next.handle()
  5. Apos sucesso (tap), registra a acao no AuditService
  6. O entityId e extraido em ordem de prioridade: response.id > params.id > body.id > 'unknown'

IMPORTANTE: O registro de auditoria acontece apos o handler executar com sucesso. Se o handler lancar uma excecao, a auditoria NAO e registrada. Isso e intencional - so auditamos acoes que efetivamente aconteceram.

Metadata salva (apos redacao/sumarizacao):

typescript
{
  method: 'POST',
  url: '/api/admin/cases',
  body: { name: 'Nova Caixa', price: 1990 },
  params: {},
  query: {},
}

Redacao de dados sensiveis: antes de persistir, o interceptor redige chaves sensiveis no metadata — password, twoFactorCode, totp, totpCode, secret, twoFactorSecret, turnstileToken, sessionId, apiKey. A resposta e reduzida por summarizeResponse, que mantem apenas success, status, id e message. So grava o registro se houver userId.

Na pratica, a maior parte dos audits e escrita por chamada direta a auditService.logUserAction(...) dentro dos use-cases (ex.: depositos, saques, cripto, skinsback), e nao pelo AuditInterceptor. O interceptor cobre os endpoints decorados com @Audit().


Endpoints

GET /audit/my-logs

Retorna os logs de auditoria do usuario autenticado.

Autenticacao: AuthGuard

Privacidade: neste endpoint o ipAddress de cada log e mascarado por maskIp (preserva apenas os dois primeiros octetos, ex.: 192.168.x.x). O IP completo so e exposto no endpoint de entidade (admin).

Query Parameters:

ParamTipoObrigatorioDescricao
actionstringnaoFiltrar por tipo de acao
entityTypestringnaoFiltrar por tipo de entidade
limitnumbernaoLimite de resultados (default: 50)
offsetnumbernaoOffset para paginacao (default: 0)

Response (200) - AuditLogResponseDto[]:

typescript
[
  {
    id: "819234567890123456",
    userId: "123456789",
    action: "OPEN_CASE",
    entityType: "case",
    entityId: "456",
    previousHash: "a1b2c3d4...",
    currentHash: "e5f6g7h8...",
    metadata: {
      method: "POST",
      url: "/api/cases/456/open",
      body: { clientSeed: "abc" },
      params: { id: "456" },
      query: {}
    },
    ipAddress: "192.168.x.x",
    userAgent: "Mozilla/5.0...",
    createdAt: "2025-06-15T14:30:00.000Z"
  }
]

O ipAddress acima aparece mascarado (192.168.x.x) porque my-logs aplica maskIp.

GET /audit/verify-chain

Verifica a integridade da cadeia de hashes.

Autenticacao: AdminGuard (endpoint restrito a administradores)

Query Parameters:

ParamTipoObrigatorioDescricao
startIdstringnaoID inicial do range (Snowflake)
endIdstringnaoID final do range (Snowflake)

Response (200) - VerifyChainResponseDto:

typescript
// Cadeia integra:
{
  valid: true,
  logsVerified: 1523,
  message: "Chain integrity verified"
}

// Cadeia corrompida:
{
  valid: false,
  corruptedLogId: "819234567890123456",
  message: "Chain integrity broken at log 819234567890123456"
}

// Hash inconsistente:
{
  valid: false,
  corruptedLogId: "819234567890123456",
  message: "Hash mismatch at log 819234567890123456"
}

Se startId e endId nao forem fornecidos, verifica TODA a cadeia.

GET /audit/entity/:type/:id

Retorna todos os logs de auditoria de uma entidade especifica.

Autenticacao: AdminGuard (endpoint restrito a administradores)

Diferente de my-logs, aqui o ipAddress nao e mascarado — o IP completo e retornado (visao administrativa).

Path Parameters:

ParamTipoDescricao
typestringTipo da entidade (ex: case, user, battle)
idstringID da entidade

Response (200) - AuditLogResponseDto[]:

Mesmo formato de GET /audit/my-logs.

Exemplo: GET /audit/entity/case/456 retorna todos os logs da caixa com ID 456 (criacao, atualizacao, exclusao, etc.).


DTOs

GetAuditLogsQueryDto

typescript
export class GetAuditLogsQueryDto {
  @IsOptional()
  @IsString()
  action?: string;

  @IsOptional()
  @IsString()
  entityType?: string;

  @IsOptional()
  @Type(() => Number)
  limit?: number;

  @IsOptional()
  @Type(() => Number)
  offset?: number;
}

AuditLogResponseDto

typescript
export class AuditLogResponseDto {
  id: string;
  userId?: string;
  action: string;
  entityType: string;
  entityId: string;
  previousHash?: string;
  currentHash: string;
  metadata?: any;
  ipAddress: string;
  userAgent?: string;
  createdAt: Date;
}

VerifyChainResponseDto

typescript
export class VerifyChainResponseDto {
  valid: boolean;
  logsVerified?: number;
  corruptedLogId?: string;
  message: string;
}

Modulos NestJS

Modulo de Infraestrutura

typescript
// src/infrastructure/audit/audit.module.ts
@Module({
  providers: [AuditService, PrismaClient, SnowflakeService],
  exports: [AuditService],
})
export class AuditModule {}

Modulo de Apresentacao

typescript
// src/presentation/modules/audit.module.ts
@Module({
  imports: [InfraAuditModule, AuthModule],
  controllers: [AuditController],
  providers: [
    VerifyChainUseCase,
    GetUserAuditLogsUseCase,
    GetEntityAuditLogsUseCase,
  ],
})
export class AuditPresentationModule {}

Use Cases

VerifyChainUseCase

typescript
@Injectable()
export class VerifyChainUseCase {
  constructor(private readonly auditService: AuditService) {}

  async execute(startId?: bigint, endId?: bigint) {
    return this.auditService.verifyChainIntegrity(startId, endId);
  }
}

GetUserAuditLogsUseCase

typescript
@Injectable()
export class GetUserAuditLogsUseCase {
  constructor(private readonly auditService: AuditService) {}

  async execute(
    userId: bigint,
    filters?: {
      action?: string;
      entityType?: string;
      limit?: number;
      offset?: number;
    },
  ) {
    return this.auditService.getUserLogs(userId, filters);
  }
}

GetEntityAuditLogsUseCase

typescript
@Injectable()
export class GetEntityAuditLogsUseCase {
  constructor(private readonly auditService: AuditService) {}

  async execute(entityType: string, entityId: string) {
    return this.auditService.getEntityLogs(entityType, entityId);
  }
}

Exemplos de Uso Programatico

Registrar acao de usuario manualmente

typescript
await this.auditService.logUserAction(
  userId,
  'WITHDRAW',
  'withdrawal',
  withdrawalId.toString(),
  { amountCents: 50000, method: 'PIX' },
  '192.168.1.1',
  'Mozilla/5.0...',
);

Registrar acao de sistema

typescript
await this.auditService.logSystemAction(
  'AUTO_BAN',
  'user',
  suspiciousUserId.toString(),
  { reason: 'Suspicious betting pattern', riskScore: 95 },
);

Verificar cadeia em script

typescript
const result = await this.auditService.verifyChainIntegrity();
if (!result.valid) {
  console.error(`ALERTA: Cadeia corrompida no log ${result.corruptedLogId}`);
  console.error(result.message);
}

Debugging

Verificar ultimo log

sql
SELECT id, action, entity_type, entity_id, previous_hash, current_hash
FROM audit_logs
ORDER BY id DESC
LIMIT 5;

Verificar cadeia manualmente

sql
SELECT
  a.id,
  a.previous_hash,
  a.current_hash,
  b.current_hash as expected_previous
FROM audit_logs a
LEFT JOIN audit_logs b ON a.previous_hash = b.current_hash
WHERE a.previous_hash IS NOT NULL
  AND b.id IS NULL;
-- Se retornar linhas, a cadeia esta quebrada

Problemas Comuns

ProblemaCausaSolucao
Cadeia sempre invalida (Hash mismatch)log() calcula o hash com Date.now(), mas verifyChainIntegrity() recomputa com createdAt (default do banco)Bug latente conhecido — alinhar o timestamp persistido ao usado no hash antes de confiar no verify-chain
Logs duplicadosInterceptor + chamada manualUsar apenas um metodo (decorator OU manual)
entityId = 'unknown'Response sem id e sem paramsGarantir que response ou params tenham id
Performance lenta na verificacaoMuitos logsUsar startId/endId para verificar ranges

Regras Importantes

  1. Logs sao IMUTAVEIS - nunca altere ou delete registros de auditoria
  2. A cadeia deve ser verificada periodicamente - cron job ou verificacao manual
  3. Use @Audit() para acoes automaticas e logSystemAction() para acoes programaticas
  4. Nunca use os dois ao mesmo tempo no mesmo endpoint (duplicaria logs)
  5. O hash inclui timestamp - logs nao podem ser reproduzidos retroativamente
  6. userId pode ser null - para acoes de sistema (webhooks, crons, etc.)
  7. metadata e flexivel - pode conter qualquer JSON, mas evite dados sensiveis (senhas, tokens)
  8. Indices otimizam queries - use os filtros disponiveis ao inves de busca full-table

Documentação Técnica - ProCases