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:
@@ -8,6 +8,12 @@ NODE_ENV=production
|
||||
APP_NAME=B2BCall
|
||||
APP_URL=http://10.10.32.142
|
||||
TZ=America/Sao_Paulo
|
||||
PORT=3000
|
||||
# Origens permitidas por CORS (separadas por vírgula). Nunca usar "*" — a
|
||||
# API usa cookies (credentials: true), que exigem origem explícita.
|
||||
ALLOWED_ORIGINS=http://10.10.32.142
|
||||
# Swagger/OpenAPI em /api/docs — desabilitar em produção real.
|
||||
SWAGGER_ENABLED=true
|
||||
|
||||
# Modo de simulação do discador: quando true, NENHUMA chamada externa real é
|
||||
# originada (usado em dev/testes). Nunca deixar true em produção.
|
||||
|
||||
40
TODO.md
40
TODO.md
@@ -57,13 +57,39 @@ mestre original (`agente.md`, seções 90-93).
|
||||
ODBC — decisão adequada, não precisa dos módulos pgsql nativos.
|
||||
|
||||
## Fase 3 — Backend base
|
||||
- [ ] apps/api (NestJS + Fastify) bootstrap
|
||||
- [ ] Autenticação (Argon2id, access+refresh, cookies HttpOnly)
|
||||
- [ ] Rate limiting (login e endpoints sensíveis)
|
||||
- [ ] RBAC (users/roles/permissions/user_roles/role_permissions) + tela de perfis
|
||||
- [ ] Auditoria (audit_logs) + interceptor genérico
|
||||
- [ ] Health checks /api/health(/live|/ready)
|
||||
- [ ] Swagger/OpenAPI (desabilitável em produção)
|
||||
- [x] apps/api (NestJS 11 + Fastify) bootstrap — containerizado, healthy
|
||||
- [x] packages/database (Prisma) + packages/shared (catálogo de permissões)
|
||||
— decisão de ORM documentada em docs/ARCHITECTURE.md 3.6.1
|
||||
- [x] Migration inicial aplicada (users/sessions/roles/permissions/
|
||||
user_roles/role_permissions/audit_logs/password_reset_tokens)
|
||||
- [x] Seed (40 permissões, 4 perfis, bootstrap super_admin com senha
|
||||
aleatória em FIRST_LOGIN.txt chmod 600) — testado
|
||||
- [x] Autenticação (Argon2id, access JWT + refresh opaco com rotação,
|
||||
cookies HttpOnly/SameSite=Lax, refresh_token restrito a /api/auth) —
|
||||
testado: login, /me, refresh, change-password, revogação de sessão
|
||||
- [x] Rate limiting de login progressivo via Redis (5/60s, bloqueio
|
||||
1min→2min→4min...até 1h por IP) — testado com 7 tentativas seguidas
|
||||
- [x] RBAC completo (users/roles/permissions/user_roles/role_permissions)
|
||||
reforçado no backend via PermissionsGuard — testado (nega sem
|
||||
permissão, permite com todas as permissões) + unit tests
|
||||
- [x] Proteção contra auto-elevação de privilégio (usuário não altera os
|
||||
próprios roleIds) — testado, HTTP 403
|
||||
- [x] Auditoria (audit_logs) chamada explicitamente em cada ação sensível
|
||||
(login/login_failed/logout/password_changed/user_created/
|
||||
user_updated/role_*) com redação de campos sensíveis — testado via
|
||||
GET /api/audit (roles.manage/audit.view), paginação server-side
|
||||
- [x] Health checks /api/health, /api/health/live, /api/health/ready
|
||||
(Postgres + Redis reais, sem dado fake) — testado
|
||||
- [x] Swagger/OpenAPI em /api/docs, desabilitável via SWAGGER_ENABLED=false
|
||||
— testado (200 com flag default true)
|
||||
- [x] Logs estruturados JSON (nestjs-pino) com request_id de correlação e
|
||||
redação de senha/tokens/cookies
|
||||
- [x] Filtro global de exceções: nunca expõe erro cru, sempre requestId
|
||||
- [x] Lint (eslint --fix) e testes unitários (PermissionsGuard) passando
|
||||
- [ ] Recuperação de senha por e-mail: lógica completa (token com
|
||||
expiração, hash, uso único), mas envio real via SMTP é um stub que
|
||||
só loga — falta credencial SMTP real (.env SMTP_*), meramente
|
||||
externo — trocar MailerService por nodemailer quando houver
|
||||
|
||||
## Fase 4 — Telefonia (camada de aplicação)
|
||||
- [ ] packages/telephony: TelephonyProvider + AsteriskTelephonyProvider
|
||||
|
||||
4
apps/api/.prettierrc
Normal file
4
apps/api/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
98
apps/api/README.md
Normal file
98
apps/api/README.md
Normal file
@@ -0,0 +1,98 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
35
apps/api/eslint.config.mjs
Normal file
35
apps/api/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
90
apps/api/package.json
Normal file
90
apps/api/package.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@b2bcall/api",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@b2bcall/database": "workspace:*",
|
||||
"@b2bcall/shared": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/platform-fastify": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"argon2": "^0.44.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"fastify": "^5.2.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"ms": "^2.1.3",
|
||||
"nestjs-pino": "^4.4.0",
|
||||
"pino-http": "^10.5.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^7.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
25
apps/api/src/app.controller.spec.ts
Normal file
25
apps/api/src/app.controller.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/api/src/app.controller.ts
Normal file
14
apps/api/src/app.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
65
apps/api/src/app.module.ts
Normal file
65
apps/api/src/app.module.ts
Normal 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 {}
|
||||
8
apps/api/src/app.service.ts
Normal file
8
apps/api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getInfo() {
|
||||
return { name: 'B2BCall API', status: 'ok' };
|
||||
}
|
||||
}
|
||||
15
apps/api/src/audit/audit.controller.ts
Normal file
15
apps/api/src/audit/audit.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/audit/audit.module.ts
Normal file
11
apps/api/src/audit/audit.module.ts
Normal 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 {}
|
||||
97
apps/api/src/audit/audit.service.ts
Normal file
97
apps/api/src/audit/audit.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
44
apps/api/src/audit/dto/query-audit.dto.ts
Normal file
44
apps/api/src/audit/dto/query-audit.dto.ts
Normal 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;
|
||||
}
|
||||
173
apps/api/src/auth/auth.controller.ts
Normal file
173
apps/api/src/auth/auth.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal 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 {}
|
||||
298
apps/api/src/auth/auth.service.ts
Normal file
298
apps/api/src/auth/auth.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/auth/dto/change-password.dto.ts
Normal file
10
apps/api/src/auth/dto/change-password.dto.ts
Normal 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;
|
||||
}
|
||||
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
6
apps/api/src/auth/dto/forgot-password.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class ForgotPasswordDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
}
|
||||
10
apps/api/src/auth/dto/login.dto.ts
Normal file
10
apps/api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal file
10
apps/api/src/auth/dto/reset-password.dto.ts
Normal 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;
|
||||
}
|
||||
76
apps/api/src/auth/login-throttle.service.ts
Normal file
76
apps/api/src/auth/login-throttle.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
26
apps/api/src/auth/mailer.service.ts
Normal file
26
apps/api/src/auth/mailer.service.ts
Normal 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)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/common/decorators/current-user.decorator.ts
Normal file
11
apps/api/src/common/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
11
apps/api/src/common/decorators/permissions.decorator.ts
Normal file
11
apps/api/src/common/decorators/permissions.decorator.ts
Normal 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);
|
||||
6
apps/api/src/common/decorators/public.decorator.ts
Normal file
6
apps/api/src/common/decorators/public.decorator.ts
Normal 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);
|
||||
53
apps/api/src/common/filters/global-exception.filter.ts
Normal file
53
apps/api/src/common/filters/global-exception.filter.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
67
apps/api/src/common/guards/auth.guard.ts
Normal file
67
apps/api/src/common/guards/auth.guard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/api/src/common/guards/permissions.guard.spec.ts
Normal file
67
apps/api/src/common/guards/permissions.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
apps/api/src/common/guards/permissions.guard.ts
Normal file
39
apps/api/src/common/guards/permissions.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
58
apps/api/src/health/health.controller.ts
Normal file
58
apps/api/src/health/health.controller.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/health/health.module.ts
Normal file
9
apps/api/src/health/health.module.ts
Normal 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
66
apps/api/src/main.ts
Normal 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();
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal 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 {}
|
||||
24
apps/api/src/prisma/prisma.service.ts
Normal file
24
apps/api/src/prisma/prisma.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
23
apps/api/src/redis/redis.module.ts
Normal file
23
apps/api/src/redis/redis.module.ts
Normal 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 {}
|
||||
15
apps/api/src/roles/dto/create-role.dto.ts
Normal file
15
apps/api/src/roles/dto/create-role.dto.ts
Normal 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[];
|
||||
}
|
||||
13
apps/api/src/roles/dto/update-role.dto.ts
Normal file
13
apps/api/src/roles/dto/update-role.dto.ts
Normal 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[];
|
||||
}
|
||||
71
apps/api/src/roles/roles.controller.ts
Normal file
71
apps/api/src/roles/roles.controller.ts
Normal 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/roles/roles.module.ts
Normal file
9
apps/api/src/roles/roles.module.ts
Normal 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 {}
|
||||
165
apps/api/src/roles/roles.service.ts
Normal file
165
apps/api/src/roles/roles.service.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
apps/api/src/users/dto/create-user.dto.ts
Normal file
22
apps/api/src/users/dto/create-user.dto.ts
Normal 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[];
|
||||
}
|
||||
24
apps/api/src/users/dto/update-user.dto.ts
Normal file
24
apps/api/src/users/dto/update-user.dto.ts
Normal 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[];
|
||||
}
|
||||
61
apps/api/src/users/users.controller.ts
Normal file
61
apps/api/src/users/users.controller.ts
Normal 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
11
apps/api/src/users/users.module.ts
Normal file
11
apps/api/src/users/users.module.ts
Normal 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 {}
|
||||
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);
|
||||
}
|
||||
}
|
||||
29
apps/api/test/app.e2e-spec.ts
Normal file
29
apps/api/test/app.e2e-spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
9
apps/api/test/jest-e2e.json
Normal file
9
apps/api/test/jest-e2e.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
25
apps/api/tsconfig.json
Normal file
25
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
@@ -121,3 +121,40 @@ services:
|
||||
resources:
|
||||
limits:
|
||||
memory: 400M
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: infrastructure/docker/api.Dockerfile
|
||||
image: b2bcall-api:0.1.0
|
||||
container_name: b2bcall-api
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- b2bcall-net
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||
# Sem "ports": só o Nginx (Fase 9) expõe HTTP ao mundo externo. Em dev,
|
||||
# acessar via `docker compose exec` ou publicar temporariamente.
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/api/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 300M
|
||||
|
||||
@@ -133,6 +133,18 @@ Asterisk diretamente.
|
||||
renewal + ownership token) para impedir dois workers controlando a mesma
|
||||
campanha.
|
||||
|
||||
### 3.6.1 ORM / migrations (decisão técnica)
|
||||
|
||||
Escolhido **Prisma** (`packages/database`) em vez de Drizzle/TypeORM: migrations
|
||||
versionadas e testáveis nativamente (`prisma migrate`), schema declarativo
|
||||
único como fonte de verdade, e client tipado que reduz erro humano no domínio
|
||||
RBAC/auditoria (muitas tabelas de relacionamento). Trade-off aceito: o engine
|
||||
binário do Prisma adiciona overhead de build/memória, mitigado por rodar
|
||||
`prisma generate` uma vez por build de imagem (não em runtime) e por ser um
|
||||
ambiente com swap disponível. Consultas de alta performance do motor do
|
||||
discador (ex.: `SELECT ... FOR UPDATE SKIP LOCKED`) usam `$queryRaw` do
|
||||
Prisma em vez de tentar modelar lock otimista via ORM.
|
||||
|
||||
### 3.7 Banco de dados
|
||||
|
||||
- PostgreSQL 17.
|
||||
|
||||
40
infrastructure/docker/api.Dockerfile
Normal file
40
infrastructure/docker/api.Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# apps/api — NestJS (Fastify), monorepo pnpm.
|
||||
FROM node:24-slim AS build
|
||||
|
||||
# Prisma precisa do OpenSSL para detectar corretamente o engine binário a
|
||||
# baixar — a imagem "slim" não vem com ele.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copia primeiro só os manifests para aproveitar cache de layer no `pnpm
|
||||
# install` — reinstalar tudo só quando dependências mudam, não a cada
|
||||
# alteração de código-fonte.
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY apps/api/package.json apps/api/package.json
|
||||
COPY packages/database/package.json packages/database/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/database packages/database
|
||||
COPY apps/api apps/api
|
||||
|
||||
RUN pnpm --filter @b2bcall/shared build \
|
||||
&& pnpm --filter @b2bcall/database build \
|
||||
&& pnpm --filter @b2bcall/api build
|
||||
|
||||
# --- runtime -------------------------------------------------------------
|
||||
FROM node:24-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* \
|
||||
&& groupadd -r b2bcall && useradd -r -g b2bcall b2bcall
|
||||
|
||||
COPY --from=build /app /app
|
||||
USER b2bcall
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/api/dist/main.js"]
|
||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "b2bcall",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "B2BCall - plataforma de discagem preditiva / call center",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"packageManager": "pnpm@11.24.0",
|
||||
"scripts": {
|
||||
"dev:api": "pnpm --filter @b2bcall/api start:dev",
|
||||
"build": "pnpm -r build",
|
||||
"lint": "pnpm -r lint",
|
||||
"test": "pnpm -r test",
|
||||
"db:migrate": "pnpm --filter @b2bcall/database prisma:migrate",
|
||||
"db:seed": "pnpm --filter @b2bcall/database seed"
|
||||
}
|
||||
}
|
||||
24
packages/database/package.json
Normal file
24
packages/database/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@b2bcall/database",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "prisma generate && tsc",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.3",
|
||||
"argon2": "^0.44.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"prisma": "^6.16.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"must_change_password" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_login_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"refresh_token_hash" TEXT NOT NULL,
|
||||
"user_agent" TEXT,
|
||||
"ip_address" TEXT,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"revoked_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "roles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "permissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_roles" (
|
||||
"user_id" TEXT NOT NULL,
|
||||
"role_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("user_id","role_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"role_id" TEXT NOT NULL,
|
||||
"permission_id" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" BIGSERIAL NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"entity_type" TEXT,
|
||||
"entity_id" TEXT,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_entity_type_entity_id_idx" ON "audit_logs"("entity_type", "entity_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
3
packages/database/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
133
packages/database/prisma/schema.prisma
Normal file
133
packages/database/prisma/schema.prisma
Normal file
@@ -0,0 +1,133 @@
|
||||
// Schema do domínio de aplicação do B2BCall. Vive no schema "public" do
|
||||
// Postgres — nunca misturado com as tabelas do Asterisk Realtime (schema
|
||||
// "asterisk", ver infrastructure/postgres/init/002-asterisk-realtime.sql).
|
||||
//
|
||||
// Modelado incrementalmente por fase (ver TODO.md): esta primeira migration
|
||||
// cobre apenas autenticação, RBAC e auditoria (Fase 3). Demais entidades
|
||||
// (agentes, troncos, filas, campanhas, leads, ...) chegam em migrations
|
||||
// subsequentes, nunca alteração manual de schema.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
mustChangePassword Boolean @default(false) @map("must_change_password")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
roles UserRole[]
|
||||
sessions Session[]
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
refreshTokenHash String @map("refresh_token_hash")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
tokenHash String @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("password_reset_tokens")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
description String?
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
users UserRole[]
|
||||
permissions RolePermission[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id String @id @default(uuid())
|
||||
key String @unique
|
||||
description String?
|
||||
|
||||
roles RolePermission[]
|
||||
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
userId String @map("user_id")
|
||||
roleId String @map("role_id")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([userId, roleId])
|
||||
@@map("user_roles")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId String @map("role_id")
|
||||
permissionId String @map("permission_id")
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId String? @map("user_id")
|
||||
action String
|
||||
entityType String? @map("entity_type")
|
||||
entityId String? @map("entity_id")
|
||||
before Json?
|
||||
after Json?
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([entityType, entityId])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
104
packages/database/prisma/seed.ts
Normal file
104
packages/database/prisma/seed.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { writeFileSync, chmodSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import * as argon2 from 'argon2';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PERMISSIONS, DEFAULT_ROLE_PERMISSIONS } from '../../shared/src/permissions';
|
||||
import { generateStrongPassword } from '../../shared/src/generate-password';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Caminho do arquivo mostrado uma única vez com a senha do super_admin
|
||||
// (agente.md seção 70). Nunca versionado (.gitignore).
|
||||
const FIRST_LOGIN_PATH = resolve(__dirname, '../../../FIRST_LOGIN.txt');
|
||||
|
||||
async function seedPermissions() {
|
||||
for (const key of PERMISSIONS) {
|
||||
await prisma.permission.upsert({
|
||||
where: { key },
|
||||
update: {},
|
||||
create: { key },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function seedRoles() {
|
||||
const allPermissions = await prisma.permission.findMany();
|
||||
const permissionByKey = new Map(allPermissions.map((p) => [p.key, p.id]));
|
||||
|
||||
for (const [roleName, permissionKeys] of Object.entries(DEFAULT_ROLE_PERMISSIONS)) {
|
||||
const role = await prisma.role.upsert({
|
||||
where: { name: roleName },
|
||||
update: {},
|
||||
create: { name: roleName, isSystem: true, description: `Perfil padrão: ${roleName}` },
|
||||
});
|
||||
|
||||
for (const key of permissionKeys) {
|
||||
const permissionId = permissionByKey.get(key);
|
||||
if (!permissionId) continue;
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: role.id, permissionId } },
|
||||
update: {},
|
||||
create: { roleId: role.id, permissionId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedSuperAdmin() {
|
||||
const existing = await prisma.user.findFirst({
|
||||
where: { roles: { some: { role: { name: 'super_admin' } } } },
|
||||
});
|
||||
if (existing) {
|
||||
console.log('[seed] super_admin já existe, pulando bootstrap de senha.');
|
||||
return;
|
||||
}
|
||||
|
||||
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { name: 'super_admin' } });
|
||||
const password = generateStrongPassword();
|
||||
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Super Admin',
|
||||
email: 'admin@b2bcall.local',
|
||||
passwordHash,
|
||||
mustChangePassword: true,
|
||||
roles: { create: { roleId: superAdminRole.id } },
|
||||
},
|
||||
});
|
||||
|
||||
const content = [
|
||||
'B2BCall - credenciais do primeiro acesso',
|
||||
'==========================================',
|
||||
'',
|
||||
`Usuário: ${user.email}`,
|
||||
`Senha: ${password}`,
|
||||
'',
|
||||
'Este arquivo é gerado UMA ÚNICA VEZ na primeira instalação.',
|
||||
'A troca de senha será exigida no primeiro login.',
|
||||
'Remova este arquivo do servidor após o primeiro acesso.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
writeFileSync(FIRST_LOGIN_PATH, content, { mode: 0o600 });
|
||||
chmodSync(FIRST_LOGIN_PATH, 0o600);
|
||||
console.log(`[seed] super_admin criado. Credenciais em ${FIRST_LOGIN_PATH}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (existsSync(FIRST_LOGIN_PATH)) {
|
||||
console.log('[seed] FIRST_LOGIN.txt já existe — não sobrescrevendo (evita vazar/perder credencial ativa).');
|
||||
}
|
||||
await seedPermissions();
|
||||
await seedRoles();
|
||||
await seedSuperAdmin();
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
2
packages/database/src/index.ts
Normal file
2
packages/database/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PrismaClient, Prisma } from '@prisma/client';
|
||||
export * from '@prisma/client';
|
||||
14
packages/database/tsconfig.json
Normal file
14
packages/database/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
packages/shared/package.json
Normal file
15
packages/shared/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@b2bcall/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
7
packages/shared/src/generate-password.ts
Normal file
7
packages/shared/src/generate-password.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
// Senha aleatória forte para bootstrap de contas (super_admin, novos
|
||||
// usuários criados por um admin) — nunca senha padrão (agente.md seção 70).
|
||||
export function generateStrongPassword(): string {
|
||||
return randomBytes(24).toString('base64url');
|
||||
}
|
||||
2
packages/shared/src/index.ts
Normal file
2
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './permissions';
|
||||
export * from './generate-password';
|
||||
85
packages/shared/src/permissions.ts
Normal file
85
packages/shared/src/permissions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Catálogo de permissões do RBAC (ver docs/ARCHITECTURE.md e agente.md seção
|
||||
// 11). Fonte única de verdade usada pelo seed (packages/database) e pelos
|
||||
// guards/decorators da API (apps/api). Adicionar uma permissão aqui NUNCA
|
||||
// deve vir acompanhado de "if (user.role === 'admin')" no código — sempre
|
||||
// via checagem de permissão (RolePermission) resolvida no backend.
|
||||
|
||||
export const PERMISSIONS = [
|
||||
'dashboard.view',
|
||||
|
||||
'trunks.view',
|
||||
'trunks.create',
|
||||
'trunks.update',
|
||||
'trunks.delete',
|
||||
|
||||
'extensions.view',
|
||||
'extensions.create',
|
||||
'extensions.update',
|
||||
'extensions.delete',
|
||||
|
||||
'dialplans.view',
|
||||
'dialplans.create',
|
||||
'dialplans.update',
|
||||
'dialplans.delete',
|
||||
|
||||
'queues.view',
|
||||
'queues.create',
|
||||
'queues.update',
|
||||
'queues.delete',
|
||||
|
||||
'agents.view',
|
||||
'agents.create',
|
||||
'agents.update',
|
||||
'agents.delete',
|
||||
|
||||
'campaigns.view',
|
||||
'campaigns.create',
|
||||
'campaigns.start',
|
||||
'campaigns.pause',
|
||||
'campaigns.stop',
|
||||
'campaigns.update',
|
||||
'campaigns.delete',
|
||||
|
||||
'reports.view',
|
||||
'reports.export',
|
||||
|
||||
'monitoring.view',
|
||||
|
||||
'asterisk.view',
|
||||
'asterisk.configure',
|
||||
'asterisk.reload',
|
||||
|
||||
'users.view',
|
||||
'users.create',
|
||||
'users.update',
|
||||
|
||||
'roles.manage',
|
||||
|
||||
'audit.view',
|
||||
|
||||
'settings.manage',
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof PERMISSIONS)[number];
|
||||
|
||||
// Perfis iniciais (seed). Os níveis NÃO são fixos: super_admin pode
|
||||
// reconfigurar quais permissões cada perfil possui pela tela de
|
||||
// "Perfis e Permissões". isSystem apenas impede excluir o perfil em si.
|
||||
export const DEFAULT_ROLE_PERMISSIONS: Record<string, Permission[]> = {
|
||||
super_admin: [...PERMISSIONS],
|
||||
admin: PERMISSIONS.filter((p) => p !== 'roles.manage'),
|
||||
supervisor: [
|
||||
'dashboard.view',
|
||||
'queues.view',
|
||||
'agents.view',
|
||||
'agents.update',
|
||||
'campaigns.view',
|
||||
'campaigns.start',
|
||||
'campaigns.pause',
|
||||
'campaigns.stop',
|
||||
'reports.view',
|
||||
'reports.export',
|
||||
'monitoring.view',
|
||||
],
|
||||
agent: ['dashboard.view'],
|
||||
};
|
||||
14
packages/shared/tsconfig.json
Normal file
14
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2022",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7233
pnpm-lock.yaml
generated
Normal file
7233
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
'@scarf/scarf': false
|
||||
argon2: true
|
||||
prisma: true
|
||||
unrs-resolver: true
|
||||
Reference in New Issue
Block a user