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.
This commit is contained in:
2026-08-27 12:23:02 -03:00
parent eee6d7aece
commit a2898fa566
69 changed files with 10161 additions and 7 deletions

View File

@@ -0,0 +1,175 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import * as argon2 from 'argon2';
import { generateStrongPassword } from '@b2bcall/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { MailerService } from '../auth/mailer.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import type { RequestContext } from '../auth/auth.service';
function toSafeUser(user: {
id: string;
name: string;
email: string;
isActive: boolean;
mustChangePassword: boolean;
lastLoginAt: Date | null;
createdAt: Date;
roles?: { role: { id: string; name: string } }[];
}) {
return {
id: user.id,
name: user.name,
email: user.email,
isActive: user.isActive,
mustChangePassword: user.mustChangePassword,
lastLoginAt: user.lastLoginAt,
createdAt: user.createdAt,
roles: user.roles?.map((r) => r.role) ?? [],
};
}
@Injectable()
export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly mailer: MailerService,
) {}
async list() {
const users = await this.prisma.user.findMany({
include: { roles: { include: { role: true } } },
orderBy: { createdAt: 'asc' },
});
return users.map(toSafeUser);
}
async findByIdOrThrow(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: { roles: { include: { role: true } } },
});
if (!user) throw new NotFoundException('Usuário não encontrado.');
return toSafeUser(user);
}
async create(dto: CreateUserDto, actor: { id: string }, ctx: RequestContext) {
const existing = await this.prisma.user.findUnique({
where: { email: dto.email },
});
if (existing)
throw new BadRequestException('Já existe um usuário com este e-mail.');
const roles = await this.prisma.role.findMany({
where: { id: { in: dto.roleIds } },
});
if (roles.length !== dto.roleIds.length) {
throw new BadRequestException(
'Um ou mais perfis informados não existem.',
);
}
const password = generateStrongPassword();
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const user = await this.prisma.user.create({
data: {
name: dto.name,
email: dto.email,
passwordHash,
mustChangePassword: true,
roles: { create: dto.roleIds.map((roleId) => ({ roleId })) },
},
include: { roles: { include: { role: true } } },
});
await this.audit.log({
userId: actor.id,
action: 'user_created',
entityType: 'user',
entityId: user.id,
after: { name: user.name, email: user.email, roleIds: dto.roleIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
// Stub de log até SMTP ser configurado — ver MailerService.
this.mailer.sendNewUserCredentials(user.email, password);
return toSafeUser(user);
}
async update(
id: string,
dto: UpdateUserDto,
actor: { id: string },
ctx: RequestContext,
) {
// Nunca permitir que alguém altere os próprios perfis (agente.md seção
// 92: "agent não consegue elevar a própria permissão"), independente da
// permissão que já possua.
if (id === actor.id && dto.roleIds !== undefined) {
throw new ForbiddenException(
'Você não pode alterar seus próprios perfis de acesso.',
);
}
const before = await this.prisma.user.findUnique({
where: { id },
include: { roles: true },
});
if (!before) throw new NotFoundException('Usuário não encontrado.');
if (dto.roleIds) {
const roles = await this.prisma.role.findMany({
where: { id: { in: dto.roleIds } },
});
if (roles.length !== dto.roleIds.length) {
throw new BadRequestException(
'Um ou mais perfis informados não existem.',
);
}
}
const user = await this.prisma.$transaction(async (tx) => {
if (dto.roleIds) {
await tx.userRole.deleteMany({ where: { userId: id } });
await tx.userRole.createMany({
data: dto.roleIds.map((roleId) => ({ userId: id, roleId })),
});
}
return tx.user.update({
where: { id },
data: {
name: dto.name,
isActive: dto.isActive,
},
include: { roles: { include: { role: true } } },
});
});
await this.audit.log({
userId: actor.id,
action: 'user_updated',
entityType: 'user',
entityId: id,
before: {
name: before.name,
isActive: before.isActive,
roleIds: before.roles.map((r) => r.roleId),
},
after: { name: user.name, isActive: user.isActive, roleIds: dto.roleIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toSafeUser(user);
}
}