- 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.
176 lines
5.0 KiB
TypeScript
176 lines
5.0 KiB
TypeScript
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);
|
|
}
|
|
}
|