Pular para o conteúdo principal

Sistema de Auditoria — Rastreamento de Eventos no SuperApp

O sistema de auditoria do SuperApp permite rastrear ações de usuários sobre recursos do sistema de forma modular, segura e extensível. Qualquer produto ou feature pode registrar eventos de auditoria sem alterar o schema do banco — basta definir os valores de resource_type e action no TypeScript.

Visão geral

O sistema foi projetado para responder perguntas como:

  • Quem visualizou o relatório X no dia Y?
  • Quais usuários exportaram dados da conta Z no último mês?
  • Quando e por quem o recurso ABC foi deletado?

Eventos de auditoria são imutáveis por design: usuários autenticados podem apenas inserir registros (e somente com o próprio user_id). A leitura é feita exclusivamente via uma função RPC com SECURITY DEFINER, acessível apenas pelo service_role (admin client). Isso garante que nenhum usuário consiga ler, alterar ou deletar o histórico de outro.

Quando usar

  • Ações sensíveis sobre dados de negócio (visualização de relatórios, exportação de dados, aprovações)
  • Rastreamento de acesso a recursos por usuário
  • Compliance e trilha de auditoria por conta (team account)

Design modular

A tabela audit_events não tem enum no banco para resource_type ou action. Esses valores são texto livre, e os tipos válidos são controlados pela camada de aplicação via TypeScript (AuditAction, resourceType como string). Isso significa que adicionar um novo tipo de recurso ou ação não exige nenhuma migration.


Arquitetura

Tabela audit_events

create table if not exists public.audit_events (
id uuid unique not null default extensions.uuid_generate_v4(),
account_id uuid references public.accounts(id) on delete cascade not null,
user_id uuid references auth.users(id) on delete cascade not null,
action text not null,
resource_type text not null,
resource_id text not null,
resource_name text,
metadata jsonb default '{}'::jsonb not null,
created_at timestamp with time zone default now() not null,
primary key (id)
);
CampoTipoDescrição
iduuidIdentificador único do evento
account_iduuidConta (team ou personal) à qual o evento pertence
user_iduuidUsuário que executou a ação
actiontextTipo de ação: view, create, update, delete, download, etc.
resource_typetextTipo de recurso afetado: ex. report, lead, vehicle
resource_idtextID do recurso afetado
resource_nametextNome legível do recurso (opcional, para exibição)
metadatajsonbContexto adicional livre (filtros usados, IP, etc.)
created_attimestamptzTimestamp do evento (UTC)

Índices

A tabela tem índices otimizados para as queries mais comuns:

  • Por account_id (listagem por conta)
  • Por user_id (listagem por usuário)
  • Por created_at desc (ordenação cronológica)
  • Por resource_type e action (filtros)
  • Composto (account_id, created_at desc) — o mais usado em produção

RLS — Controle de acesso

-- Usuários autenticados só podem inserir o próprio user_id
create policy "audit_events_insert" on public.audit_events
for insert to authenticated
with check (user_id = (select auth.uid()));

-- Leitura, update e delete: apenas service_role
revoke all on public.audit_events from authenticated, service_role;
grant insert on table public.audit_events to authenticated;
grant select, insert, update, delete on table public.audit_events to service_role;

Isso significa que:

  • Um usuário autenticado pode inserir eventos, mas nunca para outro user_id
  • Nenhum usuário pode ler, alterar ou deletar eventos via cliente padrão
  • Toda leitura passa obrigatoriamente pelo service_role (admin client)

RPC get_audit_events

A leitura de eventos é feita via uma função RPC com SECURITY DEFINER, que executa com privilégios de service_role independentemente de quem a chama — mas como o grant execute é concedido apenas ao service_role, na prática só o admin client consegue invocá-la.

create or replace function public.get_audit_events(
p_account_id uuid,
p_resource_type text default null,
p_action text default null,
p_start date default null,
p_end date default null,
p_limit int default 50,
p_offset int default 0
)
returns table(
id uuid,
user_email text,
action text,
resource_type text,
resource_id text,
resource_name text,
metadata jsonb,
created_at text
)
language sql
security definer
set search_path = ''

A função faz um LEFT JOIN com auth.users para resolver o user_email a partir do user_id, sem expor a tabela auth.users diretamente ao cliente.


Tipos TypeScript

Os tipos centrais do sistema estão em apps/web/lib/server/audit/audit.types.ts.

AuditAction

export type AuditAction =
| 'view'
| 'create'
| 'update'
| 'delete'
| 'download'
| 'upload'
| 'share'
| 'export'
| 'approve'
| 'reject';

O tipo aceita tanto os valores predefinidos quanto qualquer string, permitindo ações customizadas sem alterar o tipo base.

TrackAuditEventParams

export interface TrackAuditEventParams {
accountId: string;
action: AuditAction | string;
resourceType: string;
resourceId: string;
resourceName?: string;
metadata?: Record<string, unknown>;
}

AuditFilters

export interface AuditFilters {
accountId: string;
resourceType?: string;
action?: string;
start?: string; // formato: 'YYYY-MM-DD'
end?: string; // formato: 'YYYY-MM-DD'
limit?: number; // padrão: 50
offset?: number; // padrão: 0
}

Como registrar um evento

O ponto de entrada recomendado para registrar eventos a partir de componentes ou Server Actions é o trackAuditEventAction, definido em apps/web/lib/server/audit/audit.actions.ts.

'use server';

import { trackAuditEventAction } from '~/lib/server/audit/audit.actions';

Exemplos por tipo de ação

Visualização de relatório

await trackAuditEventAction({
accountId: team.id,
action: 'view',
resourceType: 'report',
resourceId: reportId,
resourceName: 'Relatório de Acessos — Junho 2026',
metadata: {
filters: { start: '2026-06-01', end: '2026-06-30' },
},
});

Criação de recurso

await trackAuditEventAction({
accountId: team.id,
action: 'create',
resourceType: 'lead',
resourceId: newLead.id,
resourceName: newLead.name,
});

Atualização de recurso

await trackAuditEventAction({
accountId: team.id,
action: 'update',
resourceType: 'vehicle',
resourceId: vehicle.plate,
resourceName: vehicle.plate,
metadata: {
changedFields: ['owner', 'status'],
},
});

Exclusão de recurso

await trackAuditEventAction({
accountId: team.id,
action: 'delete',
resourceType: 'document',
resourceId: document.id,
resourceName: document.filename,
});

Download / exportação

await trackAuditEventAction({
accountId: team.id,
action: 'download',
resourceType: 'report',
resourceId: reportId,
resourceName: 'Ranking por Marca — Mai/2026',
metadata: {
format: 'xlsx',
rowCount: 1420,
},
});

O que o trackAuditEventAction faz internamente

  1. Obtém o usuário autenticado via getSupabaseServerClient().auth.getUser()
  2. Verifica se o usuário é membro da accountId informada consultando accounts_memberships
  3. Se ambas as checagens passarem, chama AuditService.trackEvent() com o userId resolvido
  4. Qualquer erro é capturado silenciosamente (padrão fire-and-forget)

Como ler eventos

A leitura é feita via createAuditService().getEvents(), que usa o admin client internamente. Use isso em Server Components, Server Actions ou Route Handlers onde o contexto de servidor está disponível.

import { createAuditService } from '~/lib/server/audit/audit.service';

const svc = createAuditService();

const events = await svc.getEvents({
accountId: team.id,
resourceType: 'report',
action: 'view',
start: '2026-06-01',
end: '2026-06-30',
limit: 100,
offset: 0,
});

O retorno é um array de AuditEvent[]:

export interface AuditEvent {
id: string;
userEmail: string; // email resolvido via JOIN com auth.users
action: AuditAction | string;
resourceType: string;
resourceId: string;
resourceName: string | null;
metadata: Record<string, unknown>;
createdAt: string; // string ISO 8601
}

Filtros disponíveis

FiltroTipoPadrãoDescrição
accountIdstringObrigatório. Filtra por conta.
resourceTypestringnullFiltra por tipo de recurso (ex. report)
actionstringnullFiltra por ação (ex. view)
startstringnullData inicial no formato YYYY-MM-DD
endstringnullData final no formato YYYY-MM-DD
limitnumber50Número máximo de registros retornados
offsetnumber0Offset para paginação

Segurança

Por que authenticated só pode INSERT?

O objetivo da auditoria é produzir um registro imutável e confiável. Se usuários pudessem ler, alterar ou deletar eventos, o histórico perderia valor como evidência. A política de RLS garante que:

  • Um usuário só insere eventos com o próprio user_id (enforçado pelo with check (user_id = auth.uid()))
  • Nenhum usuário autenticado consegue ler os eventos — nem os seus próprios — pelo cliente padrão
  • Toda leitura passa pelo service_role, que é chamado apenas em contexto de servidor

Por que a leitura usa o admin client?

A função RPC get_audit_events tem grant execute apenas para service_role. O cliente padrão (authenticated) não consegue executá-la. Por isso, o AuditService.getEvents() usa getSupabaseServerAdminClient() — que nunca é exposto ao browser.

// audit.service.ts — leitura usa admin client
const admin = getSupabaseServerAdminClient() as any;
const { data, error } = await admin.rpc('get_audit_events', { ... });

Validação de membership antes de inserir

O trackAuditEventAction verifica se o usuário é membro da conta antes de registrar o evento:

const { data: membership } = await client
.from('accounts_memberships')
.select('account_id')
.eq('account_id', params.accountId)
.eq('user_id', user.id)
.single();

if (!membership) return;

Isso impede que um usuário autenticado registre eventos em contas das quais não faz parte — mesmo que ele tente forjar uma chamada diretamente para a action.


Padrão fire-and-forget

O trackAuditEventAction captura todos os erros silenciosamente:

try {
// ... lógica de auditoria
} catch {
// fire-and-forget: erros são silenciosos para não bloquear a ação principal
}

Quando usar esse padrão

Use o padrão fire-and-forget quando a auditoria é secundária à ação principal. Por exemplo:

  • O usuário clica em "Exportar relatório" — a exportação deve acontecer mesmo que o registro de auditoria falhe
  • O usuário visualiza uma página — a visualização não deve ser bloqueada por um timeout no banco

Quando NÃO usar

Se o registro de auditoria for parte do requisito de compliance (ex: o evento de auditoria precisa ser garantido antes de liberar o acesso ao recurso), não use fire-and-forget. Nesse caso, trate o erro explicitamente e decida se a ação principal deve ser bloqueada.


Extensibilidade — adicionando novos tipos

Como resource_type e action são texto livre no banco, adicionar suporte a um novo recurso é simples:

1. Definir o novo resourceType como constante

// apps/web/lib/server/audit/audit-resource-types.ts
export const AUDIT_RESOURCE_TYPES = {
REPORT: 'report',
LEAD: 'lead',
VEHICLE: 'vehicle',
DOCUMENT: 'document',
// Adicione aqui:
INSURANCE_POLICY: 'insurance_policy',
} as const;

export type AuditResourceType = typeof AUDIT_RESOURCE_TYPES[keyof typeof AUDIT_RESOURCE_TYPES];

2. Usar na chamada

await trackAuditEventAction({
accountId: team.id,
action: 'create',
resourceType: AUDIT_RESOURCE_TYPES.INSURANCE_POLICY,
resourceId: policy.id,
resourceName: policy.number,
});

3. Nenhuma migration necessária

O banco aceita qualquer string. A consistência é garantida pelo TypeScript — nenhuma alteração de schema é necessária.


Visualização em desenvolvimento

Existe uma rota exclusiva para desenvolvimento em apps/web/app/[locale]/(dev)/auditoria/page.tsx. Ela retorna 404 em produção e não exige autenticação — usa o admin client diretamente para exibir todos os eventos do sistema.

http://localhost:3000/pt-BR/auditoria

A página exibe:

  • Seletor de conta (filtra por account_id)
  • Campo de tipo de recurso (filtra por resource_type)
  • Seletor de ação (filtra por action)
  • Tabela com os últimos 100 eventos: data, ação, tipo de recurso, recurso, time, usuário e metadados

Parâmetros de URL disponíveis:

ParamExemploDescrição
accountId?accountId=uuidFiltra por conta
resourceType?resourceType=reportFiltra por tipo de recurso
action?action=viewFiltra por ação

Limitações e próximos passos

Ainda não implementado

ItemStatusObservação
UI de produção (fora do protótipo)PendenteA rota /auditoria existe mas ainda não tem design final aprovado
Permissão granular por papel de membroPendenteHoje qualquer membro da conta pode acionar trackAuditEventAction; a leitura é restrita ao servidor
Retenção e arquivamento de eventos antigosPendenteEventos crescem indefinidamente; avaliar política de retenção (ex: 90 dias)
Tipagem automática da RPC via typegenPendenteO cast as any no AuditService.getEvents() será removido quando o Supabase CLI suportar tipagem de RPCs customizadas
Testes PgTAP para a RLS e a RPCPendenteAdicionar testes automatizados na suite supabase/tests/
Paginação no frontendPendenteO serviço suporta limit/offset, mas a UI ainda não expõe controles de paginação

Débito técnico conhecido

O getSupabaseServerAdminClient() é convertido para any no AuditService.getEvents() porque o typegen do Supabase não gera tipos para RPCs customizadas automaticamente. Quando isso for resolvido (via tipagem manual ou atualização do CLI), o cast deve ser removido.