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:
@@ -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,
|
||||
|
||||
76
apps/api/src/platform/platform-ai-usage.controller.ts
Normal file
76
apps/api/src/platform/platform-ai-usage.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { 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";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
|
||||
/**
|
||||
* "IA > Uso/Custos" (agente.md secao 169) — visão de plataforma, todos os
|
||||
* tenants, diferente do BYOK do tenant (`/ai/providers`, `/ai/models`,
|
||||
* escopo TENANT/GLOBAL). Uso = quantidade bruta do ledger `AIUsageRecord`
|
||||
* (mesmo ledger que `/reports/consumo` usa por tenant, aqui somado em
|
||||
* todos). Custo = uso × preço unitário do `AIModel` correspondente
|
||||
* (`providerId` + `model` batendo com `externalModelId`) — quando não dá
|
||||
* pra casar um registro de uso com um AIModel cadastrado (ex.: provider
|
||||
* apagado, nome de modelo mudou), o custo daquele registro fica de fora
|
||||
* da soma e o tenant é marcado `costIncomplete: true`, nunca um número
|
||||
* inventado (agente.md secao 138/233: null > estimativa disfarçada de
|
||||
* número fechado).
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("platform/ai-usage")
|
||||
export class PlatformAiUsageController {
|
||||
@RequirePermission("tenants.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode ver uso de IA de todos os tenants");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
|
||||
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
|
||||
|
||||
const models = await prisma.aIModel.findMany({ select: { providerId: true, externalModelId: true, inputCost: true, outputCost: true, audioCost: true } });
|
||||
const modelByKey = new Map(models.map((m) => [`${m.providerId}:${m.externalModelId}`, m]));
|
||||
|
||||
return Promise.all(
|
||||
tenants.map(async (tenant) => {
|
||||
const records = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||
tx.aIUsageRecord.findMany({ where: { tenantId: tenant.id, occurredAt: { gte: monthStart } } }),
|
||||
);
|
||||
|
||||
const usage = { transcriptionSeconds: 0, analysisRequests: 0, inputTokens: 0, outputTokens: 0 };
|
||||
let cost = 0;
|
||||
let costIncomplete = false;
|
||||
|
||||
for (const r of records) {
|
||||
if (r.type === "AI_TRANSCRIPTION_SECONDS") usage.transcriptionSeconds += r.quantity;
|
||||
if (r.type === "AI_ANALYSIS_REQUEST") usage.analysisRequests += r.quantity;
|
||||
if (r.type === "AI_INPUT_TOKENS") usage.inputTokens += r.quantity;
|
||||
if (r.type === "AI_OUTPUT_TOKENS") usage.outputTokens += r.quantity;
|
||||
|
||||
const model = r.providerId && r.model ? modelByKey.get(`${r.providerId}:${r.model}`) : undefined;
|
||||
if (!model) {
|
||||
if (r.type !== "AI_ANALYSIS_REQUEST") costIncomplete = true;
|
||||
continue;
|
||||
}
|
||||
if (r.type === "AI_TRANSCRIPTION_SECONDS" && model.audioCost != null) cost += r.quantity * model.audioCost;
|
||||
else if (r.type === "AI_INPUT_TOKENS" && model.inputCost != null) cost += r.quantity * model.inputCost;
|
||||
else if (r.type === "AI_OUTPUT_TOKENS" && model.outputCost != null) cost += r.quantity * model.outputCost;
|
||||
}
|
||||
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
legalName: tenant.legalName,
|
||||
usage,
|
||||
estimatedCost: records.length === 0 ? null : cost,
|
||||
costIncomplete,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PlatformHealthController } from "./platform-health.controller";
|
||||
import { PlatformRolesController } from "./platform-roles.controller";
|
||||
import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
@@ -16,6 +17,7 @@ import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
|
||||
PlatformRolesController,
|
||||
PlatformQuotasController,
|
||||
PlatformFreeswitchController,
|
||||
PlatformAiUsageController,
|
||||
],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
Reference in New Issue
Block a user