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:
- AuditInterceptor: captura automaticamente acoes em endpoints decorados com
@Audit() - AuditService: API programatica para registro manual de acoes de usuario e sistema
Arquitetura de Arquivos
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Presentation | src/presentation/controllers/audit.controller.ts | Endpoints HTTP |
| Presentation | src/presentation/modules/audit.module.ts | Modulo de apresentacao |
| Presentation | src/presentation/interceptors/audit.interceptor.ts | Interceptor automatico |
| Presentation | src/presentation/decorators/audit.decorator.ts | Decorator @Audit() |
| Infrastructure | src/infrastructure/audit/audit.service.ts | Servico principal |
| Infrastructure | src/infrastructure/audit/audit.module.ts | Modulo de infraestrutura |
| Application | src/application/use-cases/audit/verify-chain.use-case.ts | Verificacao de cadeia |
| Application | src/application/use-cases/audit/get-user-audit-logs.use-case.ts | Logs do usuario |
| Application | src/application/use-cases/audit/get-entity-audit-logs.use-case.ts | Logs por entidade |
| Application | src/application/dto/audit.dto.ts | DTOs |
Modelo de Banco de Dados
AuditLog
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:
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt (PK) | Snowflake ID |
userId | BigInt? | Usuario que executou a acao (null para acoes de sistema) |
action | String | Tipo da acao (ex: CREATE, UPDATE, DELETE, LOGIN) |
entityType | String | Tipo da entidade (ex: case, user, battle) |
entityId | String | ID da entidade afetada |
previousHash | String? | Hash SHA-256 do registro anterior (null para o primeiro) |
currentHash | String | Hash SHA-256 deste registro |
metadata | Json? | Dados adicionais (body, params, query, etc.) |
ipAddress | String | IP do requisitante (system para acoes automaticas) |
userAgent | String? | User-Agent do navegador |
createdAt | DateTime | Timestamp de criacao |
Indices:
userId- busca rapida de logs por usuarioaction- filtro por tipo de acao(entityType, entityId)- busca de historico de uma entidade especificacreatedAt- ordenacao temporal
Calculo do Hash (Blockchain-like)
O hash de cada registro e calculado encadeando com o hash anterior:
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:
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 = hash3Se 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:
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:
- Busca o ultimo registro de auditoria (
ORDER BY id DESC LIMIT 1) - Serializa os dados atuais como JSON
- Calcula o hash encadeando com o hash do registro anterior
- Persiste o novo registro com Snowflake ID
Metodo logUserAction()
Wrapper para acoes de usuario (com userId obrigatorio):
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):
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:
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:
- O
previousHashdo registro atual e igual aocurrentHashdo registro anterior? - O
currentHashpode 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:
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:
async getEntityLogs(entityType: string, entityId: string) {
return this.prisma.auditLog.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'desc' },
});
}Decorator @Audit()
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:
@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():
@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:
- Verifica se o handler tem o decorator
@Audit() - Se nao tiver, executa normalmente sem auditoria
- Se tiver, extrai dados do request (user, IP, user-agent)
- Executa o handler normalmente via
next.handle() - Apos sucesso (
tap), registra a acao noAuditService - O
entityIde 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):
{
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 peloAuditInterceptor. 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
ipAddressde cada log e mascarado pormaskIp(preserva apenas os dois primeiros octetos, ex.:192.168.x.x). O IP completo so e exposto no endpoint de entidade (admin).
Query Parameters:
| Param | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
action | string | nao | Filtrar por tipo de acao |
entityType | string | nao | Filtrar por tipo de entidade |
limit | number | nao | Limite de resultados (default: 50) |
offset | number | nao | Offset para paginacao (default: 0) |
Response (200) - AuditLogResponseDto[]:
[
{
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
ipAddressacima aparece mascarado (192.168.x.x) porquemy-logsaplicamaskIp.
GET /audit/verify-chain
Verifica a integridade da cadeia de hashes.
Autenticacao: AdminGuard (endpoint restrito a administradores)
Query Parameters:
| Param | Tipo | Obrigatorio | Descricao |
|---|---|---|---|
startId | string | nao | ID inicial do range (Snowflake) |
endId | string | nao | ID final do range (Snowflake) |
Response (200) - VerifyChainResponseDto:
// 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 oipAddressnao e mascarado — o IP completo e retornado (visao administrativa).
Path Parameters:
| Param | Tipo | Descricao |
|---|---|---|
type | string | Tipo da entidade (ex: case, user, battle) |
id | string | ID 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
export class GetAuditLogsQueryDto {
@IsOptional()
@IsString()
action?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@Type(() => Number)
limit?: number;
@IsOptional()
@Type(() => Number)
offset?: number;
}AuditLogResponseDto
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
export class VerifyChainResponseDto {
valid: boolean;
logsVerified?: number;
corruptedLogId?: string;
message: string;
}Modulos NestJS
Modulo de Infraestrutura
// src/infrastructure/audit/audit.module.ts
@Module({
providers: [AuditService, PrismaClient, SnowflakeService],
exports: [AuditService],
})
export class AuditModule {}Modulo de Apresentacao
// src/presentation/modules/audit.module.ts
@Module({
imports: [InfraAuditModule, AuthModule],
controllers: [AuditController],
providers: [
VerifyChainUseCase,
GetUserAuditLogsUseCase,
GetEntityAuditLogsUseCase,
],
})
export class AuditPresentationModule {}Use Cases
VerifyChainUseCase
@Injectable()
export class VerifyChainUseCase {
constructor(private readonly auditService: AuditService) {}
async execute(startId?: bigint, endId?: bigint) {
return this.auditService.verifyChainIntegrity(startId, endId);
}
}GetUserAuditLogsUseCase
@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
@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
await this.auditService.logUserAction(
userId,
'WITHDRAW',
'withdrawal',
withdrawalId.toString(),
{ amountCents: 50000, method: 'PIX' },
'192.168.1.1',
'Mozilla/5.0...',
);Registrar acao de sistema
await this.auditService.logSystemAction(
'AUTO_BAN',
'user',
suspiciousUserId.toString(),
{ reason: 'Suspicious betting pattern', riskScore: 95 },
);Verificar cadeia em script
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
SELECT id, action, entity_type, entity_id, previous_hash, current_hash
FROM audit_logs
ORDER BY id DESC
LIMIT 5;Verificar cadeia manualmente
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 quebradaProblemas Comuns
| Problema | Causa | Solucao |
|---|---|---|
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 duplicados | Interceptor + chamada manual | Usar apenas um metodo (decorator OU manual) |
entityId = 'unknown' | Response sem id e sem params | Garantir que response ou params tenham id |
| Performance lenta na verificacao | Muitos logs | Usar startId/endId para verificar ranges |
Regras Importantes
- Logs sao IMUTAVEIS - nunca altere ou delete registros de auditoria
- A cadeia deve ser verificada periodicamente - cron job ou verificacao manual
- Use
@Audit()para acoes automaticas elogSystemAction()para acoes programaticas - Nunca use os dois ao mesmo tempo no mesmo endpoint (duplicaria logs)
- O hash inclui timestamp - logs nao podem ser reproduzidos retroativamente
- userId pode ser null - para acoes de sistema (webhooks, crons, etc.)
- metadata e flexivel - pode conter qualquer JSON, mas evite dados sensiveis (senhas, tokens)
- Indices otimizam queries - use os filtros disponiveis ao inves de busca full-table
