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