feat: implement authentication and RBAC
- packages/auth: Argon2id password hashing, JWT access tokens (jose), opaque refresh tokens with rotation, generic error messages (no user-enumeration via timing or message differences) - roles/permissions/role_permissions/user_roles/sessions/audit_logs schema (agente.md secoes 142-150); RBAC scope PLATFORM vs TENANT - withUserContext(): narrow RLS exception so a user can discover their own tenant_memberships before a tenant is chosen (login flow) - userHasPermission()/isPlatformUser(): explicit service-layer RBAC checks (roles/permissions tables are not RLS-protected — documented why in docs/AUTHENTICATION.md) - seed: permission catalog, 4 system roles, initial Platform Super Admin (password written once to FIRST_LOGIN.txt, 600, outside Git) - automated end-to-end test: login, RBAC check, refresh rotation, logout
This commit is contained in:
13
TODO.md
13
TODO.md
@@ -29,8 +29,17 @@
|
|||||||
- [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`)
|
- [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`)
|
||||||
|
|
||||||
## PHASE 04 — Authentication / RBAC
|
## PHASE 04 — Authentication / RBAC
|
||||||
- [ ] Login (Argon2id), access/refresh tokens
|
- [x] `packages/auth`: hash Argon2id (`@node-rs/argon2`), JWT access token (`jose`),
|
||||||
- [ ] roles/permissions/user_roles/role_permissions
|
refresh token opaco com rotation
|
||||||
|
- [x] Tabelas `roles`, `permissions`, `role_permissions`, `user_roles`, `sessions`, `audit_logs`
|
||||||
|
- [x] `login()` / `refreshSession()` / `logout()` / `listUserTenants()` / `setActiveTenant()`
|
||||||
|
- [x] `userHasPermission()` (RBAC com scope PLATFORM/TENANT)
|
||||||
|
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
|
||||||
|
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
|
||||||
|
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
|
||||||
|
- [ ] Camada HTTP (endpoints, rate limit por IP, guards) — depende de `apps/api` existir,
|
||||||
|
ver docs/AUTHENTICATION.md → "O que falta"
|
||||||
|
- [ ] Password reset por e-mail — depende de SMTP configurado
|
||||||
|
|
||||||
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,
|
## PHASE 05+ — ver `agente.md` seções 15 em diante (FreeSWITCH, Telefonia, Call Center,
|
||||||
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||||
|
|||||||
88
docs/AUTHENTICATION.md
Normal file
88
docs/AUTHENTICATION.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Autenticação e RBAC
|
||||||
|
|
||||||
|
Implementado em `packages/auth`, sobre o schema criado pela migration
|
||||||
|
`auth_and_rbac` (`packages/database/prisma/schema.prisma`).
|
||||||
|
|
||||||
|
## Login (agente.md secao 148)
|
||||||
|
|
||||||
|
- Senha: Argon2id via `@node-rs/argon2` (binário pré-compilado, sem node-gyp),
|
||||||
|
parâmetros padrão OWASP (memoryCost 19 MiB, timeCost 2, parallelism 1).
|
||||||
|
- `login()` sempre roda `verify()` contra um hash Argon2id fixo mesmo quando o
|
||||||
|
e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`)
|
||||||
|
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
|
||||||
|
de resposta ou mensagem.
|
||||||
|
- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId,
|
||||||
|
tenantId? }`.
|
||||||
|
- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o
|
||||||
|
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
|
||||||
|
é a prova de identidade.
|
||||||
|
- Refresh rotation: cada uso de `refreshSession()` troca o hash guardado na
|
||||||
|
mesma linha de `sessions`; o token anterior para de bater com qualquer hash
|
||||||
|
no banco imediatamente.
|
||||||
|
- Logout / revogação: `sessions.revoked_at` — idempotente.
|
||||||
|
- `mustChangePassword` no `User`: setado `true` no Platform Super Admin
|
||||||
|
inicial (seed); a aplicação (quando existir a camada HTTP) deve forçar troca
|
||||||
|
de senha antes de liberar qualquer outra rota quando essa flag estiver ativa.
|
||||||
|
|
||||||
|
## Resolução de tenant (agente.md secao 31)
|
||||||
|
|
||||||
|
Login não recebe nem decide `tenant_id`. O fluxo é:
|
||||||
|
|
||||||
|
1. `login()` autentica só por e-mail/senha — devolve tokens sem tenant.
|
||||||
|
2. `listUserTenants(userId)` devolve os tenants aos quais o usuário pertence
|
||||||
|
(via `tenant_memberships`), usando `withUserContext` — a única exceção
|
||||||
|
documentada de RLS "olhar os próprios dados sem tenant ainda escolhido"
|
||||||
|
(ver docs/TENANT_ISOLATION.md).
|
||||||
|
3. `setActiveTenant(sessionId, userId, tenantId)` valida a membership de novo
|
||||||
|
(nunca confia em `tenantId` vindo do cliente sem checar) e emite um novo
|
||||||
|
access token já com `tenantId` nas claims.
|
||||||
|
|
||||||
|
## RBAC (agente.md secoes 142-145)
|
||||||
|
|
||||||
|
Tabelas: `roles`, `permissions`, `role_permissions`, `user_roles`.
|
||||||
|
|
||||||
|
- `Role.scope`: `PLATFORM` (vale em qualquer tenant, `UserRole.tenantId =
|
||||||
|
null`) ou `TENANT` (vale só no tenant especificado em `UserRole.tenantId`).
|
||||||
|
- `userHasPermission(userId, permissionKey, tenantId?)`: junta roles PLATFORM
|
||||||
|
do usuário com as roles TENANT do tenant informado, e checa se alguma delas
|
||||||
|
carrega a permission. Chamado explicitamente pela camada de serviço — **não
|
||||||
|
depende de RLS** (ver próxima seção).
|
||||||
|
- Seed (`packages/auth/src/seed.ts`, agente.md secao 200) cria o catálogo de
|
||||||
|
permissions e 4 roles de sistema: `platform_super_admin` (PLATFORM, todas as
|
||||||
|
permissions), `tenant_admin`, `supervisor`, `agent` (TENANT, mapeamento
|
||||||
|
inicial documentado no próprio seed — ajustar quando existir UI de RBAC).
|
||||||
|
|
||||||
|
## Por que roles/permissions/user_roles/sessions/audit_logs NÃO têm RLS
|
||||||
|
|
||||||
|
Diferente das tabelas de negócio tenant-scoped (extensions, agents, campaigns,
|
||||||
|
calls, ...), essas tabelas são infraestrutura de autenticação/autorização,
|
||||||
|
tocadas exclusivamente pelo código confiável de `packages/auth`, que já
|
||||||
|
resolve o filtro de tenant explicitamente em cada query (`userHasPermission`,
|
||||||
|
`setActiveTenant`, etc.). Isso é a camada "RBAC + object authorization" da
|
||||||
|
defesa em profundidade da seção 32 do `agente.md` — complementar à RLS, não
|
||||||
|
substituída por ela. Se no futuro essas tabelas passarem a ser expostas por
|
||||||
|
queries genéricas (ex.: um endpoint de admin que aceita filtros arbitrários),
|
||||||
|
revisitar essa decisão e considerar RLS ali também.
|
||||||
|
|
||||||
|
## Platform Super Admin inicial (agente.md secao 199)
|
||||||
|
|
||||||
|
O seed cria `admin@b2bcall.local` com senha aleatória de 24 bytes, salva uma
|
||||||
|
única vez em `FIRST_LOGIN.txt` (permissão 600, fora do Git) com
|
||||||
|
`mustChangePassword = true`. Rodar de novo o seed não recria o admin se já
|
||||||
|
existir um usuário com role `platform_super_admin`.
|
||||||
|
|
||||||
|
## O que falta (fica para quando existir `apps/api`)
|
||||||
|
|
||||||
|
Tudo abaixo depende de uma camada HTTP (NestJS/Fastify) que ainda não existe:
|
||||||
|
|
||||||
|
- Rate limiting de login por IP/usuário (agente.md secao 149) — plugin
|
||||||
|
`@fastify/rate-limit` ou equivalente, não implementável em `packages/auth`
|
||||||
|
isoladamente.
|
||||||
|
- Endpoints REST (`POST /auth/login`, `/auth/refresh`, `/auth/logout`,
|
||||||
|
`/auth/select-tenant`) e guards HTTP que traduzem `InvalidCredentialsError`/
|
||||||
|
`NotATenantMemberError` em 401/403.
|
||||||
|
- Password reset (link expirável por e-mail) — precisa de um provedor de
|
||||||
|
e-mail/SMTP, fora do escopo desta fase.
|
||||||
|
- Reuse detection de refresh token roubado (família de tokens) — não
|
||||||
|
implementado; a rotação simples (secao 148) já está feita, a detecção de
|
||||||
|
reuso é um hardening adicional a avaliar depois.
|
||||||
21
packages/auth/package.json
Normal file
21
packages/auth/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@b2bcall/auth",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"types": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"seed": "tsx src/seed.ts",
|
||||||
|
"test:auth": "tsx src/__tests__/auth.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@b2bcall/database": "workspace:*",
|
||||||
|
"@node-rs/argon2": "^2.1.0",
|
||||||
|
"jose": "^6.2.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
packages/auth/src/__tests__/auth.test.ts
Normal file
110
packages/auth/src/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Verificação end-to-end do fluxo de autenticação (agente.md secao 148):
|
||||||
|
* login, refresh rotation, logout/revogação, e checagem de RBAC.
|
||||||
|
*
|
||||||
|
* Roda com: pnpm --filter @b2bcall/auth run test:auth
|
||||||
|
*/
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { hashPassword } from "../password";
|
||||||
|
import {
|
||||||
|
InvalidCredentialsError,
|
||||||
|
InvalidRefreshTokenError,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshSession,
|
||||||
|
} from "../session";
|
||||||
|
import { userHasPermission } from "../permissions";
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): void {
|
||||||
|
if (!condition) {
|
||||||
|
throw new Error(`FALHOU: ${message}`);
|
||||||
|
}
|
||||||
|
console.log(`OK: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertThrows(fn: () => Promise<unknown>, ErrorClass: new (...a: any[]) => Error, message: string) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
throw new Error(`FALHOU: ${message} (nao lancou erro)`);
|
||||||
|
} catch (err) {
|
||||||
|
assert(err instanceof ErrorClass, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const suffix = randomUUID().slice(0, 8);
|
||||||
|
const email = `auth-test-${suffix}@test.local`;
|
||||||
|
const password = "S3nhaForteDeTeste!123";
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: { code: `auth-test-${suffix}`, slug: `auth-test-${suffix}`, legalName: "Auth Test LTDA" },
|
||||||
|
});
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email, passwordHash: await hashPassword(password), name: "Auth Test User" },
|
||||||
|
});
|
||||||
|
const agentRole = await prisma.role.findUniqueOrThrow({ where: { key: "agent" } });
|
||||||
|
await prisma.userRole.create({
|
||||||
|
data: { userId: user.id, roleId: agentRole.id, tenantId: tenant.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login com senha errada / e-mail inexistente: mesmo erro genérico.
|
||||||
|
await assertThrows(
|
||||||
|
() => login({ email, password: "senha-errada" }),
|
||||||
|
InvalidCredentialsError,
|
||||||
|
"Senha errada rejeitada com erro genérico",
|
||||||
|
);
|
||||||
|
await assertThrows(
|
||||||
|
() => login({ email: `nao-existe-${suffix}@test.local`, password }),
|
||||||
|
InvalidCredentialsError,
|
||||||
|
"E-mail inexistente rejeitado com o MESMO erro genérico (sem user-enumeration)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Login correto.
|
||||||
|
const result = await login({ email, password, ipAddress: "127.0.0.1", userAgent: "test-agent" });
|
||||||
|
assert(!result.mustChangePassword, "Usuário de teste não precisa trocar senha");
|
||||||
|
assert(Boolean(result.accessToken) && Boolean(result.refreshToken), "Login retorna access e refresh token");
|
||||||
|
|
||||||
|
// RBAC: agent tem dashboard.view, não tem tenants.manage.
|
||||||
|
assert(
|
||||||
|
await userHasPermission(user.id, "dashboard.view", tenant.id),
|
||||||
|
"Agent tem permissão dashboard.view no próprio tenant",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!(await userHasPermission(user.id, "tenants.manage", tenant.id)),
|
||||||
|
"Agent NÃO tem permissão tenants.manage",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh rotation: token antigo morre assim que um novo é emitido.
|
||||||
|
const refreshed = await refreshSession(result.refreshToken);
|
||||||
|
assert(Boolean(refreshed.accessToken), "Refresh emite novo access token");
|
||||||
|
assert(refreshed.refreshToken !== result.refreshToken, "Refresh token rotacionado é diferente do anterior");
|
||||||
|
await assertThrows(
|
||||||
|
() => refreshSession(result.refreshToken),
|
||||||
|
InvalidRefreshTokenError,
|
||||||
|
"Refresh token antigo (já rotacionado) não funciona mais",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Logout revoga a sessão — refresh subsequente falha.
|
||||||
|
await logout(result.sessionId);
|
||||||
|
await assertThrows(
|
||||||
|
() => refreshSession(refreshed.refreshToken),
|
||||||
|
InvalidRefreshTokenError,
|
||||||
|
"Refresh token de sessão revogada (logout) não funciona",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Limpeza.
|
||||||
|
await prisma.userRole.deleteMany({ where: { userId: user.id } });
|
||||||
|
await prisma.session.deleteMany({ where: { userId: user.id } });
|
||||||
|
await prisma.user.delete({ where: { id: user.id } });
|
||||||
|
await prisma.tenant.delete({ where: { id: tenant.id } });
|
||||||
|
|
||||||
|
console.log("\nFluxo de autenticação OK: login, RBAC, refresh rotation e logout funcionam.");
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
29
packages/auth/src/audit.ts
Normal file
29
packages/auth/src/audit.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@b2bcall/database";
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
action: string;
|
||||||
|
tenantId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
before?: Prisma.InputJsonValue;
|
||||||
|
after?: Prisma.InputJsonValue;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordAuditEvent(prisma: PrismaClient, event: AuditEvent): Promise<void> {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
action: event.action,
|
||||||
|
tenantId: event.tenantId ?? null,
|
||||||
|
userId: event.userId ?? null,
|
||||||
|
entityType: event.entityType,
|
||||||
|
entityId: event.entityId,
|
||||||
|
before: event.before,
|
||||||
|
after: event.after,
|
||||||
|
ipAddress: event.ipAddress,
|
||||||
|
userAgent: event.userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
5
packages/auth/src/index.ts
Normal file
5
packages/auth/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./password";
|
||||||
|
export * from "./tokens";
|
||||||
|
export * from "./audit";
|
||||||
|
export * from "./session";
|
||||||
|
export * from "./permissions";
|
||||||
17
packages/auth/src/password.ts
Normal file
17
packages/auth/src/password.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Algorithm, hash, verify } from "@node-rs/argon2";
|
||||||
|
|
||||||
|
// OWASP-recommended minimums for Argon2id (agente.md secao 148: Argon2id).
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
algorithm: Algorithm.Argon2id,
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hashPassword(plainPassword: string): Promise<string> {
|
||||||
|
return hash(plainPassword, ARGON2_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(passwordHash: string, plainPassword: string): Promise<boolean> {
|
||||||
|
return verify(passwordHash, plainPassword);
|
||||||
|
}
|
||||||
40
packages/auth/src/permissions.ts
Normal file
40
packages/auth/src/permissions.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se `userId` tem a permissão `permissionKey`, considerando roles
|
||||||
|
* PLATFORM (tenantId nulo, valem em qualquer contexto) e roles TENANT
|
||||||
|
* atribuídas especificamente em `tenantId` (agente.md secao 144: role scope
|
||||||
|
* PLATFORM ou TENANT — nunca escopo global acidental).
|
||||||
|
*
|
||||||
|
* Não usa RLS (ver comentário na migration auth_and_rbac): a checagem é
|
||||||
|
* feita explicitamente aqui, na camada de serviço confiável.
|
||||||
|
*/
|
||||||
|
export async function userHasPermission(
|
||||||
|
userId: string,
|
||||||
|
permissionKey: string,
|
||||||
|
tenantId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const where = tenantId
|
||||||
|
? { userId, OR: [{ tenantId }, { tenantId: null }] }
|
||||||
|
: { userId, tenantId: null };
|
||||||
|
|
||||||
|
const userRoles = await prisma.userRole.findMany({
|
||||||
|
where,
|
||||||
|
include: { role: { include: { rolePermissions: { include: { permission: true } } } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return userRoles.some((userRole) =>
|
||||||
|
userRole.role.rolePermissions.some((rp) => rp.permission.key === permissionKey),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atalho: usuário tem QUALQUER role com scope PLATFORM (ex.: platform_super_admin). */
|
||||||
|
export async function isPlatformUser(userId: string): Promise<boolean> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const count = await prisma.userRole.count({
|
||||||
|
where: { userId, tenantId: null, role: { scope: "PLATFORM" } },
|
||||||
|
});
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
172
packages/auth/src/seed.ts
Normal file
172
packages/auth/src/seed.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Seed de RBAC (agente.md secoes 142, 145, 199, 200): catálogo de
|
||||||
|
* permissions, roles de sistema, e o Platform Super Admin inicial.
|
||||||
|
*
|
||||||
|
* Idempotente — seguro rodar de novo (upsert por chave única). Roda com:
|
||||||
|
* pnpm --filter @b2bcall/auth run seed
|
||||||
|
*/
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { writeFileSync, chmodSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { getPrismaClient } from "@b2bcall/database";
|
||||||
|
import { hashPassword } from "./password";
|
||||||
|
|
||||||
|
// Catálogo completo de permissions (agente.md secao 145).
|
||||||
|
const PERMISSIONS: Array<{ key: string; description: string }> = [
|
||||||
|
{ key: "tenants.view", description: "Ver tenants (plataforma)" },
|
||||||
|
{ key: "tenants.manage", description: "Criar/editar/suspender tenants" },
|
||||||
|
{ key: "billing.view", description: "Ver consumo e faturas" },
|
||||||
|
{ key: "billing.manage", description: "Gerenciar fechamentos de billing" },
|
||||||
|
{ key: "pricing.manage", description: "Gerenciar planos e price books" },
|
||||||
|
{ key: "dashboard.view", description: "Ver dashboards" },
|
||||||
|
{ key: "extensions.view", description: "Ver ramais" },
|
||||||
|
{ key: "extensions.manage", description: "Criar/editar ramais" },
|
||||||
|
{ key: "trunks.view", description: "Ver troncos" },
|
||||||
|
{ key: "trunks.manage", description: "Criar/editar troncos" },
|
||||||
|
{ key: "agents.view", description: "Ver agentes" },
|
||||||
|
{ key: "agents.manage", description: "Criar/editar agentes" },
|
||||||
|
{ key: "queues.view", description: "Ver filas" },
|
||||||
|
{ key: "queues.manage", description: "Criar/editar filas" },
|
||||||
|
{ key: "campaigns.view", description: "Ver campanhas" },
|
||||||
|
{ key: "campaigns.create", description: "Criar campanhas" },
|
||||||
|
{ key: "campaigns.update", description: "Editar campanhas" },
|
||||||
|
{ key: "campaigns.start", description: "Iniciar campanhas" },
|
||||||
|
{ key: "campaigns.pause", description: "Pausar campanhas" },
|
||||||
|
{ key: "campaigns.stop", description: "Parar campanhas" },
|
||||||
|
{ key: "monitoring.view", description: "Ver monitoramento em tempo real" },
|
||||||
|
{ key: "reports.view", description: "Ver relatórios" },
|
||||||
|
{ key: "reports.export", description: "Exportar relatórios" },
|
||||||
|
{ key: "recordings.view", description: "Ver gravações" },
|
||||||
|
{ key: "recordings.download", description: "Baixar gravações" },
|
||||||
|
{ key: "ai.view", description: "Ver análises de IA" },
|
||||||
|
{ key: "ai.manage", description: "Configurar providers/prompts de IA" },
|
||||||
|
{ key: "ai.analyze", description: "Disparar análise de IA manualmente" },
|
||||||
|
{ key: "freeswitch.view", description: "Ver estado do FreeSWITCH" },
|
||||||
|
{ key: "freeswitch.configure", description: "Configurar FreeSWITCH" },
|
||||||
|
{ key: "users.manage", description: "Gerenciar usuários" },
|
||||||
|
{ key: "roles.manage", description: "Gerenciar roles/permissões" },
|
||||||
|
{ key: "audit.view", description: "Ver audit log" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mapeamento inicial role -> permissions. Ponto de partida razoável;
|
||||||
|
// revisar quando existir uma UI de administração de RBAC.
|
||||||
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
platform_super_admin: PERMISSIONS.map((p) => p.key), // tudo
|
||||||
|
tenant_admin: PERMISSIONS.map((p) => p.key).filter(
|
||||||
|
(key) => !["tenants.view", "tenants.manage", "pricing.manage"].includes(key),
|
||||||
|
),
|
||||||
|
supervisor: [
|
||||||
|
"dashboard.view",
|
||||||
|
"extensions.view",
|
||||||
|
"trunks.view",
|
||||||
|
"agents.view",
|
||||||
|
"agents.manage",
|
||||||
|
"queues.view",
|
||||||
|
"campaigns.view",
|
||||||
|
"campaigns.update",
|
||||||
|
"campaigns.start",
|
||||||
|
"campaigns.pause",
|
||||||
|
"campaigns.stop",
|
||||||
|
"monitoring.view",
|
||||||
|
"reports.view",
|
||||||
|
"reports.export",
|
||||||
|
"recordings.view",
|
||||||
|
"recordings.download",
|
||||||
|
"ai.view",
|
||||||
|
],
|
||||||
|
agent: ["dashboard.view", "campaigns.view"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYSTEM_ROLES: Array<{ key: string; name: string; scope: "PLATFORM" | "TENANT" }> = [
|
||||||
|
{ key: "platform_super_admin", name: "Platform Super Admin", scope: "PLATFORM" },
|
||||||
|
{ key: "tenant_admin", name: "Tenant Admin", scope: "TENANT" },
|
||||||
|
{ key: "supervisor", name: "Supervisor", scope: "TENANT" },
|
||||||
|
{ key: "agent", name: "Agente", scope: "TENANT" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLATFORM_ADMIN_EMAIL = "admin@b2bcall.local";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
for (const permission of PERMISSIONS) {
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { key: permission.key },
|
||||||
|
update: { description: permission.description },
|
||||||
|
create: permission,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Permissions: ${PERMISSIONS.length} sincronizadas.`);
|
||||||
|
|
||||||
|
for (const role of SYSTEM_ROLES) {
|
||||||
|
const created = await prisma.role.upsert({
|
||||||
|
where: { key: role.key },
|
||||||
|
update: { name: role.name, scope: role.scope, isSystem: true },
|
||||||
|
create: { ...role, isSystem: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const permissionKeys = ROLE_PERMISSIONS[role.key] ?? [];
|
||||||
|
const permissions = await prisma.permission.findMany({
|
||||||
|
where: { key: { in: permissionKeys } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
|
||||||
|
if (permissions.length > 0) {
|
||||||
|
await prisma.rolePermission.createMany({
|
||||||
|
data: permissions.map((p) => ({ roleId: created.id, permissionId: p.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Role '${role.key}': ${permissions.length} permissions.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPlatformAdmin = await prisma.userRole.findFirst({
|
||||||
|
where: { tenantId: null, role: { key: "platform_super_admin" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingPlatformAdmin) {
|
||||||
|
console.log("Platform Super Admin já existe, pulando criação.");
|
||||||
|
} else {
|
||||||
|
const platformSuperAdminRole = await prisma.role.findUniqueOrThrow({
|
||||||
|
where: { key: "platform_super_admin" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialPassword = randomBytes(18).toString("base64url");
|
||||||
|
const passwordHash = await hashPassword(initialPassword);
|
||||||
|
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: PLATFORM_ADMIN_EMAIL },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: PLATFORM_ADMIN_EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
name: "Platform Super Admin",
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.userRole.create({
|
||||||
|
data: { userId: admin.id, roleId: platformSuperAdminRole.id, tenantId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstLoginPath = resolve(__dirname, "../../../FIRST_LOGIN.txt");
|
||||||
|
writeFileSync(
|
||||||
|
firstLoginPath,
|
||||||
|
`B2BCall — Platform Super Admin (gerado em ${new Date().toISOString()})\n` +
|
||||||
|
`Email: ${PLATFORM_ADMIN_EMAIL}\n` +
|
||||||
|
`Senha: ${initialPassword}\n\n` +
|
||||||
|
`Troca de senha OBRIGATÓRIA no primeiro login. Apague este arquivo depois de guardar a senha em um local seguro.\n`,
|
||||||
|
);
|
||||||
|
chmodSync(firstLoginPath, 0o600);
|
||||||
|
|
||||||
|
console.log(`Platform Super Admin criado: ${PLATFORM_ADMIN_EMAIL}`);
|
||||||
|
console.log(`Senha salva em ${firstLoginPath} (permissao 600) — nao sera exibida no terminal.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
187
packages/auth/src/session.ts
Normal file
187
packages/auth/src/session.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { getPrismaClient, withUserContext } from "@b2bcall/database";
|
||||||
|
import { recordAuditEvent } from "./audit";
|
||||||
|
import { verifyPassword } from "./password";
|
||||||
|
import {
|
||||||
|
REFRESH_TOKEN_TTL_MS,
|
||||||
|
generateRefreshToken,
|
||||||
|
hashRefreshToken,
|
||||||
|
signAccessToken,
|
||||||
|
} from "./tokens";
|
||||||
|
|
||||||
|
// Hash Argon2id de uma senha aleatória fixa, usado só para manter o tempo de
|
||||||
|
// resposta do login constante quando o e-mail não existe — evita
|
||||||
|
// user-enumeration via timing attack. Nunca corresponde a uma senha real.
|
||||||
|
const DUMMY_PASSWORD_HASH =
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$ue+qGHKnsPX6ducjF4h61Q$wRqupd7dHkad21UhvTxLuv8bDqNr63dZmuydRkjohr0";
|
||||||
|
|
||||||
|
export class InvalidCredentialsError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("E-mail ou senha invalidos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidRefreshTokenError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Refresh token invalido, expirado ou revogado");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotATenantMemberError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Usuario nao pertence a este tenant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResult {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
sessionId: string;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login por e-mail/senha (agente.md secao 148). Sempre roda verify() de
|
||||||
|
* Argon2id mesmo quando o e-mail não existe (contra timing attack), e nunca
|
||||||
|
* revela via mensagem se foi o e-mail ou a senha que estava errada.
|
||||||
|
*/
|
||||||
|
export async function login(params: LoginParams): Promise<AuthResult> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const email = params.email.trim().toLowerCase();
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
|
const passwordOk = await verifyPassword(user?.passwordHash ?? DUMMY_PASSWORD_HASH, params.password);
|
||||||
|
const isUsable = Boolean(user) && !user!.deletedAt && user!.status === "ACTIVE";
|
||||||
|
|
||||||
|
if (!user || !passwordOk || !isUsable) {
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "LOGIN_FAILED",
|
||||||
|
userId: user?.id,
|
||||||
|
ipAddress: params.ipAddress,
|
||||||
|
userAgent: params.userAgent,
|
||||||
|
after: { email },
|
||||||
|
});
|
||||||
|
throw new InvalidCredentialsError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = generateRefreshToken();
|
||||||
|
const session = await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
refreshTokenHash: hashRefreshToken(refreshToken),
|
||||||
|
userAgent: params.userAgent,
|
||||||
|
ipAddress: params.ipAddress,
|
||||||
|
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = await signAccessToken({ sub: user.id, sessionId: session.id });
|
||||||
|
|
||||||
|
await recordAuditEvent(prisma, {
|
||||||
|
action: "LOGIN",
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: params.ipAddress,
|
||||||
|
userAgent: params.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
sessionId: session.id,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotaciona o refresh token (agente.md secao 148: "refresh rotation"). O
|
||||||
|
* token antigo para de funcionar assim que um novo é emitido — a linha de
|
||||||
|
* `sessions` é a mesma, só o hash e a expiração mudam.
|
||||||
|
*/
|
||||||
|
export async function refreshSession(
|
||||||
|
refreshToken: string,
|
||||||
|
): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { refreshTokenHash: hashRefreshToken(refreshToken) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.revokedAt || session.expiresAt.getTime() < Date.now()) {
|
||||||
|
throw new InvalidRefreshTokenError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRefreshToken = generateRefreshToken();
|
||||||
|
await prisma.session.update({
|
||||||
|
where: { id: session.id },
|
||||||
|
data: {
|
||||||
|
refreshTokenHash: hashRefreshToken(newRefreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = await signAccessToken({
|
||||||
|
sub: session.userId,
|
||||||
|
sessionId: session.id,
|
||||||
|
tenantId: session.activeTenantId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { accessToken, refreshToken: newRefreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoga a sessão (logout / revogação administrativa). Idempotente. */
|
||||||
|
export async function logout(sessionId: string): Promise<void> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
await prisma.session.updateMany({
|
||||||
|
where: { id: sessionId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista os tenants aos quais o usuário pertence — precisa do contexto
|
||||||
|
* especial de RLS "olhar as próprias memberships" (withUserContext), já que
|
||||||
|
* ainda não existe um tenant escolhido nesse ponto do fluxo (ver
|
||||||
|
* docs/AUTHENTICATION.md e docs/TENANT_ISOLATION.md).
|
||||||
|
*/
|
||||||
|
export async function listUserTenants(userId: string) {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
return withUserContext(prisma, userId, (tx) =>
|
||||||
|
tx.tenantMembership.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { tenant: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Troca o tenant ativo de uma sessão já autenticada, validando a membership
|
||||||
|
* antes de emitir um novo access token com `tenantId` nas claims.
|
||||||
|
*/
|
||||||
|
export async function setActiveTenant(
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
tenantId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const prisma = getPrismaClient();
|
||||||
|
|
||||||
|
const membership = await withUserContext(prisma, userId, (tx) =>
|
||||||
|
tx.tenantMembership.findUnique({
|
||||||
|
where: { tenantId_userId: { tenantId, userId } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!membership) {
|
||||||
|
throw new NotATenantMemberError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await prisma.session.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: { activeTenantId: tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return signAccessToken({ sub: userId, sessionId: session.id, tenantId: session.activeTenantId! });
|
||||||
|
}
|
||||||
48
packages/auth/src/tokens.ts
Normal file
48
packages/auth/src/tokens.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_TTL = "15m";
|
||||||
|
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
|
||||||
|
|
||||||
|
export interface AccessTokenClaims extends JWTPayload {
|
||||||
|
sub: string; // userId
|
||||||
|
sessionId: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccessTokenSecret(): Uint8Array {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error("JWT_SECRET nao configurado");
|
||||||
|
}
|
||||||
|
return new TextEncoder().encode(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signAccessToken(
|
||||||
|
claims: Omit<AccessTokenClaims, "iat" | "exp">,
|
||||||
|
): Promise<string> {
|
||||||
|
return new SignJWT(claims)
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(ACCESS_TOKEN_TTL)
|
||||||
|
.sign(getAccessTokenSecret());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAccessToken(token: string): Promise<AccessTokenClaims> {
|
||||||
|
const { payload } = await jwtVerify(token, getAccessTokenSecret());
|
||||||
|
return payload as AccessTokenClaims;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh tokens são strings opacas de alta entropia — nunca JWT, nunca
|
||||||
|
* guardadas em texto puro. O que fica em `sessions.refresh_token_hash` é o
|
||||||
|
* SHA-256 do token; posse do token original é a prova de identidade, então
|
||||||
|
* hash reverso não compromete a sessão.
|
||||||
|
*/
|
||||||
|
export function generateRefreshToken(): string {
|
||||||
|
return randomBytes(32).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashRefreshToken(token: string): string {
|
||||||
|
return createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
8
packages/auth/tsconfig.json
Normal file
8
packages/auth/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "role_scope" AS ENUM ('PLATFORM', 'TENANT');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "must_change_password" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "roles" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"scope" "role_scope" NOT NULL,
|
||||||
|
"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" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "role_permissions" (
|
||||||
|
"role_id" UUID NOT NULL,
|
||||||
|
"permission_id" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "user_roles" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"role_id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "sessions" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"refresh_token_hash" TEXT NOT NULL,
|
||||||
|
"active_tenant_id" UUID,
|
||||||
|
"user_agent" TEXT,
|
||||||
|
"ip_address" TEXT,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
"revoked_at" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "audit_logs" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"tenant_id" UUID,
|
||||||
|
"user_id" UUID,
|
||||||
|
"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 "roles_key_key" ON "roles"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "permissions_key_key" ON "permissions"("key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "user_roles_user_id_role_id_tenant_id_key" ON "user_roles"("user_id", "role_id", "tenant_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_logs_tenant_id_created_at_idx" ON "audit_logs"("tenant_id", "created_at");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs"("user_id");
|
||||||
|
|
||||||
|
-- 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 "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 "user_roles" ADD CONSTRAINT "user_roles_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- The login flow needs to discover which tenants a user belongs to BEFORE a
|
||||||
|
-- tenant has been chosen (chicken-and-egg: RLS normally requires
|
||||||
|
-- app.current_tenant_id to already be set). Extend the tenant_isolation
|
||||||
|
-- policy so a session can also see its OWN memberships via
|
||||||
|
-- app.current_user_id, without ever exposing other users' memberships or
|
||||||
|
-- other tenants' data. See docs/AUTHENTICATION.md.
|
||||||
|
DROP POLICY "tenant_isolation" ON "tenant_memberships";
|
||||||
|
|
||||||
|
CREATE POLICY "tenant_isolation" ON "tenant_memberships"
|
||||||
|
USING (
|
||||||
|
tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
|
||||||
|
OR user_id = NULLIF(current_setting('app.current_user_id', true), '')::uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
-- roles / permissions / role_permissions / user_roles / sessions / audit_logs
|
||||||
|
-- are intentionally NOT protected by RLS. Unlike tenant business data
|
||||||
|
-- (extensions, agents, campaigns, calls, ...), these are auth-internal
|
||||||
|
-- tables touched only by the trusted packages/auth service layer, which
|
||||||
|
-- applies its own explicit WHERE clauses and RBAC checks (defense-in-depth
|
||||||
|
-- per agente.md secao 32: RBAC + object authorization + tenant repositories
|
||||||
|
-- + RLS are complementary layers, not all mandatory for every table).
|
||||||
@@ -33,6 +33,7 @@ model Tenant {
|
|||||||
deletedAt DateTime? @map("deleted_at")
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
memberships TenantMembership[]
|
memberships TenantMembership[]
|
||||||
|
userRoles UserRole[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -47,20 +48,124 @@ enum UserStatus {
|
|||||||
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
|
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
|
||||||
// é sempre resolvido via TenantMembership (agente.md secao 31).
|
// é sempre resolvido via TenantMembership (agente.md secao 31).
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
email String @unique
|
email String @unique
|
||||||
passwordHash String @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
name String
|
name String
|
||||||
status UserStatus @default(ACTIVE)
|
status UserStatus @default(ACTIVE)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
mustChangePassword Boolean @default(false) @map("must_change_password")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
deletedAt DateTime? @map("deleted_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
deletedAt DateTime? @map("deleted_at")
|
||||||
|
|
||||||
memberships TenantMembership[]
|
memberships TenantMembership[]
|
||||||
|
userRoles UserRole[]
|
||||||
|
sessions Session[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RoleScope {
|
||||||
|
PLATFORM
|
||||||
|
TENANT
|
||||||
|
|
||||||
|
@@map("role_scope")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definições de role. Roles de sistema (isSystem=true) são criadas pelo seed
|
||||||
|
// (agente.md secao 142) e não podem ser removidas via API.
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
key String @unique
|
||||||
|
name String
|
||||||
|
scope RoleScope
|
||||||
|
isSystem Boolean @default(false) @map("is_system")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
userRoles UserRole[]
|
||||||
|
|
||||||
|
@@map("roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
key String @unique
|
||||||
|
description String?
|
||||||
|
|
||||||
|
rolePermissions RolePermission[]
|
||||||
|
|
||||||
|
@@map("permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RolePermission {
|
||||||
|
roleId String @map("role_id") @db.Uuid
|
||||||
|
permissionId String @map("permission_id") @db.Uuid
|
||||||
|
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([roleId, permissionId])
|
||||||
|
@@map("role_permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantId é obrigatório quando role.scope == TENANT e deve ser nulo quando
|
||||||
|
// role.scope == PLATFORM (invariante aplicado em packages/auth, não no banco —
|
||||||
|
// ver docs/AUTHENTICATION.md).
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
roleId String @map("role_id") @db.Uuid
|
||||||
|
tenantId String? @map("tenant_id") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, roleId, tenantId])
|
||||||
|
@@map("user_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh-token de longa duração (rotacionado a cada uso). Nunca guardamos o
|
||||||
|
// token em texto puro — só o hash (SHA-256) usado para lookup/comparação.
|
||||||
|
model Session {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
refreshTokenHash String @unique @map("refresh_token_hash")
|
||||||
|
activeTenantId String? @map("active_tenant_id") @db.Uuid
|
||||||
|
userAgent String? @map("user_agent")
|
||||||
|
ipAddress String? @map("ip_address")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
expiresAt DateTime @map("expires_at")
|
||||||
|
revokedAt DateTime? @map("revoked_at")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenantId nulo = evento de escopo plataforma (agente.md secao 150).
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
tenantId String? @map("tenant_id") @db.Uuid
|
||||||
|
userId String? @map("user_id") @db.Uuid
|
||||||
|
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")
|
||||||
|
|
||||||
|
@@index([tenantId, createdAt])
|
||||||
|
@@index([userId])
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
|
|
||||||
// Tabela tenant-scoped protegida por Row Level Security (ver migration
|
// Tabela tenant-scoped protegida por Row Level Security (ver migration
|
||||||
// 'tenant_isolation' e docs/TENANT_ISOLATION.md).
|
// 'tenant_isolation' e docs/TENANT_ISOLATION.md).
|
||||||
model TenantMembership {
|
model TenantMembership {
|
||||||
|
|||||||
@@ -41,3 +41,22 @@ export async function withTenantContext<T>(
|
|||||||
return fn(tx);
|
return fn(tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like withTenantContext, but sets `app.current_user_id` instead of a
|
||||||
|
* tenant. Used only during login/tenant-resolution, when the caller needs to
|
||||||
|
* see a user's OWN tenant_memberships before any tenant has been chosen. The
|
||||||
|
* RLS policy on tenant_memberships allows this narrow self-lookup without
|
||||||
|
* exposing other users' memberships or other tenants' data (see
|
||||||
|
* docs/AUTHENTICATION.md).
|
||||||
|
*/
|
||||||
|
export async function withUserContext<T>(
|
||||||
|
client: PrismaClient,
|
||||||
|
userId: string,
|
||||||
|
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
return client.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||||
|
return fn(tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
165
pnpm-lock.yaml
generated
165
pnpm-lock.yaml
generated
@@ -12,6 +12,25 @@ importers:
|
|||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/auth:
|
||||||
|
dependencies:
|
||||||
|
'@b2bcall/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../database
|
||||||
|
'@node-rs/argon2':
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.0
|
||||||
|
jose:
|
||||||
|
specifier: ^6.2.10
|
||||||
|
version: 6.2.10
|
||||||
|
devDependencies:
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.23.12
|
||||||
|
version: 4.23.12
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/database:
|
packages/database:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/adapter-pg':
|
'@prisma/adapter-pg':
|
||||||
@@ -239,6 +258,92 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
||||||
|
resolution: {integrity: sha512-hdWo5kb4eFRbHjdu4O6dVlRPI/CR1vbkJpe3Z9kF2s0Kp42428wg8AxI+8Cv4mygdW309BpUBf3sZaqXBNpdpw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@node-rs/argon2-android-arm64@2.1.0':
|
||||||
|
resolution: {integrity: sha512-MHby1n9UzlVma3GiEYWNNqRhn2Z/90QwF2PFckGwucdz0HfdVX+lSKEcyfwcbvS5sJ9GAzhop3hXlUPxUYe72Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@node-rs/argon2-darwin-arm64@2.1.0':
|
||||||
|
resolution: {integrity: sha512-5nayvL9wepOvTO5JmdaiDZ6bgHEsI/vksnXphM3UTy/cflARr9MoD1pBrMPs921hx2otURJhG0avg9hN0Q0KPw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@node-rs/argon2-darwin-x64@2.1.0':
|
||||||
|
resolution: {integrity: sha512-7bFf3qH/PLQ5kU5GkxMF+dMz/tiT+0yFXILpOx4fgSFjw3wdXpGZt3wH4i6dHMURDcwYlNap0hJaX4h2E3JGng==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@node-rs/argon2-freebsd-x64@2.1.0':
|
||||||
|
resolution: {integrity: sha512-01mVdl+7bjSW0to5xcEzueq7BuYbz3476/dmLMXziJf8fhykzr723YRNcGjOcHSU5fxNrElY9uVeUoow16vBrQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm-gnueabihf@2.1.0':
|
||||||
|
resolution: {integrity: sha512-NxZILF+Hqav6yzJTUFuZXTUC1VagTO4Vfd2HSlzkwankUa1B7goo0NLY2tUPw6MdqtLojkqVG5MZ26AaF49tHw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm64-gnu@2.1.0':
|
||||||
|
resolution: {integrity: sha512-jpx2VwJLyAF/cM64HRigFBk67GwtQBcssYyVhvHqfmCEZz/CP//SV9bxE+uJOGIPKJo7wDg/5f2k3JY0PNMROg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm64-musl@2.1.0':
|
||||||
|
resolution: {integrity: sha512-xXAaPUlnPx/44qwaSekTG/g6/h5QCyrTayPLmvewJNyYz17vyBRmFKWLgv9Q3+NJwSvV88nVnCu/xkQbEPQaog==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-x64-gnu@2.1.0':
|
||||||
|
resolution: {integrity: sha512-ICnfyFaxZzr8OdAEwTPsAjx6gpNla1sI3miZJWAVCtZlw98Wo3Wd4H7tvW6S2NRJ1BIOf4l8Z7aX7h3fimXn5w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-x64-musl@2.1.0':
|
||||||
|
resolution: {integrity: sha512-f56jehb/IPTGBWWrvMjDGZWlQm/hKkH2VK/MxJ9u3kUR9PCacdtLFZmX4sL+qKeo4wsyhlD1EvlZF6nV1FSi3Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-arm64-msvc@2.1.0':
|
||||||
|
resolution: {integrity: sha512-KH8lBdjoZLUiVG1GjJQIQPKhmddJmEbJbL+e3S7BKVq1wKl1mx+ClmhGtMtYp8ZtEIw8/BYIFUIjL3sBuWGEuQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-ia32-msvc@2.1.0':
|
||||||
|
resolution: {integrity: sha512-zZ7NDHoKTgrjoUehNN7xvP+dA/M9krOp42l0MKns6TnMkwrp5nes9agEZsUy+0t0JoR8OQ8D7EUCNLh5BEn++w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-x64-msvc@2.1.0':
|
||||||
|
resolution: {integrity: sha512-JN/yNiX7u8Cw9XXTbRrjnlgzFjCiEQe6x97wJnMIsJ3vty89q0SAg504Otd9qy2ofQS2F5CCWvrOXhm/oyHaAQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@node-rs/argon2@2.1.0':
|
||||||
|
resolution: {integrity: sha512-VBOWfM2u58/to3DFqTGJ2U5cJKQwmjN2zxzsQNZ5a2o8Z6aUrhvqQh8NdgotIF1Y0tMsBNtzOBDBdfvvkwJDSQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@prisma/adapter-pg@7.10.0':
|
'@prisma/adapter-pg@7.10.0':
|
||||||
resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==}
|
resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==}
|
||||||
|
|
||||||
@@ -654,6 +759,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.2.10:
|
||||||
|
resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
|
||||||
|
|
||||||
json-schema-traverse@1.0.0:
|
json-schema-traverse@1.0.0:
|
||||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
@@ -988,6 +1096,61 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.28.2':
|
'@esbuild/win32-x64@0.28.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-android-arm-eabi@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-android-arm64@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-darwin-arm64@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-darwin-x64@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-freebsd-x64@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm-gnueabihf@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm64-gnu@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-arm64-musl@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-x64-gnu@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-linux-x64-musl@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-arm64-msvc@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-ia32-msvc@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2-win32-x64-msvc@2.1.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@node-rs/argon2@2.1.0':
|
||||||
|
optionalDependencies:
|
||||||
|
'@node-rs/argon2-android-arm-eabi': 2.1.0
|
||||||
|
'@node-rs/argon2-android-arm64': 2.1.0
|
||||||
|
'@node-rs/argon2-darwin-arm64': 2.1.0
|
||||||
|
'@node-rs/argon2-darwin-x64': 2.1.0
|
||||||
|
'@node-rs/argon2-freebsd-x64': 2.1.0
|
||||||
|
'@node-rs/argon2-linux-arm-gnueabihf': 2.1.0
|
||||||
|
'@node-rs/argon2-linux-arm64-gnu': 2.1.0
|
||||||
|
'@node-rs/argon2-linux-arm64-musl': 2.1.0
|
||||||
|
'@node-rs/argon2-linux-x64-gnu': 2.1.0
|
||||||
|
'@node-rs/argon2-linux-x64-musl': 2.1.0
|
||||||
|
'@node-rs/argon2-win32-arm64-msvc': 2.1.0
|
||||||
|
'@node-rs/argon2-win32-ia32-msvc': 2.1.0
|
||||||
|
'@node-rs/argon2-win32-x64-msvc': 2.1.0
|
||||||
|
|
||||||
'@prisma/adapter-pg@7.10.0':
|
'@prisma/adapter-pg@7.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/driver-adapter-utils': 7.10.0
|
'@prisma/driver-adapter-utils': 7.10.0
|
||||||
@@ -1475,6 +1638,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jose@6.2.10: {}
|
||||||
|
|
||||||
json-schema-traverse@1.0.0: {}
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user