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:
2026-08-28 05:58:49 -03:00
parent d66170c795
commit 70c5586595
16 changed files with 1172 additions and 10 deletions

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

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

View File

@@ -0,0 +1,5 @@
export * from "./password";
export * from "./tokens";
export * from "./audit";
export * from "./session";
export * from "./permissions";

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

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

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

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