Auth completo (Argon2id + JWT/refresh rotativo), permissões, Companies e OpenAPI

- Auth (apps/api/src/auth/): Argon2id com parâmetros versionados
  (ADR-0005), access token JWT de 15min, refresh token opaco de 256 bits
  com rotação e detecção de replay (reuso de token já rotacionado revoga
  toda a cadeia de sessões do usuário). Endpoints /api/v1/auth/
  {login,refresh,logout,me}.
- Permissões resource+action+scope (ADR-0004) via PermissionGuard,
  resolvidas do banco a cada request (nunca cacheadas no JWT) — mudança
  de papel tem efeito imediato. super_admin com bypass hardcoded,
  espelhando a trava já existente no banco.
- Companies (migration + CRUD): leitura aberta a qualquer autenticado,
  escrita restrita a companies:manage; validação de CNPJ via
  packages/domain-shared (dígito verificador).
- SeedService: cria o primeiro super_admin a partir de
  EDEN_SUPERADMIN_EMAIL/PASSWORD só quando `users` está vazia.
- OpenAPI/Swagger em /api/docs; ValidationPipe global com
  whitelist+forbidNonWhitelisted (proteção contra mass-assignment).
- packages/database: repositórios (roles/users/sessions/audit/companies)
  reutilizáveis por api e worker.

Validado manualmente contra a stack real (não só typecheck): login com
senha errada → 401 genérico; refresh rotaciona e invalida o token
anterior; reuso do token antigo → replay detectado, cadeia revogada;
logout idempotente; audit log com a sequência completa de eventos;
CNPJ inválido rejeitado; mass-assignment rejeitado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:25:48 -03:00
parent 44510bd019
commit 8d81cf506b
42 changed files with 1804 additions and 33 deletions

View File

@@ -1 +1,8 @@
export { query, withTransaction, closePool } from "./pool.js";
export * as rolesRepo from "./repositories/roles.js";
export * as usersRepo from "./repositories/users.js";
export * as sessionsRepo from "./repositories/sessions.js";
export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.js";
export type * from "./repositories/types.js";
export type { Company } from "./repositories/companies.js";

View File

@@ -0,0 +1,65 @@
import { createHash } from "node:crypto";
import { query } from "../pool.js";
export interface AuditEventInput {
actorType: "user" | "system" | "service";
actorId?: string | null;
action: string;
resourceType: string;
resourceId?: string | null;
metadata?: Record<string, unknown>;
ipAddress?: string | null;
}
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
}
/**
* Appends one event to the audit_log hash chain. Never call this inside a
* larger transaction that might roll back for unrelated reasons without
* considering that the chain must stay contiguous — a rolled-back event
* never happened, which is fine (nothing was recorded), but a *committed*
* event must never be skipped when computing previous_event_hash for the
* next one. See .claude/skills/eden-security/references/audit.md.
*/
export async function appendAuditEvent(input: AuditEventInput): Promise<void> {
const { rows } = await query<{ event_hash: string }>(
"SELECT event_hash FROM audit_log ORDER BY seq DESC LIMIT 1",
);
const previousEventHash = rows[0]?.event_hash ?? "";
const fields = {
actorType: input.actorType,
actorId: input.actorId ?? null,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId ?? null,
metadata: input.metadata ?? {},
ipAddress: input.ipAddress ?? null,
};
const eventHash = createHash("sha256")
.update(previousEventHash + canonicalJson(fields))
.digest("hex");
await query(
`INSERT INTO audit_log
(actor_type, actor_id, action, resource_type, resource_id, metadata, ip_address, previous_event_hash, event_hash)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
input.actorType,
input.actorId ?? null,
input.action,
input.resourceType,
input.resourceId ?? null,
JSON.stringify(input.metadata ?? {}),
input.ipAddress ?? null,
previousEventHash || null,
eventHash,
],
);
}

View File

@@ -0,0 +1,163 @@
import { query } from "../pool.js";
export interface Company {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
company_name: string;
trade_name: string | null;
cnpj: string | null;
state_registration: string | null;
municipal_registration: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
phone: string | null;
email: string | null;
website: string | null;
legal_rep_name: string | null;
legal_rep_cpf: string | null;
legal_rep_role: string | null;
legal_rep_email: string | null;
is_active: boolean;
}
export async function listCompanies(includeInactive = false): Promise<Company[]> {
const { rows } = await query<Company>(
includeInactive
? "SELECT * FROM companies ORDER BY created_at ASC"
: "SELECT * FROM companies WHERE is_active = true ORDER BY created_at ASC",
);
return rows;
}
export async function getCompanyById(id: string): Promise<Company | null> {
const { rows } = await query<Company>("SELECT * FROM companies WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateCompanyInput {
companyName: string;
tradeName?: string | null;
cnpj?: string | null;
stateRegistration?: string | null;
municipalRegistration?: string | null;
addressZip?: string | null;
addressStreet?: string | null;
addressNumber?: string | null;
addressComplement?: string | null;
addressNeighborhood?: string | null;
addressCity?: string | null;
addressState?: string | null;
addressCountry?: string;
phone?: string | null;
email?: string | null;
website?: string | null;
legalRepName?: string | null;
legalRepCpf?: string | null;
legalRepRole?: string | null;
legalRepEmail?: string | null;
createdBy?: string | null;
}
export async function createCompany(input: CreateCompanyInput): Promise<Company> {
const { rows } = await query<Company>(
`INSERT INTO companies (
company_name, trade_name, cnpj, state_registration, municipal_registration,
address_zip, address_street, address_number, address_complement, address_neighborhood,
address_city, address_state, address_country, phone, email, website,
legal_rep_name, legal_rep_cpf, legal_rep_role, legal_rep_email, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,'Brasil'),$14,$15,$16,$17,$18,$19,$20,$21)
RETURNING *`,
[
input.companyName,
input.tradeName ?? null,
input.cnpj ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.phone ?? null,
input.email ?? null,
input.website ?? null,
input.legalRepName ?? null,
input.legalRepCpf ?? null,
input.legalRepRole ?? null,
input.legalRepEmail ?? null,
input.createdBy ?? null,
],
);
const company = rows[0];
if (!company) throw new Error("failed to create company");
return company;
}
export type UpdateCompanyInput = Partial<Omit<CreateCompanyInput, "createdBy">> & {
isActive?: boolean;
};
export async function updateCompany(id: string, input: UpdateCompanyInput): Promise<Company | null> {
const { rows } = await query<Company>(
`UPDATE companies SET
company_name = COALESCE($2, company_name),
trade_name = COALESCE($3, trade_name),
cnpj = COALESCE($4, cnpj),
state_registration = COALESCE($5, state_registration),
municipal_registration = COALESCE($6, municipal_registration),
address_zip = COALESCE($7, address_zip),
address_street = COALESCE($8, address_street),
address_number = COALESCE($9, address_number),
address_complement = COALESCE($10, address_complement),
address_neighborhood = COALESCE($11, address_neighborhood),
address_city = COALESCE($12, address_city),
address_state = COALESCE($13, address_state),
address_country = COALESCE($14, address_country),
phone = COALESCE($15, phone),
email = COALESCE($16, email),
website = COALESCE($17, website),
legal_rep_name = COALESCE($18, legal_rep_name),
legal_rep_cpf = COALESCE($19, legal_rep_cpf),
legal_rep_role = COALESCE($20, legal_rep_role),
legal_rep_email = COALESCE($21, legal_rep_email),
is_active = COALESCE($22, is_active)
WHERE id = $1
RETURNING *`,
[
id,
input.companyName ?? null,
input.tradeName ?? null,
input.cnpj ?? null,
input.stateRegistration ?? null,
input.municipalRegistration ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.phone ?? null,
input.email ?? null,
input.website ?? null,
input.legalRepName ?? null,
input.legalRepCpf ?? null,
input.legalRepRole ?? null,
input.legalRepEmail ?? null,
input.isActive ?? null,
],
);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,20 @@
import { query } from "../pool.js";
import type { Role, RolePermission } from "./types.js";
export async function getRoleByKey(key: string): Promise<Role | null> {
const { rows } = await query<Role>("SELECT * FROM roles WHERE key = $1", [key]);
return rows[0] ?? null;
}
export async function listRoles(): Promise<Role[]> {
const { rows } = await query<Role>("SELECT * FROM roles ORDER BY weight ASC");
return rows;
}
export async function getRolePermissions(roleKey: string): Promise<RolePermission[]> {
const { rows } = await query<RolePermission>(
"SELECT * FROM role_permissions WHERE role_key = $1",
[roleKey],
);
return rows;
}

View File

@@ -0,0 +1,84 @@
import { query } from "../pool.js";
import type { Session } from "./types.js";
export interface CreateSessionInput {
userId: string;
applicationKey: "core" | "reseller" | "subscriber";
refreshTokenHash: string;
expiresAt: Date;
ipAddress?: string | null;
userAgent?: string | null;
replacesSessionId?: string | null;
}
export async function createSession(input: CreateSessionInput): Promise<Session> {
const { rows } = await query<Session>(
`INSERT INTO sessions (user_id, application_key, refresh_token_hash, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
input.userId,
input.applicationKey,
input.refreshTokenHash,
input.expiresAt,
input.ipAddress ?? null,
input.userAgent ?? null,
],
);
const session = rows[0];
if (!session) throw new Error("failed to create session");
if (input.replacesSessionId) {
await query("UPDATE sessions SET replaced_by_session_id = $1, revoked_at = now() WHERE id = $2", [
session.id,
input.replacesSessionId,
]);
}
return session;
}
export async function findActiveSessionByRefreshTokenHash(hash: string): Promise<Session | null> {
const { rows } = await query<Session>(
`SELECT * FROM sessions
WHERE refresh_token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`,
[hash],
);
return rows[0] ?? null;
}
/**
* Finds a session by refresh token hash regardless of revoked/expired state —
* used for replay detection: a rotated (already-revoked) token being
* reused is a strong signal of a stolen refresh token (ADR-0003).
*/
export async function findSessionByRefreshTokenHashAnyState(hash: string): Promise<Session | null> {
const { rows } = await query<Session>("SELECT * FROM sessions WHERE refresh_token_hash = $1", [
hash,
]);
return rows[0] ?? null;
}
export async function touchSession(id: string): Promise<void> {
await query("UPDATE sessions SET last_used_at = now() WHERE id = $1", [id]);
}
export async function revokeSession(id: string): Promise<void> {
await query("UPDATE sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", [id]);
}
/** Revokes every active session of a user — "encerrar todas as sessões" (Master Prompt §5.3). */
export async function revokeAllUserSessions(userId: string): Promise<void> {
await query("UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", [
userId,
]);
}
export async function listActiveSessionsForUser(userId: string): Promise<Session[]> {
const { rows } = await query<Session>(
`SELECT * FROM sessions WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now()
ORDER BY created_at DESC`,
[userId],
);
return rows;
}

View File

@@ -0,0 +1,53 @@
export interface Role {
key: string;
label: string;
is_system: boolean;
weight: number;
created_at: Date;
updated_at: Date;
created_by: string | null;
}
export interface RolePermission {
id: string;
role_key: string;
resource: string;
action: "view" | "create" | "edit" | "delete" | "approve" | "export" | "manage";
scope: "own" | "team" | "reseller" | "legal_entity" | "all";
}
export interface User {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
full_name: string | null;
email: string;
password_hash: string | null;
password_algo_version: number;
role_key: string;
is_active: boolean;
reset_token_hash: string | null;
reset_token_expires_at: Date | null;
mfa_enabled: boolean;
mfa_secret_encrypted: string | null;
}
export interface Session {
id: string;
user_id: string;
application_key: "core" | "reseller" | "subscriber";
refresh_token_hash: string;
created_at: Date;
last_used_at: Date | null;
expires_at: Date;
revoked_at: Date | null;
replaced_by_session_id: string | null;
ip_address: string | null;
user_agent: string | null;
}
export interface Application {
key: "core" | "reseller" | "subscriber";
label: string;
}

View File

@@ -0,0 +1,80 @@
import { query } from "../pool.js";
import type { User } from "./types.js";
export async function countUsers(): Promise<number> {
const { rows } = await query<{ count: string }>("SELECT count(*) FROM users");
return Number(rows[0]?.count ?? 0);
}
export async function findUserByEmail(email: string): Promise<User | null> {
const { rows } = await query<User>(
"SELECT * FROM users WHERE lower(email) = lower($1) AND is_active = true",
[email],
);
return rows[0] ?? null;
}
export async function findUserById(id: string): Promise<User | null> {
const { rows } = await query<User>("SELECT * FROM users WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateUserInput {
email: string;
passwordHash: string;
passwordAlgoVersion: number;
roleKey: string;
fullName?: string;
createdBy?: string | null;
}
export async function createUser(input: CreateUserInput): Promise<User> {
const { rows } = await query<User>(
`INSERT INTO users (email, password_hash, password_algo_version, role_key, full_name, created_by)
VALUES (lower($1), $2, $3, $4, $5, $6)
RETURNING *`,
[
input.email,
input.passwordHash,
input.passwordAlgoVersion,
input.roleKey,
input.fullName ?? null,
input.createdBy ?? null,
],
);
const user = rows[0];
if (!user) throw new Error("failed to create user");
return user;
}
export async function updateUserPassword(
userId: string,
passwordHash: string,
passwordAlgoVersion: number,
): Promise<void> {
await query("UPDATE users SET password_hash = $1, password_algo_version = $2 WHERE id = $3", [
passwordHash,
passwordAlgoVersion,
userId,
]);
}
export async function grantUserApplication(
userId: string,
applicationKey: "core" | "reseller" | "subscriber",
): Promise<void> {
await query(
`INSERT INTO user_applications (user_id, application_key)
VALUES ($1, $2)
ON CONFLICT (user_id, application_key) DO NOTHING`,
[userId, applicationKey],
);
}
export async function getUserApplications(userId: string): Promise<string[]> {
const { rows } = await query<{ application_key: string }>(
"SELECT application_key FROM user_applications WHERE user_id = $1",
[userId],
);
return rows.map((r) => r.application_key);
}