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:
@@ -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";
|
||||
|
||||
65
packages/database/src/repositories/audit.ts
Normal file
65
packages/database/src/repositories/audit.ts
Normal 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,
|
||||
],
|
||||
);
|
||||
}
|
||||
163
packages/database/src/repositories/companies.ts
Normal file
163
packages/database/src/repositories/companies.ts
Normal 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;
|
||||
}
|
||||
20
packages/database/src/repositories/roles.ts
Normal file
20
packages/database/src/repositories/roles.ts
Normal 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;
|
||||
}
|
||||
84
packages/database/src/repositories/sessions.ts
Normal file
84
packages/database/src/repositories/sessions.ts
Normal 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;
|
||||
}
|
||||
53
packages/database/src/repositories/types.ts
Normal file
53
packages/database/src/repositories/types.ts
Normal 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;
|
||||
}
|
||||
80
packages/database/src/repositories/users.ts
Normal file
80
packages/database/src/repositories/users.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user