Skip to content

Módulo Auditoria

Localização: backend/src/modules/audit/ e backend/src/common/interceptors/audit.interceptor.ts

Registra todas as ações sensíveis realizadas pelos usuários no sistema. A arquitetura é composta por dois componentes desacoplados: o AuditInterceptor (captura automática) e o AuditService (consulta e análise).


Arquitetura geral

Camada de captura:
  common/interceptors/audit.interceptor.ts
    └── AuditInterceptor (NestInterceptor global)
          └── AUDIT_RULES (allowlist de endpoints auditáveis)
          └── maskIp() (anonimização de IPs)
          └── → grava em AccessLog via PrismaService

Camada de consulta:
  modules/audit/
    ├── audit.controller.ts  — HTTP layer (GET /audit/logs, GET /audit/stats)
    └── audit.service.ts     — AuditService (leitura, filtros, agregações)

Princípio central: Apenas endpoints explicitamente declarados na allowlist AUDIT_RULES são auditados. Qualquer endpoint não mapeado é silenciosamente ignorado — isso evita poluição dos logs com leituras rotineiras de dados.


Classe: AuditInterceptor

Arquivo: backend/src/common/interceptors/audit.interceptor.ts

Interface NestJS: NestInterceptor

Interceptor global aplicado a todos os requests HTTP do backend. Atua na camada de resposta: após o handler executar (com sucesso ou com erro), verifica se a rota corresponde a alguma regra da allowlist e, caso positivo, grava um registro em AccessLog.

Dependências injetadas

  • PrismaService — escrita assíncrona em AccessLog

Constante AUDIT_RULES

Array de regras que define quais endpoints são auditáveis. Cada regra tem:

typescript
{
  method: string;           // Método HTTP (GET, POST, PATCH, DELETE, PUT)
  pattern: RegExp;          // Regex aplicada ao path sem query string
  actionType: string;       // Código da ação gravada em AccessLog.actionType
  metadataFn?: (url: string, body?: any) => Record<string, any>;
                            // Extrator de metadados — opcional
}

A tabela completa de regras está documentada na seção "Ações auditadas" abaixo.


Método intercept(context, next): Observable<any>

Ponto de entrada do interceptor. Executa para todo request HTTP.

Fluxo:

  1. Extrai user, url e request do contexto de execução
  2. Se a URL contém /health: passa adiante sem auditar (evita poluição por health checks)
  3. Encadeia o handler com dois operadores RxJS:
    • tap() — executado após sucesso: chama logAccess() com o status code real da resposta
    • catchError() — executado após erro:
      • Se status 400: loga a lista de campos do body e a resposta de validação
      • Chama logAccess() com o status de erro e a mensagem de erro
      • Repropaga o erro sem modificar o fluxo original

Importante: O interceptor audita tanto requests bem-sucedidos quanto falhados (excluindo endpoints de health check). Logs de erro incluem um campo error nos metadados.


Método privado logAccess(user, request, status, errorMessage?)

Núcleo da lógica de auditoria. Determina se o request deve ser auditado e grava o registro.

Parâmetros:

  • user — objeto do usuário autenticado (do request.user)
  • request — objeto HTTP completo do NestJS
  • status — HTTP status code da resposta
  • errorMessage — mensagem de erro opcional (apenas em caso de falha)

Fluxo:

  1. Extrai path do request.originalUrl removendo query string (split('?')[0])
  2. Extrai method em uppercase
  3. Busca em AUDIT_RULES a primeira regra onde r.method === method && r.pattern.test(path)
  4. Se nenhuma regra encontrada: retorna silenciosamente (sem log)
  5. Se a regra tem metadataFn: executa rule.metadataFn(path, request.body) para extrair metadados contextuais
  6. Monta o objeto metadata combinando dados da regra e a mensagem de erro (se houver)
  7. Chama prisma.accessLog.create() de forma fire-and-forget (.catch() apenas loga erros de escrita sem propagar)

Campos gravados em AccessLog:

CampoFonte
userIduser.id
organizationIduser.organizationId
storeIduser.storeId (pode ser null)
endpointrequest.originalUrl (com query string)
methodMétodo HTTP em uppercase
ipx-forwarded-for header (primeiro IP) ou request.ip, após maskIp()
userAgentHeader user-agent
statusHTTP status code
actionTyperule.actionType
metadataResultado de metadataFn + campo error se falha

Função maskIp(ip): string | null

Anonimiza endereços IP antes de gravar no banco. Implementada como função pura no topo do arquivo.

Comportamento por formato:

FormatoExemplo de entradaExemplo de saída
IPv4 completo192.168.1.100192.168.1.*
IPv6 completo2001:db8:85a3:0:0:8a2e:370:73342001:db8:85a3:0:****
Outros formatos::1::1**** (primeiros 8 chars + ****)
Nulo/vazionullnull

Regex IPv4: /^(\d{1,3}\.\d{1,3}\.\d{1,3})\.\d{1,3}$/ — captura os 3 primeiros octetos e substitui o quarto por *.

IPv6: Split por :, mantém os 4 primeiros grupos, concatena ':****'.


Classe: AuditService

Arquivo: backend/src/modules/audit/audit.service.ts

Responsável pelas consultas e agregações de dados de auditoria. Todos os métodos são filtrados por organizationId — isolamento total de tenant.

Dependências injetadas

  • PrismaService — leitura de AccessLog e User

Interface AuditLogsFilter

typescript
interface AuditLogsFilter {
  userId?: string;      // Filtro por usuário específico
  actionType?: string;  // Filtro por tipo de ação exato
  startDate?: string;   // ISO 8601 date string (ex: "2025-01-01")
  endDate?: string;     // ISO 8601 date string (ex: "2025-12-31")
  search?: string;      // Busca parcial e case-insensitive no campo endpoint
  page?: number;        // Página atual (padrão: 1)
  limit?: number;       // Registros por página (padrão: 50, máximo: 200)
}

Método findAll(organizationId, filter): Promise<PaginatedLogs>

Retorna logs de auditoria paginados com filtros opcionais.

Parâmetros:

  • organizationId — UUID da organização (obrigatório, garante isolamento)
  • filter — objeto AuditLogsFilter

Comportamento dos filtros:

FiltroOperador PrismaObservação
userIdwhere.userId = userIdIgualdade exata
actionTypewhere.actionType = actionTypeIgualdade exata
searchwhere.endpoint = { contains, mode: 'insensitive' }Busca parcial case-insensitive
startDatewhere.createdAt.gte = new Date(startDate + 'T00:00:00.000Z')Início do dia em UTC
endDatewhere.createdAt.lte = new Date(endDate + 'T23:59:59.999Z')Final do dia em UTC

Limite de segurança: safeLimit = Math.min(Number(limit) || 50, 200) — máximo absoluto de 200 registros por página, independente do que o cliente solicite.

Paginação:

  • offset = (page - 1) * safeLimit
  • Executa count e findMany em paralelo via Promise.all()
  • include: { user: { select: { id, name, email } } } — dados básicos do usuário vêm junto

Resposta:

typescript
{
  data: AccessLog[],       // Array de logs com dados do usuário aninhados
  pagination: {
    total: number,         // Total de registros que satisfazem os filtros
    page: number,          // Página atual
    totalPages: number,    // Math.ceil(total / safeLimit)
  }
}

Ordenação: createdAt DESC — mais recentes primeiro.

Tabelas afetadas: AccessLog (leitura), User (leitura via include)


Método getStats(organizationId, startDate?, endDate?): Promise<AuditStats>

Retorna estatísticas agregadas de auditoria para o período especificado. Executa 3 queries em paralelo via Promise.all().

Parâmetros:

  • organizationId — UUID da organização
  • startDate — início do período (opcional)
  • endDate — fim do período (opcional)

Queries executadas em paralelo:

1. totalLogs

  • AccessLog.count({ where }) — total de eventos no período

2. actionTypeCounts

  • AccessLog.groupBy({ by: ['actionType'], _count }) — contagem por tipo de ação
  • Ordenado por _count DESC (ações mais frequentes primeiro)

3. uniqueUsersGroups

  • AccessLog.groupBy({ by: ['userId'], _count, take: 5 }) — top 5 usuários mais ativos
  • Ordenado por _count DESC

Após as queries paralelas:

  • Busca detalhes dos top 5 usuários: User.findMany({ where: { id: { in: topUserIds } } })
  • Calcula total de usuários únicos: AccessLog.groupBy({ by: ['userId'], where }) e conta o array resultante

Resposta:

typescript
{
  totalLogs: number,                    // Total de eventos no período
  uniqueUsers: number,                  // Total de usuários únicos com eventos
  actionTypeDistribution: Array<{       // Distribuição por tipo de ação
    actionType: string,
    count: number,
  }>,
  topUsers: Array<{                     // Top 5 usuários mais ativos
    userId: string,
    userName: string | null,
    userEmail: string,
    count: number,
  }>,
}

Tabelas afetadas: AccessLog (3 queries de leitura/agregação), User (leitura de detalhes dos top 5)


Classe: AuditController

Arquivo: backend/src/modules/audit/audit.controller.ts

Permissão: app:audit (aplicada a nível de controller — todos os endpoints exigem esta permissão)

Base path: /audit

GET /audit/logs

Lista logs de auditoria com filtros via query string.

Query params:

ParamTipoPadrãoDescrição
pagestring (number)1Página de resultados
limitstring (number)50Registros por página (máx. 200)
userIdstringFiltra por UUID do usuário
actionTypestringFiltra por tipo de ação
startDatestringData inicial (YYYY-MM-DD)
endDatestringData final (YYYY-MM-DD)
searchstringBusca no campo endpoint

Isolamento: organizationId é extraído de req.user — nunca aceito como parâmetro do cliente.

GET /audit/stats

Retorna KPIs agregados para o período.

Query params:

ParamTipoDescrição
startDatestringData inicial (YYYY-MM-DD)
endDatestringData final (YYYY-MM-DD)

Ações auditadas

Usuários e equipe

actionTypeMétodoPath regexMetadados capturados
USER_INVITEPOST/users/invite${ email, roleId } — do body
USER_ROLE_CHANGEPATCH/users/[^/]+/role${ targetUserId } — do path
USER_DELETEDELETE/users/[^/]+${ targetUserId } — do path
USER_RESET_PASSWORDPOST/users/[^/]+/reset-password${ targetUserId } — do path

Papéis (cargos)

actionTypeMétodoPath regexMetadados capturados
ROLE_CREATEPOST/roles$— (sem metadados)
ROLE_UPDATEPATCH/roles/[^/]+${ roleId } — do path
ROLE_DELETEDELETE/roles/[^/]+${ roleId } — do path

Clientes

actionTypeMétodoPath regexMetadados capturados
CUSTOMER_VIEWGET/webhook/erp/customers/[^/]+${ customerId } — do path

Campanhas

actionTypeMétodoPath regexMetadados capturados
CAMPAIGN_CREATEPOST/campaigns$— (sem metadados)
CAMPAIGN_SENDPOST/campaigns/[^/]+/send${ campaignId } — do path
CAMPAIGN_UPDATEPATCH/campaigns/[^/]+${ campaignId } — do path
CAMPAIGN_DELETEDELETE/campaigns/[^/]+${ campaignId } — do path

Segmentos

actionTypeMétodoPath regexMetadados capturados
SEGMENT_CREATEPOST/intelligence/segments$— (sem metadados)
SEGMENT_EDITPUT/intelligence/segments/[^/]+${ segmentId } — do path
SEGMENT_STATUS_CHANGEPATCH/intelligence/segments/[^/]+/status${ segmentId } — do path
SEGMENT_DELETEDELETE/intelligence/segments/[^/]+${ segmentId } — do path
SEGMENT_EXPORTGET/intelligence/segments/[^/]+/export${ segmentId } — do path

Configurações

actionTypeMétodoPath regexMetadados capturados
SETTINGS_STORE_UPDATEPOST/settings/store$— (sem metadados)
SETTINGS_EMAIL_UPDATEPOST/settings/email$— (sem metadados)
SETTINGS_WHATSAPP_ADDPOST/settings/whatsapp$— (sem metadados)
SETTINGS_WHATSAPP_DELETEDELETE/settings/whatsapp/[^/]+${ instanceId } — do path

Sync manual (ERP)

actionTypeMétodoPath regexMetadados capturados
SYNC_TRIGGEREDPOST/erp/sync/sales$— (sem metadados)

Schema do AccessLog

prisma
model AccessLog {
  id             String   @id @default(uuid())
  userId         String
  organizationId String
  storeId        String?
  endpoint       String
  method         String
  ip             String
  userAgent      String?
  status         Int
  actionType     String
  metadata       Json?
  createdAt      DateTime @default(now())
}

Observações sobre os campos:

CampoDescrição
idUUID gerado pelo banco
userIdID do usuário que realizou a ação (referência a User.id)
organizationIdTenant do usuário — usado para isolamento em consultas
storeIdLoja associada à sessão do usuário (pode ser nulo)
endpointURL completa com query string (request.originalUrl)
methodMétodo HTTP em uppercase
ipIP mascarado (maskIp())
userAgentString do browser/cliente
statusHTTP status code (200, 201, 400, 403, 404, 500...)
actionTypeCódigo da ação (ex: USER_INVITE, CAMPAIGN_SEND)
metadataJSON livre com IDs contextuais — nunca contém PII
createdAtTimestamp de criação (índice implícito para range queries)

Políticas de privacidade nos logs

Mascaramento de IP

O IP é mascarado antes de ser gravado no banco, garantindo que nenhum IP completo seja armazenado:

  • IPv4: 192.168.1.100192.168.1.* (último octeto removido)
  • IPv6: 2001:db8:85a3:0000:0000:8a2e:0370:73342001:db8:85a3:0000:****

O IP original nunca é persistido. A extração considera o header x-forwarded-for (para requests via proxy/load balancer), usando apenas o primeiro IP da lista (IP real do cliente).

Ausência de PII nos metadados

Os campos metadata contêm apenas identificadores (UUIDs, IDs numéricos). Exemplos:

json
// USER_INVITE — nunca salva o nome ou CPF do convidado
{ "email": "usuario@empresa.com", "roleId": "uuid-do-cargo" }

// USER_ROLE_CHANGE — apenas IDs
{ "targetUserId": "uuid-do-usuario" }

// CAMPAIGN_SEND — apenas o ID da campanha
{ "campaignId": "uuid-da-campanha" }

O campo email em USER_INVITE é a única exceção — gravado intencionalmente pois é necessário para auditoria de quem foi convidado. Todos os outros campos são apenas UUIDs.

Logs de erros de validação

Quando um request retorna HTTP 400, o interceptor loga adicionalmente no console (via Logger):

  • Método e URL
  • Lista de campos presentes no body (não os valores)
  • Resposta de erro do NestJS

Isso facilita depuração de problemas de validação sem expor dados do usuário nos logs de infraestrutura.


Comportamento em casos especiais

Request de health check

URLs contendo /health são completamente ignoradas pelo interceptor — nenhuma checagem de regra é feita e nenhum log é gravado. Isso previne poluição dos logs por monitoramento frequente.

Falha na gravação do log

A escrita em AccessLog é feita com .catch() que apenas loga o erro no console do servidor. A falha de auditoria não propaga erro para o cliente — o request já foi completado com sucesso. Isso garante que uma indisponibilidade do banco de dados (ou uma constraint violation) não quebre operações legítimas do usuário.

Requests não autenticados

O interceptor verifica user?.id antes de chamar logAccess(). Requests sem usuário autenticado (que chegam antes do ClerkAuthGuard) não são auditados.

Requests com erros de domínio

Erros de negócio (403, 404, 409, etc.) também são auditados quando correspondem a regras na allowlist. O campo metadata.error contém a mensagem do erro (error?.response?.message || error?.message).


Fluxo completo: da ação do usuário ao log

1. Usuário faz PATCH /users/abc123/role

2. ClerkAuthGuard         │ Autentica JWT, popula request.user

3. TenantInterceptor      │ Estabelece contexto de organização

4. PermissionsGuard       │ Valida permissão 'app:team'

5. UsersController        │ Chama UsersService.updateRole()

6. UsersService           │ Valida e atualiza User no banco

7. AuditInterceptor.tap() │ Após sucesso:
                          │   - path = '/users/abc123/role'
                          │   - regra encontrada: USER_ROLE_CHANGE
                          │   - metadataFn extrai: { targetUserId: 'abc123' }
                          │   - ip = maskIp(x-forwarded-for)
                          │   - AccessLog.create() fire-and-forget

8. Resposta enviada ao cliente (sem esperar o log gravar)

Integração com outros módulos

Módulo Config (Users)

O UsersService.adminResetPassword() grava diretamente em AccessLog (sem passar pelo interceptor) para garantir o registro mesmo que o padrão de URL não corresponda à regra do interceptor:

typescript
await this.prisma.accessLog.create({
  data: {
    userId: currentUser.id,
    organizationId: currentUser.organizationId,
    storeId: currentUser.storeId,
    endpoint: `/users/${targetUserId}/reset-password`,
    method: 'POST',
    actionType: 'ADMIN_PASSWORD_RESET',
    // ip e userAgent não disponíveis neste contexto
  },
});

Neste caso, ip e userAgent ficam como null — a gravação direta não tem acesso ao objeto request.

Remoção de usuário

Quando um usuário é removido via UsersService.remove(), todos os seus AccessLog são deletados primeiro (antes do User.delete()) para evitar erro de foreign key:

typescript
await this.prisma.accessLog.deleteMany({ where: { userId: id } });

Isso significa que logs de auditoria de um usuário deletado são permanentemente removidos.


Guia de consultas comuns

Ver todas as ações de um usuário nos últimos 30 dias

GET /audit/logs?userId={uuid}&startDate=2026-04-24&endDate=2026-05-24

Ver apenas convites de usuários

GET /audit/logs?actionType=USER_INVITE

Buscar por endpoint específico

GET /audit/logs?search=campaigns

Estatísticas do mês atual

GET /audit/stats?startDate=2026-05-01&endDate=2026-05-31

Resposta típica do GET /audit/stats

json
{
  "totalLogs": 1240,
  "uniqueUsers": 8,
  "actionTypeDistribution": [
    { "actionType": "CAMPAIGN_UPDATE", "count": 320 },
    { "actionType": "CUSTOMER_VIEW",   "count": 290 },
    { "actionType": "SEGMENT_EDIT",    "count": 180 },
    { "actionType": "USER_INVITE",     "count": 42 }
  ],
  "topUsers": [
    { "userId": "uuid-1", "userName": "Ana Gerente", "userEmail": "ana@loja.com", "count": 430 },
    { "userId": "uuid-2", "userName": "Bruno Admin",  "userEmail": "bruno@loja.com", "count": 310 }
  ]
}

Documentação interna — Galdix CRM