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,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return app info', () => {
expect(appController.getInfo()).toEqual({
name: 'B2BCall API',
status: 'ok',
});
});
});
});

View File

@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from './common/decorators/public.decorator';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Public()
@Get()
getInfo() {
return this.appService.getInfo();
}
}

View File

@@ -0,0 +1,65 @@
import type { IncomingMessage } from 'node:http';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { RedisModule } from './redis/redis.module';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { RolesModule } from './roles/roles.module';
import { HealthModule } from './health/health.module';
import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// Logs estruturados JSON com request_id de correlação (agente.md seções
// 60/61). Nunca loga segredos: senha, tokens, credenciais AMI/ARI/SIP.
LoggerModule.forRoot({
pinoHttp: {
genReqId: (req) => req.id,
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.body.password',
'req.body.currentPassword',
'req.body.newPassword',
'res.headers["set-cookie"]',
],
censor: '[REDACTED]',
},
customProps: (req: IncomingMessage & { id: string }) => ({
service: 'b2bcall-api',
requestId: req.id,
}),
},
}),
// Rate limit global genérico (proteção geral contra abuso). O login tem
// sua própria proteção progressiva mais rígida (LoginThrottleService).
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 120 }]),
PrismaModule,
RedisModule,
AuditModule,
AuthModule,
UsersModule,
RolesModule,
HealthModule,
],
controllers: [AppController],
providers: [
AppService,
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_GUARD, useClass: PermissionsGuard },
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getInfo() {
return { name: 'B2BCall API', status: 'ok' };
}
}

View File

@@ -0,0 +1,15 @@
import { Controller, Get, Query } from '@nestjs/common';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { AuditService } from './audit.service';
import { QueryAuditDto } from './dto/query-audit.dto';
@Controller('audit')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
@Get()
@RequirePermissions('audit.view')
query(@Query() query: QueryAuditDto) {
return this.auditService.query(query);
}
}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
import { AuditController } from './audit.controller';
@Global()
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,97 @@
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,
};
}
}

View File

@@ -0,0 +1,44 @@
import { Type } from 'class-transformer';
import {
IsDateString,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryAuditDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
action?: string;
@IsOptional()
@IsString()
entityType?: string;
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 50;
}

View File

@@ -0,0 +1,173 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
const ACCESS_COOKIE = 'access_token';
const REFRESH_COOKIE = 'refresh_token';
@Controller('auth')
export class AuthController {
private readonly cookieSecure: boolean;
constructor(
private readonly authService: AuthService,
private readonly config: ConfigService,
) {
this.cookieSecure = this.config.get('COOKIE_SECURE', 'false') === 'true';
}
private setAuthCookies(
reply: FastifyReply,
tokens: {
accessToken: string;
accessTokenTtlMs: number;
refreshToken: string;
refreshTokenTtlMs: number;
},
) {
const domain = this.config.get<string>('COOKIE_DOMAIN') || undefined;
reply.setCookie(ACCESS_COOKIE, tokens.accessToken, {
httpOnly: true,
secure: this.cookieSecure,
sameSite: 'lax',
path: '/',
domain,
maxAge: Math.floor(tokens.accessTokenTtlMs / 1000),
});
// Cookie de refresh restrito a /auth: reduz superfície de exposição do
// token de maior duração a rotas que não precisam dele.
reply.setCookie(REFRESH_COOKIE, tokens.refreshToken, {
httpOnly: true,
secure: this.cookieSecure,
sameSite: 'lax',
path: '/auth',
domain,
maxAge: Math.floor(tokens.refreshTokenTtlMs / 1000),
});
}
private clearAuthCookies(reply: FastifyReply) {
const domain = this.config.get<string>('COOKIE_DOMAIN') || undefined;
reply.clearCookie(ACCESS_COOKIE, { path: '/', domain });
reply.clearCookie(REFRESH_COOKIE, { path: '/auth', domain });
}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body() dto: LoginDto,
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const result = await this.authService.login(dto.email, dto.password, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
this.setAuthCookies(reply, result);
return { mustChangePassword: result.mustChangePassword };
}
@Public()
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const refreshToken = request.cookies?.[REFRESH_COOKIE];
if (!refreshToken)
throw new UnauthorizedException('Refresh token ausente.');
const tokens = await this.authService.refresh(refreshToken, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
this.setAuthCookies(reply, tokens);
return { ok: true };
}
@Post('logout')
@HttpCode(HttpStatus.OK)
async logout(
@CurrentUser() user: AuthenticatedUser,
@Req() request: FastifyRequest,
@Res({ passthrough: true }) reply: FastifyReply,
) {
const refreshToken = request.cookies?.[REFRESH_COOKIE];
await this.authService.logout(refreshToken, user?.id, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
this.clearAuthCookies(reply);
return { ok: true };
}
@Post('change-password')
@HttpCode(HttpStatus.OK)
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ChangePasswordDto,
@Req() request: FastifyRequest,
) {
await this.authService.changePassword(
user.id,
dto.currentPassword,
dto.newPassword,
{
ip: request.ip,
userAgent: request.headers['user-agent'],
},
);
return { ok: true };
}
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() dto: ForgotPasswordDto) {
await this.authService.forgotPassword(dto.email);
// Resposta genérica sempre — nunca revela se o e-mail existe.
return {
message: 'Se o e-mail existir, um link de recuperação foi enviado.',
};
}
@Public()
@Post('reset-password')
@HttpCode(HttpStatus.OK)
async resetPassword(
@Body() dto: ResetPasswordDto,
@Req() request: FastifyRequest,
) {
await this.authService.resetPassword(dto.token, dto.newPassword, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
return { ok: true };
}
@Get('me')
me(@CurrentUser() user: AuthenticatedUser) {
return user;
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { LoginThrottleService } from './login-throttle.service';
import { MailerService } from './mailer.service';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: config.get<string>('JWT_ACCESS_TTL', '15m') },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LoginThrottleService, MailerService],
// Exporta o JwtModule para que o AuthGuard global (registrado em
// AppModule via APP_GUARD) consiga injetar JwtService.
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,298 @@
import { createHash, randomBytes } from 'node:crypto';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import ms from 'ms';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { LoginThrottleService } from './login-throttle.service';
import { MailerService } from './mailer.service';
export interface RequestContext {
ip: string;
userAgent?: string;
}
export interface TokenPair {
accessToken: string;
accessTokenTtlMs: number;
refreshToken: string;
refreshTokenTtlMs: number;
}
const GENERIC_LOGIN_ERROR = 'Credenciais inválidas.';
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@Injectable()
export class AuthService {
private readonly accessTtl: string;
private readonly refreshTtl: string;
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
private readonly audit: AuditService,
private readonly loginThrottle: LoginThrottleService,
private readonly mailer: MailerService,
) {
this.accessTtl = this.config.get('JWT_ACCESS_TTL', '15m');
this.refreshTtl = this.config.get('JWT_REFRESH_TTL', '7d');
}
async getUserPermissions(userId: string): Promise<string[]> {
const roles = await this.prisma.userRole.findMany({
where: { userId },
include: {
role: { include: { permissions: { include: { permission: true } } } },
},
});
const permissions = new Set<string>();
for (const userRole of roles) {
for (const rolePermission of userRole.role.permissions) {
permissions.add(rolePermission.permission.key);
}
}
return [...permissions];
}
async login(
email: string,
password: string,
ctx: RequestContext,
): Promise<TokenPair & { mustChangePassword: boolean }> {
await this.loginThrottle.assertNotBlocked(ctx.ip);
const user = await this.prisma.user.findUnique({ where: { email } });
const passwordValid = user
? await argon2.verify(user.passwordHash, password).catch(() => false)
: false;
if (!user || !user.isActive || !passwordValid) {
await this.loginThrottle.recordFailure(ctx.ip);
await this.audit.log({
userId: user?.id ?? null,
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
await this.loginThrottle.recordSuccess(ctx.ip);
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
await this.audit.log({
userId: user.id,
action: 'login',
entityType: 'user',
entityId: user.id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
const permissions = await this.getUserPermissions(user.id);
const tokens = await this.issueTokenPair(
user.id,
user.email,
permissions,
ctx,
);
return { ...tokens, mustChangePassword: user.mustChangePassword };
}
private async issueTokenPair(
userId: string,
email: string,
permissions: string[],
ctx: RequestContext,
): Promise<TokenPair> {
const accessToken = await this.jwtService.signAsync(
{ sub: userId, email, permissions },
{ expiresIn: this.accessTtl },
);
const refreshTokenPlain = randomBytes(48).toString('base64url');
const refreshTokenTtlMs = ms(this.refreshTtl);
await this.prisma.session.create({
data: {
userId,
refreshTokenHash: hashToken(refreshTokenPlain),
userAgent: ctx.userAgent,
ipAddress: ctx.ip,
expiresAt: new Date(Date.now() + refreshTokenTtlMs),
},
});
return {
accessToken,
accessTokenTtlMs: ms(this.accessTtl),
refreshToken: refreshTokenPlain,
refreshTokenTtlMs,
};
}
// Rotação de refresh token: cada uso invalida o token anterior e emite um
// novo par (agente.md seção 9: "rotação de refresh token").
async refresh(
refreshTokenPlain: string,
ctx: RequestContext,
): Promise<TokenPair> {
const tokenHash = hashToken(refreshTokenPlain);
const session = await this.prisma.session.findFirst({
where: { refreshTokenHash: tokenHash },
include: { user: true },
});
if (!session || session.revokedAt || session.expiresAt < new Date()) {
throw new UnauthorizedException('Sessão inválida ou expirada.');
}
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
if (!session.user.isActive) {
throw new UnauthorizedException('Usuário inativo.');
}
const permissions = await this.getUserPermissions(session.userId);
return this.issueTokenPair(
session.userId,
session.user.email,
permissions,
ctx,
);
}
async logout(
refreshTokenPlain: string | undefined,
userId: string | undefined,
ctx: RequestContext,
): Promise<void> {
if (refreshTokenPlain) {
const tokenHash = hashToken(refreshTokenPlain);
await this.prisma.session.updateMany({
where: { refreshTokenHash: tokenHash, revokedAt: null },
data: { revokedAt: new Date() },
});
}
await this.audit.log({
userId,
action: 'logout',
entityType: 'user',
entityId: userId,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
ctx: RequestContext,
): Promise<void> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
const valid = await argon2
.verify(user.passwordHash, currentPassword)
.catch(() => false);
if (!valid) throw new UnauthorizedException('Senha atual incorreta.');
const passwordHash = await argon2.hash(newPassword, {
type: argon2.argon2id,
});
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false },
});
// Revoga todas as sessões existentes ao trocar senha (boa prática de
// segurança: um refresh token vazado antes da troca deixa de funcionar).
await this.prisma.session.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
await this.audit.log({
userId,
action: 'password_changed',
entityType: 'user',
entityId: userId,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
// Resposta sempre genérica independente de o e-mail existir, para não
// permitir enumeração de usuários (agente.md seção 9).
async forgotPassword(email: string): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (!user) return;
const tokenPlain = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + ms('1h'));
await this.prisma.passwordResetToken.create({
data: { userId: user.id, tokenHash: hashToken(tokenPlain), expiresAt },
});
this.mailer.sendPasswordReset(user.email, tokenPlain);
}
async resetPassword(
tokenPlain: string,
newPassword: string,
ctx: RequestContext,
): Promise<void> {
const tokenHash = hashToken(tokenPlain);
const resetToken = await this.prisma.passwordResetToken.findUnique({
where: { tokenHash },
});
if (!resetToken || resetToken.usedAt || resetToken.expiresAt < new Date()) {
throw new UnauthorizedException(
'Token de recuperação inválido ou expirado.',
);
}
const passwordHash = await argon2.hash(newPassword, {
type: argon2.argon2id,
});
await this.prisma.$transaction([
this.prisma.user.update({
where: { id: resetToken.userId },
data: { passwordHash, mustChangePassword: false },
}),
this.prisma.passwordResetToken.update({
where: { id: resetToken.id },
data: { usedAt: new Date() },
}),
this.prisma.session.updateMany({
where: { userId: resetToken.userId, revokedAt: null },
data: { revokedAt: new Date() },
}),
]);
await this.audit.log({
userId: resetToken.userId,
action: 'password_reset',
entityType: 'user',
entityId: resetToken.userId,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword!: string;
@IsString()
@MinLength(12, { message: 'A nova senha deve ter pelo menos 12 caracteres' })
newPassword!: string;
}

View File

@@ -0,0 +1,6 @@
import { IsEmail } from 'class-validator';
export class ForgotPasswordDto {
@IsEmail()
email!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
}

View File

@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@IsString()
token!: string;
@IsString()
@MinLength(12, { message: 'A nova senha deve ter pelo menos 12 caracteres' })
newPassword!: string;
}

View File

@@ -0,0 +1,76 @@
import { Inject, Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type Redis from 'ioredis';
import { REDIS_CLIENT } from '../redis/redis.module';
// Proteção contra força bruta no login (agente.md seção 10): N tentativas
// por janela por IP, com bloqueio progressivo (1min, 2min, 4min, ... até um
// teto de 1h) baseado em quantas vezes aquele IP já estourou o limite nas
// últimas 24h. Coordenado via Redis para funcionar com múltiplas réplicas
// da API no futuro.
@Injectable()
export class LoginThrottleService {
private readonly maxAttempts: number;
private readonly windowSeconds: number;
private readonly maxBlockSeconds = 3600;
private readonly violationsTtlSeconds = 86400;
constructor(
@Inject(REDIS_CLIENT) private readonly redis: Redis,
config: ConfigService,
) {
this.maxAttempts = Number(config.get('RATE_LIMIT_LOGIN_MAX', '5'));
this.windowSeconds = Number(
config.get('RATE_LIMIT_LOGIN_WINDOW_SECONDS', '60'),
);
}
private blockKey(ip: string) {
return `auth:block:${ip}`;
}
private attemptsKey(ip: string) {
return `auth:attempts:${ip}`;
}
private violationsKey(ip: string) {
return `auth:violations:${ip}`;
}
async assertNotBlocked(ip: string): Promise<void> {
const ttl = await this.redis.ttl(this.blockKey(ip));
if (ttl > 0) {
throw new HttpException(
{
message: `Muitas tentativas de login. Tente novamente em ${ttl} segundos.`,
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
}
async recordFailure(ip: string): Promise<void> {
const attempts = await this.redis.incr(this.attemptsKey(ip));
if (attempts === 1) {
await this.redis.expire(this.attemptsKey(ip), this.windowSeconds);
}
if (attempts > this.maxAttempts) {
const violations = await this.redis.incr(this.violationsKey(ip));
if (violations === 1) {
await this.redis.expire(
this.violationsKey(ip),
this.violationsTtlSeconds,
);
}
const blockSeconds = Math.min(
60 * 2 ** (violations - 1),
this.maxBlockSeconds,
);
await this.redis.set(this.blockKey(ip), '1', 'EX', blockSeconds);
await this.redis.del(this.attemptsKey(ip));
}
}
async recordSuccess(ip: string): Promise<void> {
await this.redis.del(this.attemptsKey(ip), this.blockKey(ip));
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable, Logger } from '@nestjs/common';
// Stub: sem credenciais SMTP fornecidas (.env SMTP_*), não dá para enviar
// e-mail de verdade — isso exige informação externa (agente.md seção 1:
// "somente pare por algo realmente impossível de resolver sem informação
// externa"). Implementação real (nodemailer) é um único arquivo a trocar
// aqui assim que as credenciais existirem; a lógica de geração/validação
// de token de recuperação já está completa em AuthService.
@Injectable()
export class MailerService {
private readonly logger = new Logger(MailerService.name);
sendPasswordReset(email: string, token: string): void {
this.logger.warn(
`SMTP não configurado — link de recuperação para ${email} (apenas log, não enviado): ` +
`/reset-password?token=${token}`,
);
}
sendNewUserCredentials(email: string, temporaryPassword: string): void {
this.logger.warn(
`SMTP não configurado — credenciais iniciais para ${email} (apenas log, não enviado): ` +
`senha temporária ${temporaryPassword} (troca obrigatória no primeiro login)`,
);
}
}

View File

@@ -0,0 +1,11 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { AuthenticatedUser } from '../guards/auth.guard';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthenticatedUser => {
const request = ctx
.switchToHttp()
.getRequest<{ user: AuthenticatedUser }>();
return request.user;
},
);

View File

@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
import type { Permission } from '@b2bcall/shared';
export const PERMISSIONS_KEY = 'required_permissions';
// Uso: @RequirePermissions('users.create')
// A checagem real acontece sempre no backend (PermissionsGuard) — o
// frontend só usa isso para decidir o que exibir, nunca como controle de
// acesso de fato (agente.md seção 11).
export const RequirePermissions = (...permissions: Permission[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'is_public';
// Marca uma rota como não exigindo autenticação (ex.: login, health check).
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -0,0 +1,53 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
// Nunca deixa vazar um erro cru (stack trace, mensagem interna de driver de
// banco, etc.) para o cliente. Sempre retorna um request_id para suporte
// técnico correlacionar com o log estruturado do servidor (agente.md
// seções 60/61/73).
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger('ExceptionFilter');
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const requestId = request.id;
const isHttpException = exception instanceof HttpException;
const status: number = isHttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const responseBody = isHttpException
? exception.getResponse()
: {
message:
'Erro interno. Contate o suporte informando o código abaixo.',
};
const isServerError = status >= 500; // HttpStatus.INTERNAL_SERVER_ERROR
if (!isHttpException || isServerError) {
this.logger.error(
`[${requestId}] ${request.method} ${request.url} -> ${status}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
response.status(status).send({
statusCode: status,
requestId,
...(typeof responseBody === 'string'
? { message: responseBody }
: responseBody),
});
}
}

View File

@@ -0,0 +1,67 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import type { FastifyRequest } from 'fastify';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
export interface AuthenticatedUser {
id: string;
email: string;
permissions: string[];
}
type RequestWithUser = FastifyRequest & { user?: AuthenticatedUser };
// Extrai o access token do cookie HttpOnly (fluxo normal do frontend) ou do
// header Authorization (útil para clients/scripts/testes).
function extractToken(request: FastifyRequest): string | null {
const cookieToken = request.cookies?.['access_token'];
if (cookieToken) return cookieToken;
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer '))
return authHeader.slice('Bearer '.length);
return null;
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<RequestWithUser>();
const token = extractToken(request);
if (!token) throw new UnauthorizedException('Token de acesso ausente');
try {
const payload = await this.jwtService.verifyAsync<{
sub: string;
email: string;
permissions: string[];
}>(token);
request.user = {
id: payload.sub,
email: payload.email,
permissions: payload.permissions,
} satisfies AuthenticatedUser;
return true;
} catch {
throw new UnauthorizedException('Token de acesso inválido ou expirado');
}
}
}

View File

@@ -0,0 +1,67 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsGuard } from './permissions.guard';
function makeContext(user: unknown): ExecutionContext {
return {
getHandler: () => ({}),
getClass: () => ({}),
switchToHttp: () => ({ getRequest: () => ({ user }) }),
} as unknown as ExecutionContext;
}
describe('PermissionsGuard', () => {
it('permite quando a rota não exige nenhuma permissão', () => {
const reflector = {
getAllAndOverride: () => undefined,
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
expect(guard.canActivate(makeContext({ id: '1', permissions: [] }))).toBe(
true,
);
});
it('nega quando não há usuário autenticado na requisição', () => {
const reflector = {
getAllAndOverride: () => ['users.view'],
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
expect(() => guard.canActivate(makeContext(undefined))).toThrow(
ForbiddenException,
);
});
it('nega quando o usuário não possui a permissão exigida', () => {
const reflector = {
getAllAndOverride: () => ['users.create'],
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
const ctx = makeContext({ id: '1', permissions: ['users.view'] });
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
it('permite quando o usuário possui todas as permissões exigidas', () => {
const reflector = {
getAllAndOverride: () => ['users.view', 'users.create'],
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
const ctx = makeContext({
id: '1',
permissions: ['users.view', 'users.create', 'audit.view'],
});
expect(guard.canActivate(ctx)).toBe(true);
});
// Regressão direta do critério de aceite de seguranca: "agent não
// consegue elevar a própria permissão" depende de UsersService, não
// deste guard — mas o guard É o que impede um usuário sem 'users.update'
// de sequer chegar ao endpoint. Verificamos aqui o caso geral de negação.
it('nega quando o usuário possui apenas parte das permissões exigidas', () => {
const reflector = {
getAllAndOverride: () => ['users.view', 'roles.manage'],
} as unknown as Reflector;
const guard = new PermissionsGuard(reflector);
const ctx = makeContext({ id: '1', permissions: ['users.view'] });
expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException);
});
});

View File

@@ -0,0 +1,39 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Permission } from '@b2bcall/shared';
import { PERMISSIONS_KEY } from '../decorators/permissions.decorator';
import type { AuthenticatedUser } from './auth.guard';
// Reforça no backend o que o frontend só usa para exibição (agente.md seção
// 11: "a segurança sempre deve ser validada novamente pelo backend").
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<Permission[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required || required.length === 0) return true;
const request = context
.switchToHttp()
.getRequest<{ user?: AuthenticatedUser }>();
const user = request.user;
if (!user) throw new ForbiddenException('Usuário não autenticado');
const hasAll = required.every((perm) => user.permissions.includes(perm));
if (!hasAll) {
throw new ForbiddenException(
`Permissão necessária: ${required.join(', ')}`,
);
}
return true;
}
}

View File

@@ -0,0 +1,58 @@
import { Controller, Get, Inject } from '@nestjs/common';
import {
HealthCheck,
HealthCheckService,
HealthIndicatorFunction,
HealthIndicatorResult,
} from '@nestjs/terminus';
import type Redis from 'ioredis';
import { Public } from '../common/decorators/public.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { REDIS_CLIENT } from '../redis/redis.module';
@Controller('health')
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly prisma: PrismaService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
private postgresIndicator: HealthIndicatorFunction =
async (): Promise<HealthIndicatorResult> => {
await this.prisma.$queryRaw`SELECT 1`;
return { postgres: { status: 'up' } };
};
private redisIndicator: HealthIndicatorFunction =
async (): Promise<HealthIndicatorResult> => {
const pong = await this.redis.ping();
if (pong !== 'PONG') throw new Error('Redis não respondeu PONG');
return { redis: { status: 'up' } };
};
// Liveness: o processo da API está de pé. Não depende de dependências
// externas — usado por orquestradores para decidir se precisa reiniciar.
@Public()
@Get('live')
@HealthCheck()
live() {
return this.health.check([]);
}
// Readiness: a API está pronta para tráfego real (dependências no ar).
// Asterisk/AMI entram aqui quando apps/asterisk-events existir (Fase 4).
@Public()
@Get('ready')
@HealthCheck()
ready() {
return this.health.check([this.postgresIndicator, this.redisIndicator]);
}
@Public()
@Get()
@HealthCheck()
check() {
return this.health.check([this.postgresIndicator, this.redisIndicator]);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { HealthController } from './health.controller';
@Module({
imports: [TerminusModule],
controllers: [HealthController],
})
export class HealthModule {}

66
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,66 @@
import { randomUUID } from 'node:crypto';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { Logger } from 'nestjs-pino';
import fastifyCookie from '@fastify/cookie';
import fastifyHelmet from '@fastify/helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ genReqId: () => randomUUID(), trustProxy: true }),
{ bufferLogs: true },
);
app.useLogger(app.get(Logger));
const config = app.get(ConfigService);
await app.register(fastifyCookie);
await app.register(fastifyHelmet, {
// Swagger UI (quando habilitado) precisa de scripts/estilos inline.
contentSecurityPolicy:
config.get('SWAGGER_ENABLED', 'true') === 'true' ? false : undefined,
});
const allowedOrigins = (
config.get<string>('ALLOWED_ORIGINS') ?? config.get<string>('APP_URL', '')
)
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
app.enableCors({ origin: allowedOrigins, credentials: true });
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
if (config.get('SWAGGER_ENABLED', 'true') === 'true') {
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder()
.setTitle('B2BCall API')
.setVersion('0.1.0')
.addCookieAuth('access_token')
.build(),
);
SwaggerModule.setup('api/docs', app, document);
}
const port = Number(config.get('PORT', '3000'));
await app.listen(port, '0.0.0.0');
}
void bootstrap();

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,24 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { PrismaClient } from '@b2bcall/database';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
async onModuleInit() {
await this.$connect();
this.logger.log('Conectado ao Postgres via Prisma');
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,23 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
export const REDIS_CLIENT = 'REDIS_CLIENT';
@Global()
@Module({
providers: [
{
provide: REDIS_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
return new Redis(config.getOrThrow<string>('REDIS_URL'), {
lazyConnect: false,
maxRetriesPerRequest: 3,
});
},
},
],
exports: [REDIS_CLIENT],
})
export class RedisModule {}

View File

@@ -0,0 +1,15 @@
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
import type { Permission } from '@b2bcall/shared';
export class CreateRoleDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsArray()
permissionKeys!: Permission[];
}

View File

@@ -0,0 +1,13 @@
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
import type { Permission } from '@b2bcall/shared';
export class UpdateRoleDto {
@IsOptional()
@IsString()
@MinLength(1)
description?: string;
@IsOptional()
@IsArray()
permissionKeys?: Permission[];
}

View File

@@ -0,0 +1,71 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
@Controller('roles')
@RequirePermissions('roles.manage')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get('permissions')
listPermissionCatalog() {
return this.rolesService.listPermissionCatalog();
}
@Get()
list() {
return this.rolesService.list();
}
@Post()
create(
@Body() dto: CreateRoleDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.rolesService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateRoleDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.rolesService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.rolesService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
@Module({
controllers: [RolesController],
providers: [RolesService],
})
export class RolesModule {}

View File

@@ -0,0 +1,165 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PERMISSIONS, type Permission } from '@b2bcall/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
function assertValidPermissionKeys(keys: Permission[]) {
const invalid = keys.filter((k) => !PERMISSIONS.includes(k));
if (invalid.length > 0) {
throw new BadRequestException(
`Permissões inválidas: ${invalid.join(', ')}`,
);
}
}
function toRoleDto(role: {
id: string;
name: string;
description: string | null;
isSystem: boolean;
permissions: { permission: { key: string } }[];
}) {
return {
id: role.id,
name: role.name,
description: role.description,
isSystem: role.isSystem,
permissions: role.permissions.map((p) => p.permission.key),
};
}
@Injectable()
export class RolesService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
listPermissionCatalog(): readonly Permission[] {
return PERMISSIONS;
}
async list() {
const roles = await this.prisma.role.findMany({
include: { permissions: { include: { permission: true } } },
orderBy: { name: 'asc' },
});
return roles.map(toRoleDto);
}
async create(dto: CreateRoleDto, actor: { id: string }, ctx: RequestContext) {
assertValidPermissionKeys(dto.permissionKeys);
const existing = await this.prisma.role.findUnique({
where: { name: dto.name },
});
if (existing)
throw new BadRequestException('Já existe um perfil com este nome.');
const permissions = await this.prisma.permission.findMany({
where: { key: { in: dto.permissionKeys } },
});
const role = await this.prisma.role.create({
data: {
name: dto.name,
description: dto.description,
permissions: {
create: permissions.map((p) => ({ permissionId: p.id })),
},
},
include: { permissions: { include: { permission: true } } },
});
await this.audit.log({
userId: actor.id,
action: 'role_created',
entityType: 'role',
entityId: role.id,
after: { name: role.name, permissionKeys: dto.permissionKeys },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toRoleDto(role);
}
// Único ponto de mudança das permissões de um perfil (agente.md seção 11:
// tela de "Perfis e Permissões" do super_admin). isSystem só impede
// renomear/excluir o perfil, nunca editar suas permissões.
async update(
id: string,
dto: UpdateRoleDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.role.findUnique({
where: { id },
include: { permissions: { include: { permission: true } } },
});
if (!before) throw new NotFoundException('Perfil não encontrado.');
if (dto.permissionKeys) assertValidPermissionKeys(dto.permissionKeys);
const role = await this.prisma.$transaction(async (tx) => {
if (dto.permissionKeys) {
const permissions = await tx.permission.findMany({
where: { key: { in: dto.permissionKeys } },
});
await tx.rolePermission.deleteMany({ where: { roleId: id } });
await tx.rolePermission.createMany({
data: permissions.map((p) => ({ roleId: id, permissionId: p.id })),
});
}
return tx.role.update({
where: { id },
data: { description: dto.description },
include: { permissions: { include: { permission: true } } },
});
});
await this.audit.log({
userId: actor.id,
action: 'role_permissions_updated',
entityType: 'role',
entityId: id,
before: {
permissionKeys: before.permissions.map((p) => p.permission.key),
},
after: { permissionKeys: dto.permissionKeys },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toRoleDto(role);
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const role = await this.prisma.role.findUnique({ where: { id } });
if (!role) throw new NotFoundException('Perfil não encontrado.');
if (role.isSystem)
throw new ForbiddenException(
'Perfis padrão do sistema não podem ser excluídos.',
);
await this.prisma.role.delete({ where: { id } });
await this.audit.log({
userId: actor.id,
action: 'role_deleted',
entityType: 'role',
entityId: id,
before: { name: role.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

@@ -0,0 +1,22 @@
import {
ArrayNotEmpty,
IsArray,
IsEmail,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(1)
name!: string;
@IsEmail()
email!: string;
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
roleIds!: string[];
}

View File

@@ -0,0 +1,24 @@
import {
IsArray,
IsBoolean,
IsOptional,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
export class UpdateUserDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
roleIds?: string[];
}

View File

@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
@RequirePermissions('users.view')
list() {
return this.usersService.list();
}
@Get(':id')
@RequirePermissions('users.view')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findByIdOrThrow(id);
}
@Post()
@RequirePermissions('users.create')
create(
@Body() dto: CreateUserDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.usersService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('users.update')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.usersService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { MailerService } from '../auth/mailer.service';
@Module({
controllers: [UsersController],
providers: [UsersService, MailerService],
exports: [UsersService],
})
export class UsersModule {}

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);
}
}