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:
175
apps/api/src/users/users.service.ts
Normal file
175
apps/api/src/users/users.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user