feat(frontend): Platform > IA > Providers/Modelos/Uso/Custos + achado real de autorização

Providers/Modelos reaproveitam os endpoints /ai/providers e /ai/models já
existentes (só filtram scope GLOBAL na tela); Modelos expõe custo unitário
(inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só
platform precisa cadastrar preço. GET /platform/ai-usage (novo) agrega uso bruto de
AIUsageRecord de todos os tenants no mês corrente (Uso) e estima custo casando cada
registro com o AIModel correspondente (Custos) — quando não dá pra casar, o registro
fica de fora da soma e o tenant é marcado costIncomplete, nunca um número inventado.

Achado real de autorização ao revisar AIModelsController antes de construir a tela
de Modelos: POST/DELETE /ai/models não checava isPlatformUser quando o alvo era
scope=GLOBAL — como a RLS híbrida (OR tenant_id IS NULL) deixa qualquer tenant
ENXERGAR um provider/modelo GLOBAL, qualquer Tenant Admin com `ai.manage` (permission
de escopo TENANT) conseguia injetar um modelo no catálogo global ou desabilitar um
modelo GLOBAL só sabendo o id. O endpoint irmão (AIProvidersController) já tinha o
check certo; corrigido com o mesmo padrão. Confirmado com um teste de ataque real:
403 depois do fix (era 201/sucesso antes), com regressão confirmando que BYOK do
próprio tenant continua funcionando normalmente.

Testado ponta a ponta: provider+modelo GLOBAL com custo real, uso de teste inserido
direto no Postgres, /platform/ai-usage devolvendo o valor exato esperado (bate com a
conta manual), tudo removido no final.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 08:53:35 -03:00
parent 12af12f276
commit cc80310cb5
20 changed files with 877 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
@@ -23,6 +23,16 @@ export class AIModelsController {
);
if (!provider) throw new NotFoundException("Provider nao encontrado");
// Achado real (mesma classe já corrigida em `AIProvidersController`):
// a RLS híbrida (secao 100, `OR tenant_id IS NULL`) deixa qualquer
// tenant ENXERGAR um provider GLOBAL, mas escrever um modelo nele é
// catálogo de plataforma — sem este check, qualquer Tenant Admin com
// `ai.manage` (permission de escopo TENANT) conseguia injetar um
// modelo no catálogo visível por TODOS os tenants.
if (provider.scope === "GLOBAL" && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode adicionar modelo a um provider GLOBAL");
}
const model = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.create({
data: {
@@ -67,14 +77,26 @@ export class AIModelsController {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }),
);
if (result.count === 0) throw new NotFoundException();
const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } }));
if (!model) throw new NotFoundException();
// Mesmo achado do create(): sem este check, qualquer Tenant Admin com
// `ai.manage` conseguia desabilitar um modelo GLOBAL (visível e
// usado por todos os tenants) só por conhecer o id.
if (model.tenantId == null && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode remover um modelo GLOBAL");
}
if (model.tenantId != null && model.tenantId !== tenantId) {
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
// explicitamente antes de uma escrita (secao 31/146).
throw new NotFoundException();
}
await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.update({ where: { id }, data: { enabled: false } }));
await recordAuditEvent(prisma, {
action: "AI_MODEL_DELETE",
tenantId,
tenantId: model.tenantId,
userId: user.sub,
entityType: "ai_model",
entityId: id,