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
106 lines
4.4 KiB
TypeScript
106 lines
4.4 KiB
TypeScript
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, 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";
|
|
import { CreateAIModelDto } from "./dto/create-ai-model.dto";
|
|
|
|
/** "ai_models" (agente.md secao 102-103) — modelos configuráveis por
|
|
* provider, cada um com seu conjunto de capabilities e custos. */
|
|
@UseGuards(JwtAuthGuard, PermissionGuard)
|
|
@Controller("ai/models")
|
|
export class AIModelsController {
|
|
@RequirePermission("ai.manage")
|
|
@Post()
|
|
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIModelDto) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
const provider = await withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.aIProvider.findFirst({ where: { id: dto.providerId } }),
|
|
);
|
|
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: {
|
|
tenantId: provider.tenantId,
|
|
providerId: provider.id,
|
|
externalModelId: dto.externalModelId,
|
|
displayName: dto.displayName,
|
|
capabilities: (dto.capabilities ?? []) as Prisma.AIModelCreateInput["capabilities"],
|
|
inputCost: dto.inputCost,
|
|
outputCost: dto.outputCost,
|
|
audioCost: dto.audioCost,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await recordAuditEvent(prisma, {
|
|
action: "AI_MODEL_CREATE",
|
|
tenantId: provider.tenantId,
|
|
userId: user.sub,
|
|
entityType: "ai_model",
|
|
entityId: model.id,
|
|
after: { displayName: model.displayName, externalModelId: model.externalModelId },
|
|
});
|
|
|
|
return model;
|
|
}
|
|
|
|
@RequirePermission("ai.view")
|
|
@Get()
|
|
async list(@CurrentUser() user: AccessTokenClaims) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
return withTenantContext(prisma, tenantId, (tx) =>
|
|
tx.aIModel.findMany({ where: { enabled: true }, orderBy: { displayName: "asc" } }),
|
|
);
|
|
}
|
|
|
|
@RequirePermission("ai.manage")
|
|
@Delete(":id")
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
|
const prisma = getPrismaClient();
|
|
const tenantId = user.tenantId!;
|
|
|
|
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: model.tenantId,
|
|
userId: user.sub,
|
|
entityType: "ai_model",
|
|
entityId: id,
|
|
});
|
|
}
|
|
}
|