- 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.
98 lines
2.8 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|