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,9 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminusModule } from "@nestjs/terminus";
|
||||
import { HealthController } from "./health/health.controller";
|
||||
import { HealthController } from "./health/health.controller.js";
|
||||
import { ConfigModule } from "./config/config.module.js";
|
||||
import { AuthModule } from "./auth/auth.module.js";
|
||||
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
|
||||
import { CompaniesModule } from "./companies/companies.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
imports: [ConfigModule, TerminusModule, AuthModule, BootstrapModule, CompaniesModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
47
apps/api/src/auth/auth.controller.ts
Normal file
47
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import type { Request } from "express";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { LoginDto } from "./dto/login.dto.js";
|
||||
import { RefreshDto } from "./dto/refresh.dto.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
|
||||
|
||||
function requestContext(req: Request) {
|
||||
return {
|
||||
ipAddress: req.ip ?? null,
|
||||
userAgent: req.headers["user-agent"] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller("v1/auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("login")
|
||||
@HttpCode(200)
|
||||
async login(@Body() dto: LoginDto, @Req() req: Request) {
|
||||
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@HttpCode(200)
|
||||
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
return this.auth.refresh(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@HttpCode(204)
|
||||
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
|
||||
await this.auth.logout(dto.refreshToken, requestContext(req));
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async me(@CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.auth.getProfile(user.id);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/auth/auth.module.ts
Normal file
14
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthController } from "./auth.controller.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { PasswordService } from "./password.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { JwtAuthGuard } from "./jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "./permission.guard.js";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
||||
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
181
apps/api/src/auth/auth.service.ts
Normal file
181
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
|
||||
import { PasswordService } from "./password.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { resolvePermissions } from "./permissions.js";
|
||||
|
||||
export interface RequestContext {
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
refreshToken: string;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
}
|
||||
|
||||
function toPublicUser(user: User): PublicUser {
|
||||
return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key };
|
||||
}
|
||||
|
||||
const GENERIC_LOGIN_ERROR = "Credenciais inválidas";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly passwords: PasswordService,
|
||||
private readonly tokens: TokenService,
|
||||
) {}
|
||||
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
ctx: RequestContext,
|
||||
): Promise<AuthResult> {
|
||||
const user = await usersRepo.findUserByEmail(email);
|
||||
|
||||
// Same generic error whether the account doesn't exist or the password
|
||||
// is wrong — anti-enumeration, mirrors eden.md §4.1.
|
||||
if (!user || !user.password_hash) {
|
||||
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
|
||||
}
|
||||
|
||||
const valid = await this.passwords.verify(user.password_hash, password);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
|
||||
}
|
||||
|
||||
if (this.passwords.needsRehash(user.password_algo_version)) {
|
||||
const { hash, algoVersion } = await this.passwords.hash(password);
|
||||
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
|
||||
}
|
||||
|
||||
const grantedApps = await usersRepo.getUserApplications(user.id);
|
||||
if (!grantedApps.includes(applicationKey)) {
|
||||
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
|
||||
}
|
||||
|
||||
const result = await this.issueTokens(user, applicationKey, ctx);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.login",
|
||||
resourceType: "user",
|
||||
resourceId: user.id,
|
||||
metadata: { applicationKey },
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
|
||||
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
|
||||
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
|
||||
|
||||
if (!active) {
|
||||
// Replay detection: a token that exists but is revoked/expired being
|
||||
// reused is a signal the refresh token chain may be compromised —
|
||||
// revoke every session of that user defensively (ADR-0003).
|
||||
const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash);
|
||||
if (anyState?.revoked_at) {
|
||||
await sessionsRepo.revokeAllUserSessions(anyState.user_id);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "system",
|
||||
actorId: anyState.user_id,
|
||||
action: "auth.refresh_replay_detected",
|
||||
resourceType: "session",
|
||||
resourceId: anyState.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
throw new UnauthorizedException("Sessão inválida ou expirada");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(active.user_id);
|
||||
if (!user || !user.is_active) {
|
||||
throw new UnauthorizedException("Sessão inválida ou expirada");
|
||||
}
|
||||
|
||||
await sessionsRepo.touchSession(active.id);
|
||||
const result = await this.issueTokens(user, active.application_key, ctx, active.id);
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: user.id,
|
||||
action: "auth.refresh",
|
||||
resourceType: "session",
|
||||
resourceId: active.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise<void> {
|
||||
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
|
||||
const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
|
||||
if (!session) return; // idempotent — already logged out
|
||||
await sessionsRepo.revokeSession(session.id);
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId: session.user_id,
|
||||
action: "auth.logout",
|
||||
resourceType: "session",
|
||||
resourceId: session.id,
|
||||
ipAddress: ctx.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfile(userId: string) {
|
||||
const user = await usersRepo.findUserById(userId);
|
||||
if (!user || !user.is_active) throw new UnauthorizedException();
|
||||
const permissions = await resolvePermissions(user.role_key);
|
||||
const applications = await usersRepo.getUserApplications(user.id);
|
||||
return {
|
||||
user: toPublicUser(user),
|
||||
permissions: permissions.superAdmin
|
||||
? ("ALL" as const)
|
||||
: permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })),
|
||||
applications,
|
||||
};
|
||||
}
|
||||
|
||||
private async issueTokens(
|
||||
user: User,
|
||||
applicationKey: "core" | "reseller" | "subscriber",
|
||||
ctx: RequestContext,
|
||||
replacesSessionId?: string,
|
||||
): Promise<AuthResult> {
|
||||
const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey });
|
||||
const refresh = this.tokens.generateRefreshToken();
|
||||
|
||||
await sessionsRepo.createSession({
|
||||
userId: user.id,
|
||||
applicationKey,
|
||||
refreshTokenHash: refresh.hash,
|
||||
expiresAt: refresh.expiresAt,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
replacesSessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: access.token,
|
||||
expiresIn: access.expiresIn,
|
||||
refreshToken: refresh.plaintext,
|
||||
user: toPublicUser(user),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
7
apps/api/src/auth/decorators/current-user.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest<Request>();
|
||||
return request.edenUser;
|
||||
});
|
||||
19
apps/api/src/auth/decorators/require-permission.decorator.ts
Normal file
19
apps/api/src/auth/decorators/require-permission.decorator.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
import type { RolePermission } from "@eden/database";
|
||||
|
||||
export const REQUIRE_PERMISSION_KEY = "eden:requirePermission";
|
||||
|
||||
export interface RequiredPermission {
|
||||
resource: string;
|
||||
action: RolePermission["action"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares that an endpoint needs (resource, action) per ADR-0004.
|
||||
* super_admin always passes. Confirms the actor holds *some* scope for
|
||||
* this resource/action — row-level filtering by the granted scope (own
|
||||
* vs. all, etc.) is the responsibility of the service/query layer, which
|
||||
* reads it off `request.edenUser.permissions` (see permissions.ts).
|
||||
*/
|
||||
export const RequirePermission = (resource: string, action: RequiredPermission["action"]) =>
|
||||
SetMetadata(REQUIRE_PERMISSION_KEY, { resource, action } satisfies RequiredPermission);
|
||||
13
apps/api/src/auth/dto/login.dto.ts
Normal file
13
apps/api/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsIn, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
|
||||
@IsIn(["core", "reseller", "subscriber"])
|
||||
applicationKey!: "core" | "reseller" | "subscriber";
|
||||
}
|
||||
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
7
apps/api/src/auth/dto/refresh.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from "class-validator";
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
@MinLength(32)
|
||||
refreshToken!: string;
|
||||
}
|
||||
65
apps/api/src/auth/jwt-auth.guard.ts
Normal file
65
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import type { Request } from "express";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import { resolvePermissions, type ResolvedPermissions } from "./permissions.js";
|
||||
|
||||
export interface EdenAuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
roleKey: string;
|
||||
applicationKey: "core" | "reseller" | "subscriber";
|
||||
permissions: ResolvedPermissions;
|
||||
}
|
||||
|
||||
declare module "express" {
|
||||
interface Request {
|
||||
edenUser?: EdenAuthenticatedUser;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the access token's signature/expiry, then re-resolves the user
|
||||
* and their permissions from the database on every request — deliberately
|
||||
* not cached in the JWT payload, so a role/permission change (or account
|
||||
* deactivation) takes effect immediately rather than waiting out the
|
||||
* token's 15-minute lifetime. Mirrors the legacy's LEFT JOIN-on-every-
|
||||
* request philosophy (eden.md §1, §3.3).
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly tokens: TokenService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException("Token de acesso ausente");
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = this.tokens.verifyAccessToken(header.slice("Bearer ".length));
|
||||
} catch {
|
||||
throw new UnauthorizedException("Token de acesso inválido ou expirado");
|
||||
}
|
||||
|
||||
const user = await usersRepo.findUserById(payload.sub);
|
||||
if (!user || !user.is_active) {
|
||||
throw new UnauthorizedException("Usuário inativo ou inexistente");
|
||||
}
|
||||
|
||||
const permissions = await resolvePermissions(user.role_key);
|
||||
|
||||
request.edenUser = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.full_name,
|
||||
roleKey: user.role_key,
|
||||
applicationKey: payload.app,
|
||||
permissions,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
44
apps/api/src/auth/password.service.ts
Normal file
44
apps/api/src/auth/password.service.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import * as argon2 from "argon2";
|
||||
|
||||
/**
|
||||
* Argon2id password hashing with versioned parameters (ADR-0005). Bumping
|
||||
* CURRENT_ALGO_VERSION and its entry in PARAMS_BY_VERSION lets us raise
|
||||
* cost over time without invalidating existing hashes — verification
|
||||
* always uses the parameters embedded in argon2's own encoded hash
|
||||
* string, and a stored user is only re-hashed with the new parameters on
|
||||
* their next successful login (see needsRehash()).
|
||||
*/
|
||||
const PARAMS_BY_VERSION: Record<number, argon2.Options> = {
|
||||
1: {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 19456, // 19 MiB — OWASP minimum recommendation for argon2id
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const CURRENT_ALGO_VERSION = 1;
|
||||
|
||||
@Injectable()
|
||||
export class PasswordService {
|
||||
async hash(plain: string): Promise<{ hash: string; algoVersion: number }> {
|
||||
const params = PARAMS_BY_VERSION[CURRENT_ALGO_VERSION];
|
||||
const hash = await argon2.hash(plain, params);
|
||||
return { hash, algoVersion: CURRENT_ALGO_VERSION };
|
||||
}
|
||||
|
||||
async verify(hash: string, plain: string): Promise<boolean> {
|
||||
try {
|
||||
return await argon2.verify(hash, plain);
|
||||
} catch {
|
||||
// Malformed/foreign hash — treat as verification failure, never throw
|
||||
// into the caller (which would otherwise leak a 500 instead of 401).
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
needsRehash(algoVersion: number): boolean {
|
||||
return algoVersion !== CURRENT_ALGO_VERSION;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/auth/permission.guard.ts
Normal file
36
apps/api/src/auth/permission.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import type { Request } from "express";
|
||||
import { REQUIRE_PERMISSION_KEY, type RequiredPermission } from "./decorators/require-permission.decorator.js";
|
||||
import { hasPermission } from "./permissions.js";
|
||||
|
||||
/**
|
||||
* Runs after JwtAuthGuard (which populates request.edenUser). Endpoints
|
||||
* without @RequirePermission are left alone — authentication only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.get<RequiredPermission | undefined>(
|
||||
REQUIRE_PERMISSION_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!required) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.edenUser;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Autenticação necessária");
|
||||
}
|
||||
|
||||
if (!hasPermission(user.permissions, required.resource, required.action)) {
|
||||
throw new ForbiddenException(
|
||||
`Sem permissão para '${required.action}' em '${required.resource}'`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
55
apps/api/src/auth/permissions.ts
Normal file
55
apps/api/src/auth/permissions.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { rolesRepo, type RolePermission } from "@eden/database";
|
||||
|
||||
export const SUPER_ADMIN_ROLE = "super_admin";
|
||||
|
||||
export type ResolvedPermissions =
|
||||
| { superAdmin: true }
|
||||
| { superAdmin: false; grants: RolePermission[] };
|
||||
|
||||
/**
|
||||
* super_admin is never looked up in role_permissions — hardcoded bypass,
|
||||
* mirrored from the legacy system and enforced again at the DB level
|
||||
* (docs/adr/0004-permission-model.md, chk_role_permissions_no_super_admin).
|
||||
*/
|
||||
export async function resolvePermissions(roleKey: string): Promise<ResolvedPermissions> {
|
||||
if (roleKey === SUPER_ADMIN_ROLE) return { superAdmin: true };
|
||||
const grants = await rolesRepo.getRolePermissions(roleKey);
|
||||
return { superAdmin: false, grants };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum scope the actor holds for (resource, action), or
|
||||
* null if they hold none. Scope precedence — broader wins when a role
|
||||
* somehow has more than one row for the same resource+action (shouldn't
|
||||
* happen given the UNIQUE constraint per scope, but a role could have
|
||||
* both e.g. 'own' and 'all' rows intentionally):
|
||||
*/
|
||||
const SCOPE_RANK: Record<RolePermission["scope"], number> = {
|
||||
own: 1,
|
||||
team: 2,
|
||||
reseller: 3,
|
||||
legal_entity: 4,
|
||||
all: 5,
|
||||
};
|
||||
|
||||
export function maxGrantedScope(
|
||||
permissions: ResolvedPermissions,
|
||||
resource: string,
|
||||
action: RolePermission["action"],
|
||||
): RolePermission["scope"] | "all" | null {
|
||||
if (permissions.superAdmin) return "all";
|
||||
const matches = permissions.grants.filter((g) => g.resource === resource && g.action === action);
|
||||
if (matches.length === 0) return null;
|
||||
return matches.reduce<RolePermission["scope"]>(
|
||||
(best, g) => (SCOPE_RANK[g.scope] > SCOPE_RANK[best] ? g.scope : best),
|
||||
matches[0]!.scope,
|
||||
);
|
||||
}
|
||||
|
||||
export function hasPermission(
|
||||
permissions: ResolvedPermissions,
|
||||
resource: string,
|
||||
action: RolePermission["action"],
|
||||
): boolean {
|
||||
return maxGrantedScope(permissions, resource, action) !== null;
|
||||
}
|
||||
45
apps/api/src/auth/token.service.ts
Normal file
45
apps/api/src/auth/token.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string; // user id
|
||||
app: "core" | "reseller" | "subscriber";
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — ADR-0003
|
||||
const REFRESH_TOKEN_TTL_DAYS = 30;
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
|
||||
this.secret = env.JWT_SIGNING_SECRET;
|
||||
}
|
||||
|
||||
signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } {
|
||||
const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
|
||||
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): AccessTokenPayload {
|
||||
return jwt.verify(token, this.secret) as AccessTokenPayload;
|
||||
}
|
||||
|
||||
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
|
||||
* plaintext (shown once to the client) and its SHA-256 hash (the only
|
||||
* thing persisted). */
|
||||
generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } {
|
||||
const plaintext = randomBytes(32).toString("hex"); // 256 bits
|
||||
const hash = this.hashRefreshToken(plaintext);
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
return { plaintext, hash, expiresAt };
|
||||
}
|
||||
|
||||
hashRefreshToken(plaintext: string): string {
|
||||
return createHash("sha256").update(plaintext).digest("hex");
|
||||
}
|
||||
}
|
||||
9
apps/api/src/bootstrap/bootstrap.module.ts
Normal file
9
apps/api/src/bootstrap/bootstrap.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { SeedService } from "./seed.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [SeedService],
|
||||
})
|
||||
export class BootstrapModule {}
|
||||
50
apps/api/src/bootstrap/seed.service.ts
Normal file
50
apps/api/src/bootstrap/seed.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { usersRepo } from "@eden/database";
|
||||
import type { EdenEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "../config/config.constants.js";
|
||||
import { PasswordService } from "../auth/password.service.js";
|
||||
import { SUPER_ADMIN_ROLE } from "../auth/permissions.js";
|
||||
|
||||
/**
|
||||
* Mirrors the legacy's seedAdmin() (eden.md §5.7): runs once at boot,
|
||||
* only acts if `users` is empty, creates the first super_admin from env
|
||||
* vars — never hardcoded, never auto-generated-and-logged. If the env
|
||||
* vars aren't set, boot proceeds without seeding (useful for CI/tests
|
||||
* that don't need a real login) rather than crashing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SeedService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SeedService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(EDEN_ENV) private readonly env: EdenEnv,
|
||||
private readonly passwords: PasswordService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const existing = await usersRepo.countUsers();
|
||||
if (existing > 0) return;
|
||||
|
||||
if (!this.env.EDEN_SUPERADMIN_EMAIL || !this.env.EDEN_SUPERADMIN_PASSWORD) {
|
||||
this.logger.warn(
|
||||
"users está vazia e EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD não estão definidos — pulando seed do super_admin.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { hash, algoVersion } = await this.passwords.hash(this.env.EDEN_SUPERADMIN_PASSWORD);
|
||||
const user = await usersRepo.createUser({
|
||||
email: this.env.EDEN_SUPERADMIN_EMAIL,
|
||||
passwordHash: hash,
|
||||
passwordAlgoVersion: algoVersion,
|
||||
roleKey: SUPER_ADMIN_ROLE,
|
||||
fullName: "Super Administrador",
|
||||
});
|
||||
|
||||
await usersRepo.grantUserApplication(user.id, "core");
|
||||
await usersRepo.grantUserApplication(user.id, "reseller");
|
||||
await usersRepo.grantUserApplication(user.id, "subscriber");
|
||||
|
||||
this.logger.log(`super_admin inicial criado (${user.email}) a partir de EDEN_SUPERADMIN_EMAIL.`);
|
||||
}
|
||||
}
|
||||
50
apps/api/src/companies/companies.controller.ts
Normal file
50
apps/api/src/companies/companies.controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { CompaniesService } from "./companies.service.js";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto.js";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto.js";
|
||||
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
|
||||
import { PermissionGuard } from "../auth/permission.guard.js";
|
||||
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
|
||||
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
|
||||
|
||||
/**
|
||||
* Read is open to any authenticated user (not gated by a resource
|
||||
* permission) — company data isn't sensitive and is needed to render
|
||||
* contracts/documents from screens any seller/backoffice user can reach,
|
||||
* mirroring the legacy (eden.md §1, §6). Write requires companies:manage.
|
||||
*/
|
||||
@ApiTags("companies")
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("v1/companies")
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companies: CompaniesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query("includeInactive") includeInactive?: string) {
|
||||
return this.companies.list(includeInactive === "true");
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
get(@Param("id") id: string) {
|
||||
return this.companies.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission("companies", "manage")
|
||||
create(@Body() dto: CreateCompanyDto, @CurrentUser() user: EdenAuthenticatedUser) {
|
||||
return this.companies.create(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequirePermission("companies", "manage")
|
||||
update(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdateCompanyDto,
|
||||
@CurrentUser() user: EdenAuthenticatedUser,
|
||||
) {
|
||||
return this.companies.update(id, dto, user.id);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/companies/companies.module.ts
Normal file
11
apps/api/src/companies/companies.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module.js";
|
||||
import { CompaniesController } from "./companies.controller.js";
|
||||
import { CompaniesService } from "./companies.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CompaniesController],
|
||||
providers: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
102
apps/api/src/companies/companies.service.ts
Normal file
102
apps/api/src/companies/companies.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { companiesRepo, auditRepo, type Company } from "@eden/database";
|
||||
import { onlyDigits, isValidCNPJ } from "@eden/domain-shared";
|
||||
import type { CreateCompanyDto } from "./dto/create-company.dto.js";
|
||||
import type { UpdateCompanyDto } from "./dto/update-company.dto.js";
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
list(includeInactive: boolean): Promise<Company[]> {
|
||||
return companiesRepo.listCompanies(includeInactive);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<Company> {
|
||||
const company = await companiesRepo.getCompanyById(id);
|
||||
if (!company) throw new NotFoundException("Empresa não encontrada");
|
||||
return company;
|
||||
}
|
||||
|
||||
async create(dto: CreateCompanyDto, actorId: string): Promise<Company> {
|
||||
this.assertValidCnpjIfPresent(dto.cnpj);
|
||||
const company = await companiesRepo.createCompany({
|
||||
companyName: dto.companyName,
|
||||
tradeName: dto.tradeName,
|
||||
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
website: dto.website,
|
||||
legalRepName: dto.legalRepName,
|
||||
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
|
||||
legalRepRole: dto.legalRepRole,
|
||||
legalRepEmail: dto.legalRepEmail,
|
||||
createdBy: actorId,
|
||||
});
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "company.created",
|
||||
resourceType: "company",
|
||||
resourceId: company.id,
|
||||
});
|
||||
|
||||
return company;
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCompanyDto, actorId: string): Promise<Company> {
|
||||
await this.get(id); // 404 if missing
|
||||
this.assertValidCnpjIfPresent(dto.cnpj);
|
||||
|
||||
const company = await companiesRepo.updateCompany(id, {
|
||||
companyName: dto.companyName,
|
||||
tradeName: dto.tradeName,
|
||||
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
|
||||
stateRegistration: dto.stateRegistration,
|
||||
municipalRegistration: dto.municipalRegistration,
|
||||
addressZip: dto.addressZip,
|
||||
addressStreet: dto.addressStreet,
|
||||
addressNumber: dto.addressNumber,
|
||||
addressComplement: dto.addressComplement,
|
||||
addressNeighborhood: dto.addressNeighborhood,
|
||||
addressCity: dto.addressCity,
|
||||
addressState: dto.addressState,
|
||||
addressCountry: dto.addressCountry,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
website: dto.website,
|
||||
legalRepName: dto.legalRepName,
|
||||
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
|
||||
legalRepRole: dto.legalRepRole,
|
||||
legalRepEmail: dto.legalRepEmail,
|
||||
isActive: dto.isActive,
|
||||
});
|
||||
if (!company) throw new NotFoundException("Empresa não encontrada");
|
||||
|
||||
await auditRepo.appendAuditEvent({
|
||||
actorType: "user",
|
||||
actorId,
|
||||
action: "company.updated",
|
||||
resourceType: "company",
|
||||
resourceId: id,
|
||||
metadata: { fields: Object.keys(dto) },
|
||||
});
|
||||
|
||||
return company;
|
||||
}
|
||||
|
||||
private assertValidCnpjIfPresent(cnpj: string | undefined): void {
|
||||
if (cnpj && !isValidCNPJ(cnpj)) {
|
||||
throw new BadRequestException("CNPJ inválido");
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/api/src/companies/dto/create-company.dto.ts
Normal file
27
apps/api/src/companies/dto/create-company.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
companyName!: string;
|
||||
|
||||
@IsOptional() @IsString() tradeName?: string;
|
||||
@IsOptional() @IsString() cnpj?: string;
|
||||
@IsOptional() @IsString() stateRegistration?: string;
|
||||
@IsOptional() @IsString() municipalRegistration?: string;
|
||||
@IsOptional() @IsString() addressZip?: string;
|
||||
@IsOptional() @IsString() addressStreet?: string;
|
||||
@IsOptional() @IsString() addressNumber?: string;
|
||||
@IsOptional() @IsString() addressComplement?: string;
|
||||
@IsOptional() @IsString() addressNeighborhood?: string;
|
||||
@IsOptional() @IsString() addressCity?: string;
|
||||
@IsOptional() @IsString() addressState?: string;
|
||||
@IsOptional() @IsString() addressCountry?: string;
|
||||
@IsOptional() @IsString() phone?: string;
|
||||
@IsOptional() @IsEmail() email?: string;
|
||||
@IsOptional() @IsString() website?: string;
|
||||
@IsOptional() @IsString() legalRepName?: string;
|
||||
@IsOptional() @IsString() legalRepCpf?: string;
|
||||
@IsOptional() @IsString() legalRepRole?: string;
|
||||
@IsOptional() @IsEmail() legalRepEmail?: string;
|
||||
}
|
||||
9
apps/api/src/companies/dto/update-company.dto.ts
Normal file
9
apps/api/src/companies/dto/update-company.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { IsOptional, IsBoolean } from "class-validator";
|
||||
import { CreateCompanyDto } from "./create-company.dto.js";
|
||||
|
||||
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
1
apps/api/src/config/config.constants.ts
Normal file
1
apps/api/src/config/config.constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const EDEN_ENV = Symbol("EDEN_ENV");
|
||||
19
apps/api/src/config/config.module.ts
Normal file
19
apps/api/src/config/config.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { loadEnv } from "@eden/config";
|
||||
import { EDEN_ENV } from "./config.constants.js";
|
||||
|
||||
/**
|
||||
* Fails fast at boot (via loadEnv) if a required secret/config var is
|
||||
* missing — see packages/config/src/env-schema.ts and eden.md §5.6.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: EDEN_ENV,
|
||||
useFactory: () => loadEnv(),
|
||||
},
|
||||
],
|
||||
exports: [EDEN_ENV],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
@@ -1,13 +1,38 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
|
||||
|
||||
// Server-side validation is the authority — never trust that a client
|
||||
// already validated (Master Prompt §12). Strips unknown fields instead
|
||||
// of accepting them silently (mass-assignment protection).
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const openApiConfig = new DocumentBuilder()
|
||||
.setTitle("EDEN API")
|
||||
.setDescription("API principal do EDEN (Core, Parceiros, Assinante)")
|
||||
.setVersion("0.1.0")
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, openApiConfig);
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
const port = Number(process.env.EDEN_API_PORT ?? 8080);
|
||||
await app.listen(port, "0.0.0.0");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[eden-api] listening on :${port}`);
|
||||
console.log(`[eden-api] listening on :${port} — docs em /api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
Reference in New Issue
Block a user