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)
);
| Campo | Tipo | Descrição |
|---|---|---|
id | uuid | Identificador único do evento |
account_id | uuid | Conta (team ou personal) à qual o evento pertence |
user_id | uuid | Usuário que executou a ação |
action | text | Tipo de ação: view, create, update, delete, download, etc. |
resource_type | text | Tipo de recurso afetado: ex. report, lead, vehicle |
resource_id | text | ID do recurso afetado |
resource_name | text | Nome legível do recurso (opcional, para exibição) |
metadata | jsonb | Contexto adicional livre (filtros usados, IP, etc.) |
created_at | timestamptz | Timestamp 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_typeeaction(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
- Obtém o usuário autenticado via
getSupabaseServerClient().auth.getUser() - Verifica se o usuário é membro da
accountIdinformada consultandoaccounts_memberships - Se ambas as checagens passarem, chama
AuditService.trackEvent()com ouserIdresolvido - 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
| Filtro | Tipo | Padrão | Descrição |
|---|---|---|---|
accountId | string | — | Obrigatório. Filtra por conta. |
resourceType | string | null | Filtra por tipo de recurso (ex. report) |
action | string | null | Filtra por ação (ex. view) |
start | string | null | Data inicial no formato YYYY-MM-DD |
end | string | null | Data final no formato YYYY-MM-DD |
limit | number | 50 | Número máximo de registros retornados |
offset | number | 0 | Offset 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 pelowith 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:
| Param | Exemplo | Descrição |
|---|---|---|
accountId | ?accountId=uuid | Filtra por conta |
resourceType | ?resourceType=report | Filtra por tipo de recurso |
action | ?action=view | Filtra por ação |
Limitações e próximos passos
Ainda não implementado
| Item | Status | Observação |
|---|---|---|
| UI de produção (fora do protótipo) | Pendente | A rota /auditoria existe mas ainda não tem design final aprovado |
| Permissão granular por papel de membro | Pendente | Hoje qualquer membro da conta pode acionar trackAuditEventAction; a leitura é restrita ao servidor |
| Retenção e arquivamento de eventos antigos | Pendente | Eventos crescem indefinidamente; avaliar política de retenção (ex: 90 dias) |
Tipagem automática da RPC via typegen | Pendente | O cast as any no AuditService.getEvents() será removido quando o Supabase CLI suportar tipagem de RPCs customizadas |
| Testes PgTAP para a RLS e a RPC | Pendente | Adicionar testes automatizados na suite supabase/tests/ |
| Paginação no frontend | Pendente | O 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.