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_NAME=B2BCall
|
||||||
APP_URL=http://10.10.32.142
|
APP_URL=http://10.10.32.142
|
||||||
TZ=America/Sao_Paulo
|
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 é
|
# Modo de simulação do discador: quando true, NENHUMA chamada externa real é
|
||||||
# originada (usado em dev/testes). Nunca deixar true em produção.
|
# 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.
|
ODBC — decisão adequada, não precisa dos módulos pgsql nativos.
|
||||||
|
|
||||||
## Fase 3 — Backend base
|
## Fase 3 — Backend base
|
||||||
- [ ] apps/api (NestJS + Fastify) bootstrap
|
- [x] apps/api (NestJS 11 + Fastify) bootstrap — containerizado, healthy
|
||||||
- [ ] Autenticação (Argon2id, access+refresh, cookies HttpOnly)
|
- [x] packages/database (Prisma) + packages/shared (catálogo de permissões)
|
||||||
- [ ] Rate limiting (login e endpoints sensíveis)
|
— decisão de ORM documentada em docs/ARCHITECTURE.md 3.6.1
|
||||||
- [ ] RBAC (users/roles/permissions/user_roles/role_permissions) + tela de perfis
|
- [x] Migration inicial aplicada (users/sessions/roles/permissions/
|
||||||
- [ ] Auditoria (audit_logs) + interceptor genérico
|
user_roles/role_permissions/audit_logs/password_reset_tokens)
|
||||||
- [ ] Health checks /api/health(/live|/ready)
|
- [x] Seed (40 permissões, 4 perfis, bootstrap super_admin com senha
|
||||||
- [ ] Swagger/OpenAPI (desabilitável em produção)
|
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)
|
## Fase 4 — Telefonia (camada de aplicação)
|
||||||
- [ ] packages/telephony: TelephonyProvider + AsteriskTelephonyProvider
|
- [ ] 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:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 400M
|
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
|
renewal + ownership token) para impedir dois workers controlando a mesma
|
||||||
campanha.
|
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
|
### 3.7 Banco de dados
|
||||||
|
|
||||||
- PostgreSQL 17.
|
- 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