Files
b2bcall/apps/api/src/audit/audit.service.ts
B2BCall Bootstrap a2898fa566 feat: add authentication and RBAC
- packages/database: schema Prisma (users/sessions/roles/permissions/
  user_roles/role_permissions/audit_logs/password_reset_tokens), migration
  inicial e seed (permissoes+perfis+bootstrap super_admin com senha
  aleatoria em FIRST_LOGIN.txt). Decisao de ORM (Prisma) documentada em
  docs/ARCHITECTURE.md
- packages/shared: catalogo de permissoes (fonte unica usada por seed e API)
- apps/api: NestJS 11 + Fastify
  - autenticacao: Argon2id, access JWT + refresh token opaco com rotacao,
    cookies HttpOnly/SameSite=Lax, change/forgot/reset password
  - rate limiting progressivo de login via Redis (bloqueio crescente por IP)
  - RBAC reforcado no backend (PermissionsGuard), protecao contra
    auto-elevacao de privilegio
  - auditoria (audit_logs) nas acoes sensiveis, com redacao de segredos
  - health checks reais (postgres+redis), swagger desabilitavel, logs
    estruturados JSON com request_id de correlacao, filtro global de
    excecoes sem vazar erro cru
- infrastructure/docker/api.Dockerfile: build multi-stage do monorepo pnpm
- docker-compose.yml: servico api na rede interna, sem porta publicada

Testado via containers reais: login, /me, refresh, change-password,
rate limit (7 tentativas -> 429), RBAC (nega/permite), bloqueio de
auto-elevacao (403), audit log populado, health checks, lint e testes
unitarios passando.
2026-08-27 12:23:02 -03:00

98 lines
2.8 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma } from '@b2bcall/database';
import type { QueryAuditDto } from './dto/query-audit.dto';
export interface AuditEntry {
userId?: string | null;
action: string;
entityType?: string;
entityId?: string;
before?: Prisma.InputJsonValue | null;
after?: Prisma.InputJsonValue | null;
ipAddress?: string;
userAgent?: string;
}
// Campos que nunca devem ser persistidos em texto puro no before/after do
// audit log (agente.md seção 12: "nunca salvar segredos abertos").
const SENSITIVE_KEYS = new Set([
'password',
'passwordHash',
'secret',
'token',
'refreshToken',
'accessToken',
'amiSecret',
'ariSecret',
]);
function redact(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) return value.map(redact);
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEYS.has(key) ? '[REDACTED]' : redact(val);
}
return out;
}
return value;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
async log(entry: AuditEntry): Promise<void> {
await this.prisma.auditLog.create({
data: {
userId: entry.userId ?? null,
action: entry.action,
entityType: entry.entityType,
entityId: entry.entityId,
before:
(redact(entry.before ?? null) as Prisma.InputJsonValue) ??
Prisma.JsonNull,
after:
(redact(entry.after ?? null) as Prisma.InputJsonValue) ??
Prisma.JsonNull,
ipAddress: entry.ipAddress,
userAgent: entry.userAgent,
},
});
}
// Paginação sempre server-side (agente.md seção 54) — audit_logs cresce
// sem limite, nunca um SELECT * sem filtro/paginação.
async query(query: QueryAuditDto) {
const where: Prisma.AuditLogWhereInput = {
userId: query.userId,
action: query.action,
entityType: query.entityType,
createdAt: {
gte: query.from ? new Date(query.from) : undefined,
lte: query.to ? new Date(query.to) : undefined,
},
};
const [total, items] = await this.prisma.$transaction([
this.prisma.auditLog.count({ where }),
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: { user: { select: { id: true, name: true, email: true } } },
}),
]);
return {
items: items.map((item) => ({ ...item, id: item.id.toString() })),
total,
page: query.page,
pageSize: query.pageSize,
};
}
}