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

@@ -10,7 +10,11 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "echo 'no linter configured yet'"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,50 @@
import { z } from "zod";
/**
* Fail fast on boot if a required secret/config value is missing — same
* philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks (eden.md §5.6).
* Never fall back to an insecure default for a secret.
*/
export const envSchema = z.object({
DATABASE_URL: z.string().min(1, "DATABASE_URL é obrigatório"),
EDEN_API_PORT: z.coerce.number().int().positive().default(8080),
JWT_SIGNING_SECRET: z.string().min(32, "JWT_SIGNING_SECRET precisa ter ao menos 32 caracteres"),
SIGNATURE_OTP_SECRET: z.string().min(32).optional(),
EDEN_FIELD_ENCRYPTION_KEY: z
.string()
.min(32, "EDEN_FIELD_ENCRYPTION_KEY precisa ter ao menos 32 caracteres")
.optional(),
EDEN_SUPERADMIN_EMAIL: z.string().email().optional(),
EDEN_SUPERADMIN_PASSWORD: z.string().min(8).optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().int().positive().default(465),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_REGION: z.string().default("us-east-1"),
S3_FORCE_PATH_STYLE: z
.string()
.default("true")
.transform((v) => v === "true"),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_BUCKET: z.string().optional(),
PUBLIC_BASE_URL: z.string().optional(),
});
export type EdenEnv = z.infer<typeof envSchema>;
export function loadEnv(source: NodeJS.ProcessEnv = process.env): EdenEnv {
const result = envSchema.safeParse(source);
if (!result.success) {
const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
throw new Error(`Configuração de ambiente inválida:\n${issues}`);
}
return result.data;
}

View File

@@ -1,6 +1 @@
/**
* Shared env-var schema/validation (fail fast on boot if a required var is
* missing — same philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks,
* eden.md §5.6). Placeholder until apps/api's config module needs it.
*/
export const CONFIG_PACKAGE_VERSION = "0.1.0";
export { envSchema, loadEnv, type EdenEnv } from "./env-schema.js";

View File

@@ -0,0 +1,54 @@
/**
* Organization bounded context, first slice: companies (legal entities
* that issue contracts/documents) — Master Prompt §6.1. Shape ported
* faithfully from the legacy (eden.md §1.4), since this is validated,
* production-tested data. Warehouses/branches/cost centers land in a
* later migration once Inventory/Finance need them — Master Prompt §6.1
* explicitly warns against leaving `companies` isolated forever, but
* that linkage is real work for those later phases, not invented now.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
CREATE TABLE companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
company_name TEXT NOT NULL,
trade_name TEXT,
cnpj TEXT,
state_registration TEXT,
municipal_registration TEXT,
address_zip TEXT,
address_street TEXT,
address_number TEXT,
address_complement TEXT,
address_neighborhood TEXT,
address_city TEXT,
address_state TEXT,
address_country TEXT NOT NULL DEFAULT 'Brasil',
phone TEXT,
email TEXT,
website TEXT,
legal_rep_name TEXT,
legal_rep_cpf TEXT,
legal_rep_role TEXT,
legal_rep_email TEXT,
is_active BOOLEAN NOT NULL DEFAULT true
);
COMMENT ON TABLE companies IS
'Empresa(s) emissora(s) de contrato/documento — eden.md §1.4. Leitura aberta a qualquer autenticado (não sensível), escrita restrita à permissão companies:manage.';
CREATE TRIGGER trg_companies_updated_at BEFORE UPDATE ON companies
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_companies_active ON companies(is_active);
`);
};
exports.down = (pgm) => {
pgm.sql(`DROP TABLE IF EXISTS companies;`);
};

View File

@@ -17,6 +17,7 @@
"pg": "^8.13.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/pg": "^8.11.10",
"node-pg-migrate": "^7.9.1",
"typescript": "^5.7.3"

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

View File

@@ -1,7 +1,2 @@
/**
* Domain types/constants shared across bounded contexts (e.g. the
* resource/action/scope permission vocabulary from
* docs/adr/0004-permission-model.md). Placeholder until a second
* bounded context needs to share a type with the first.
*/
export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0";
export { onlyDigits, isValidCPF, isValidCNPJ } from "./validators/br-documents.js";

View File

@@ -0,0 +1,49 @@
/**
* CPF/CNPJ format validation (digit-check only — never confirms a document
* actually exists or belongs to whoever claims it). Ported from the
* standard Brazilian algorithm, matching the legacy's isValidCPF/
* isValidCNPJ (eden.md §1 companies, §3 client registration).
*/
export function onlyDigits(value: string): string {
return value.replace(/\D/g, "");
}
export function isValidCPF(value: string): boolean {
const cpf = onlyDigits(value);
if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false;
const digits = cpf.split("").map(Number);
const checkDigit = (length: number): number => {
let sum = 0;
for (let i = 0; i < length; i++) {
sum += digits[i]! * (length + 1 - i);
}
const remainder = (sum * 10) % 11;
return remainder === 10 ? 0 : remainder;
};
return checkDigit(9) === digits[9] && checkDigit(10) === digits[10];
}
export function isValidCNPJ(value: string): boolean {
const cnpj = onlyDigits(value);
if (cnpj.length !== 14 || /^(\d)\1{13}$/.test(cnpj)) return false;
const digits = cnpj.split("").map(Number);
// Standard CNPJ weight sequences — the second is the first with a
// leading 6, not a generic slice (easy to get subtly wrong otherwise).
const WEIGHTS_12 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const WEIGHTS_13 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2];
const checkDigit = (length: number, weights: number[]): number => {
let sum = 0;
for (let i = 0; i < length; i++) {
sum += digits[i]! * weights[i]!;
}
const remainder = sum % 11;
return remainder < 2 ? 0 : 11 - remainder;
};
return (
checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13]
);
}