1. Por que pensar em permissões no ChatGPT‑App (e qual é o risco específico)
Em um aplicativo web “comum”, entre o usuário e seu banco de dados há apenas algumas camadas: frontend, API, BD. No ChatGPT‑App, entre o usuário e a API aparece mais um participante ativo — a LLM. E ela não é apenas um “filtro de texto”, mas uma entidade que:
- escolhe sozinha quais ferramentas chamar e com quais argumentos;
- pode ser enganada por prompt injection nos dados;
- pode “confundir” ferramentas ou inventar argumentos que você não esperava.
Se você der permissões demais à LLM, terá o problema clássico do Confused Deputy: o modelo executa de boa-fé aquilo que, ao que parece, o usuário ou um texto nos documentos pedem, mas acaba chamando delete_all_orders em vez de get_last_order.
Portanto, nosso objetivo é:
- Minimizar os privilégios dos auth_tokens (quais dados e ações estão disponíveis em geral).
- Restringir quais ferramentas ficam disponíveis ao modelo em um cenário específico.
- Adicionar controle humano onde as consequências são especialmente críticas.
E tudo isso precisa ser feito sem paranoia e sem proibições totais, caso contrário o App se torna inútil. O equilíbrio entre conveniência e segurança é nossa principal missão neste módulo.
2. Modelo de acesso no ecossistema: quem acessa o quê
Para não nos confundirmos, vamos olhar o sistema como um todo. Temos vários níveis, cada um com sua zona de responsabilidade e suas permissões.
flowchart TD U[Usuário no ChatGPT] --> C[ChatGPT UI + LLM] C --> A["Seu App (plano visual + widget)"] A --> G[MCP Gateway / API Edge] G --> S[Servidores MCP e microsserviços] S --> D[Bancos de dados, filas, APIs externas]
Resumo dos papéis:
- ChatGPT UI e LLM: gerenciados pela OpenAI. Você fornece instruções (system‑prompt, descrições de ferramentas), mas não controla os tokens internos e as permissões da plataforma.
- Seu App (plano, tools, widget): você decide quais ferramentas estão disponíveis, como são descritas, quais confirmações de UX são necessárias, quais dados o widget pode exibir.
- MCP Gateway / API Edge: aqui ocorre a verificação do token, o mapeamento de userId, tenantId, a lista de scopes e a roteirização para o serviço correto.
- Servidores MCP e microsserviços: executam as ferramentas, fazem consultas ao BD e a APIs externas. Aqui devem existir as verificações mais rigorosas: scopes, isolamento por tenant, validação de entrada.
- Armazenamentos e APIs externas: a última linha de defesa (restrições no nível do BD e das contas dos serviços externos).
A ideia-chave: a LLM não é uma fonte de permissões. Tudo o que chega ao servidor MCP é tratado como “requisição do usuário, formulada pelo modelo”. Decidir se a operação pode mesmo ser executada é responsabilidade do seu backend, e não do prompt.
3. AuthN vs AuthZ: o que você já sabe e o que adicionaremos
No módulo de autenticação você já fez:
- AuthN (Authentication) — descobrir quem é esse usuário. Pelo OAuth 2.1/PKCE, o ChatGPT obtinha do IdP um token, que depois era anexado às chamadas MCP. Nele vinham sub, user_id ou similar, às vezes tenant_id.
- AuthZ básico — possivelmente você já diferenciava papéis user/admin e checava pelo menos “se é usuário” ou “se é admin”.
Agora vamos complicar o quadro:
- cada auth_token deve carregar um conjunto de scopes — permissões textuais no formato resource:action, por exemplo catalog:read, orders:write, payments:create;
- seu servidor MCP deve verificar a correspondência desses scopes para cada ação, não apenas “uma vez na entrada”;
- ferramentas diferentes e até operações diferentes dentro da mesma ferramenta podem exigir scopes distintos.
Em termos de OAuth 2.1, o ChatGPT é um “public client”, o MCP é um “resource server”, e seu servidor OAuth sabe quais scopes são suportados e o que significam. Os metadados do recurso MCP normalmente declaram scopes_supported para que o ChatGPT possa solicitar ao usuário exatamente as permissões necessárias.
4. Projetando scopes para o GiftGenius
Vamos pegar nosso GiftGenius didático e ver quais domínios de dados e ações ele tem. Em termos de funcionalidade, algo como:
- visualizar o catálogo e os cards de presentes;
- recomendações com base no histórico;
- criar pedidos;
- iniciar checkout / cobrança;
- edição do catálogo por admin.
Em vez de criar um giftgenius:full_access onipotente, é melhor decompor isso em scopes razoáveis.
Convenção de nomenclatura: resource:action
Funciona bem a estratégia resource:action, onde:
- resource descreve o domínio: catalog, recommendations, orders, payments, admin.
- action descreve o tipo de ação: read, write, às vezes mais específico: create, delete, manage.
Exemplo para o GiftGenius:
| Scope | O que permite |
|---|---|
|
Ler o catálogo público de presentes |
|
Ler o histórico de recomendações do usuário |
|
Criar novos pedidos |
|
Ler o histórico de pedidos do usuário |
|
Iniciar pagamento / checkout |
|
Editar o catálogo (somente para UI/admin de suporte) |
Um usuário comum do GiftGenius precisará de algo como (listados separados por espaço): catalog:read recommendations:read orders:write orders:read payments:create. Para o administrador, adicionamos catalog:admin.
Importante: não crie um *:* ou admin:all universal. Quanto mais granular, mais fácil revogar um direito específico sem quebrar todo o aplicativo.
Tipos de scopes: read vs write vs critical
É útil marcar mentalmente os scopes por categoria:
- seguros (read): não alteram estado, no máximo expõem dados;
- mutáveis (write): criam/alteram entidades, incrementam contadores, mas não mexem com dinheiro nem fazem deleções massivas;
- críticos (critical): pagamentos, exclusão de conta, deleção em massa de dados.
Para direitos críticos, é possível aplicar controles mais rigorosos:
- concedê-los ao menor número possível de usuários;
- solicitar consentimento separado do usuário no UI do ChatGPT ao emitir o token;
- no MCP, exigir confirmação adicional (por exemplo, um PIN de uso único — cenários avançados).
Scopes no código: RequestContext e requireScope
No nível do MCP, é conveniente definir um tipo de contexto unificado:
// mcp/context.ts
export interface RequestContext {
userId: string; // quem
tenantId: string; // no contexto de qual organização
scopes: string[]; // quais permissões outorgadas ao token
}
// Helper simples para checar permissões
export function requireScope(
ctx: RequestContext,
needed: string
) {
if (!ctx.scopes.includes(needed)) {
throw new Error(`Missing scope: ${needed}`);
}
}
Supõe-se que o RequestContext seja formado no MCP Gateway após a validação do token: decodificou o JWT, verificou assinatura/expiração, extraiu sub, tenant, scope — e então anexa esse contexto a todas as chamadas de ferramentas.
Depois, no handler da tool:
// mcp/tools/createOrder.ts
import { requireScope, RequestContext } from "../context";
export async function createOrder(
input: CreateOrderInput,
ctx: RequestContext
) {
requireScope(ctx, "orders:write");
// em seguida – lógica de criação do pedido
}
Agora, mesmo que o modelo invoque createOrder em um momento em que, do ponto de vista do UX, você não esperava, sem orders:write a ferramenta simplesmente não será executada.
securitySchemes no nível da ferramenta
A especificação MCP permite que cada ferramenta declare quais esquemas de autorização e scopes ela necessita. Nos exemplos oficiais, securitySchemes é acoplado diretamente à descrição da ferramenta.
Exemplo hipotético:
// mcp/server.ts
server.registerTool(
"createOrder",
{
title: "Create order",
description: "Creates a new order for current user",
inputSchema: {/*...*/},
securitySchemes: [
{ type: "oauth2", scopes: ["orders:write"] }
]
},
async ({ input }, ctx: RequestContext) => {
requireScope(ctx, "orders:write");
// ...
}
);
Aqui há dois níveis de proteção:
- declarativo: o ChatGPT sabe que essa ferramenta precisa de orders:write e, se faltar permissão, iniciará o fluxo de auth (ou informará o usuário);
- imperativo: seu código verifica tudo novamente antes da ação real.
Se o token existir, mas faltar scopes, o servidor deve retornar um erro com WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write" — e o ChatGPT poderá solicitar ao usuário a ampliação das permissões (step‑up authorization).
Insight
Nos exemplos oficiais é usado securitySchemes. Ele não foi aprovado na especificação oficial no formato apresentado nos exemplos do ChatGPT Apps SDK. Portanto, é preciso marcá-lo como extensão do protocolo oficial — envolvendo-o em _meta. Uma versão funcional do exemplo acima:
// mcp/server.ts
server.registerTool(
"createOrder",
{
title: "Create order",
description: "Creates a new order for current user",
inputSchema: {/*...*/},
_meta: { // assim
securitySchemes: [
{ type: "oauth2", scopes: ["orders:write"] }
]
}
},
async ({ input }, ctx: RequestContext) => {
requireScope(ctx, "orders:write");
// ...
}
);
5. Per‑tool permissions e ferramentas “perigosas”
Scopes respondem à pergunta “o que este auth_token pode fazer em princípio”. Mas dentro do token há também a lista de ferramentas que o modelo pode usar. Elas também precisam ser projetadas com cuidado.
Classificação de ferramentas
Dividimos as ferramentas, de forma simplificada, em:
- informacionais (informational / read‑only): leem dados, geram relatórios, fazem cálculos sem efeitos colaterais;
- consequenciais (consequential): alteram estado, cobram dinheiro, excluem algo.
A documentação dos ChatGPT Apps recomenda que ferramentas read‑only sejam marcadas explicitamente como seguras e, para as perigosas, descrever consequências e incluir confirmações adicionais de UX.
Isso pode ser feito:
- por meio de anotações na ferramenta (campos como readOnlyHint, destructiveHint);
- por descrição textual: “Esta ferramenta exclui pedidos de forma irreversível”;
- por uma flag separada confirmation_required, que seu plano do App usa para inserir um passo de confirmação no diálogo.
Confirmações de UX para ações críticas
Por exemplo, o GiftGenius tem a ferramenta chargeCustomer (inicia a cobrança). Você, obviamente, não quer que o modelo a chame sem o consentimento do usuário.
Como isso pode aparecer no nível do plano do App:
// app/plan/tools.ts (pseudocódigo)
export const tools = [
{
name: "giftgenius.list_catalog",
description: "Mostrar catálogo de presentes",
annotations: { readOnlyHint: true }
},
{
name: "giftgenius.create_order",
description: "Criar pedido sem pagamento",
annotations: { consequential: true }
},
{
name: "giftgenius.charge_customer",
description: "Debitar o valor do pedido",
annotations: {
consequential: true,
destructiveHint: true,
confirmationRequired: {
title: "Debitar do cartão?",
message: "Um pagamento será realizado para o pedido N."
}
}
}
];
Os nomes dos campos dependem da versão do SDK, mas a ideia coincide com as recomendações: marcamos as ferramentas read‑only como seguras e as perigosas como exigindo confirmação explícita e uma boa explicação na descrição.
Depois, seu widget pode reagir: se o modelo propuser chamar charge_customer, você mostra uma janela modal ao usuário com uma formulação clara e só após o clique em “Confirmar” executa de fato a chamada da ferramenta.
Exemplo de componente no widget (simplificado):
// widget/components/ConfirmCharge.tsx
export function ConfirmCharge(props: {
orderId: string;
onConfirm: () => void;
}) {
return (
<div>
<p>Debitar o valor do pedido {props.orderId}?</p>
<button onClick={props.onConfirm}>
Sim, confirmar pagamento
</button>
</div>
);
}
O modelo inicia a ideia de “hora de pagar”, mas o clique final é do humano. Isso é o human‑in‑the‑loop, tão valorizado pela área de segurança.
Ferramentas apenas para agentes/back‑office
Outro caso frequente: você tem ferramentas que apenas agentes (no sentido do Agents SDK) ou painéis internos podem usar — e não o ChatGPT App “comum” do usuário.
Por exemplo, rebuildSearchIndex ou syncCatalogFromERP. O ideal é:
- não incluí-las na lista geral de tools do App comum;
- configurá-las em um agente/orquestrador separado;
- protegê-las com scopes próprios e, possivelmente, com um contorno de Auth separado.
Se você apenas adicioná-las à lista de ferramentas acessíveis do App, aumenta o risco de o modelo decidir de repente: “Vou recriar o índice agora; talvez isso ajude a encontrar o presente”.
6. Segmentação de rede e fronteiras de confiança
Permissões não são apenas scopes no token. O segundo eixo importante é a segmentação de rede e de serviços.
Cenário ideal:
- existe exatamente uma entrada pública para o backend — MCP Gateway/Edge API;
- tudo que armazena PII e dinheiro vive em rede privada/VPC e é acessível apenas por meio desse gateway;
- o tráfego de saída (outbound) do backend é limitado a uma lista de domínios permitidos (allowlist: processador de pagamentos, CRM, seus microsserviços).
Esquematicamente:
flowchart LR ChatGPT -- HTTPS --> Edge[API Gateway / MCP Endpoint] Edge -- private network --> MCP[Servidor MCP] MCP -- private --> DB[(DB com PII)] MCP -- private --> SVC[Microsserviços internos] MCP -- HTTPS (allow) --> Stripe[Payments API]
Algumas regras importantes aqui:
- BDs e serviços internos não ficam expostos diretamente à internet. O acesso direto a eles ocorre apenas pela rede privada e somente a partir dos serviços que realmente precisam.
- Edge/Gateway realiza auth e rate limiting. É ele que verifica token e scopes, limita requisições muito frequentes e grava os principais audit logs.
- Controle de egress. O servidor MCP não deve conseguir acessar quaisquer URLs da internet (ataques SSRF, vazamento de dados). É melhor restringir explicitamente a lista de hosts externos.
Na prática, se você fizer deploy do MCP na Vercel, Render ou em um cluster Kubernetes, parte dessas coisas não é configurada manualmente, mas mesmo assim é possível separar:
- projetos/cluster distintos para dev/staging/prod;
- variáveis de ambiente e chaves diferentes para cada ambiente;
- um serviço “edge” separado (HTTP wrapper do MCP) e serviços privados separados.
Assim, já temos dois eixos de proteção: permissões no token (scopes) e fronteiras de rede. Vamos adicionar mais um — multi‑tenant, quando o mesmo App atende várias organizações.
7. Multi‑tenant / contexto organizacional
Até agora, pensamos em um único usuário. Mas muitos aplicativos para ChatGPT são multi‑tenant: o mesmo App atende dezenas de empresas. O GiftGenius pode facilmente virar um serviço B2B para corporações: cada departamento com seus catálogos, orçamentos, pedidos.
O que é um tenant e onde obtê-lo
Tenant é, normalmente:
- uma organização/empresa (Acme Corp);
- um workspace (espaço de trabalho);
- às vezes um projeto ou ambiente.
Propriedade principal: os dados de um tenant não devem ser visíveis a outro.
No fluxo de auth, o tenant costuma ser colocado em:
- claim do token (tenant, org_id);
- um parâmetro separado na requisição de autorização (menos confiável do que um claim assinado pelo IdP).
Importante: confiamos apenas no tenantId proveniente de um token verificado, e não nos argumentos das ferramentas. Se o modelo gerar {"tenantId": "acme"}, mas no token do usuário constar tenantId: "globex", isso deve ser tratado como tentativa de violação.
Tenant no contexto da requisição
Vamos adicionar tenantId ao nosso RequestContext (já fizemos isso acima) e não permitir que ele seja sobrescrito a partir dos dados de entrada.
Verificação básica:
// mcp/tenant.ts
import { RequestContext } from "./context";
export function enforceTenant<TInput>(
input: TInput & { tenantId?: string },
ctx: RequestContext
) {
if (input.tenantId && input.tenantId !== ctx.tenantId) {
throw new Error("Tenant mismatch");
}
return { ...input, tenantId: ctx.tenantId };
}
Depois, na ferramenta:
// mcp/tools/listOrders.ts
export async function listOrders(
input: { limit?: number; tenantId?: string },
ctx: RequestContext
) {
const safe = enforceTenant(input, ctx);
return db.order.findMany({
where: { tenantId: safe.tenantId },
take: safe.limit ?? 20
});
}
Ignoramos o tenant vindo dos argumentos e o fixamos a partir do contexto. Assim, mesmo que a LLM ou um invasor tente “inserir” outro tenant, não terá efeito.
Isolamento por tenant no nível do BD
Arquiteturalmente, há diferentes opções:
- um BD separado por tenant;
- schemas separados;
- um único BD com tenant_id em cada tabela e filtragem rigorosa.
Qualquer que seja a opção escolhida, existe uma regra de ouro: nenhuma consulta ao BD deve ser executada sem filtrar por tenant_id do contexto. Isso é particularmente importante em RAG/pesquisa vetorial: se você esquecer o filtro por tenant, o modelo pode começar a buscar documentos de outras organizações.
8. Como isso se conecta ao nosso aplicativo Next.js/Apps SDK
Agora vamos juntar tudo e ver como scopes, tenant e fronteiras de rede se materializam no nosso projeto Next.js com Apps SDK. Vamos adicionar mais concretude e olhar o código de Next.js e do Apps SDK.
Onde vivem scopes e tenant em nosso projeto
Layout típico para um projeto didático:
- No aplicativo Next.js (Apps SDK), você tem a configuração do App/conector e as páginas para os callbacks do OAuth.
- No servidor MCP — o código que aceita requisições HTTP/SSE do ChatGPT, verifica o token e invoca a ferramenta necessária.
Levamos para lá tudo o que discutimos:
- Nas configurações OAuth do recurso MCP, declaramos scopes_supported para o GiftGenius (catalog:read, orders:write, etc.).
- Na configuração do Apps SDK, descrevemos o App com a lista de ferramentas e suas anotações (read‑only, consequential, fluxos de confirmação).
- No servidor MCP, implementamos:
- parse e verificação do token;
- formação do RequestContext { userId, tenantId, scopes };
- helpers requireScope, enforceTenant etc.;
- consultas ao BD sempre usando o tenantId do contexto.
Exemplo de “caminho isolado” para criação de pedido
Vamos acompanhar um cenário end‑to‑end.
- O usuário escreve: “Faça o pedido deste kit para um orçamento de US$ 50”.
- O modelo decide que precisa chamar giftgenius.create_order com os argumentos { productId, budget, ... }.
- O ChatGPT verifica: o App tem a ferramenta create_order? Quais scopes e securitySchemes estão definidos para ela? Conclui que é necessário orders:write.
- Se o token já existir e contiver orders:write, a requisição segue; se não, o ChatGPT inicia a autorização OAuth solicitando o scope necessário.
- O MCP Gateway recebe a requisição, verifica o token e forma o RequestContext com userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
- createOrder dentro do MCP:
- executa requireScope(ctx, "orders:write");
- fixa o tenant com enforceTenant;
- cria o pedido apenas no âmbito de tenantId="acme".
- Se o pedido exigir pagamento imediato, o modelo ou o próprio backend iniciam charge_customer, onde:
- a ferramenta no plano está marcada como confirmationRequired;
- o widget renderiza ConfirmCharge e pede que o usuário confirme explicitamente a cobrança.
Assim, obtemos defesa em profundidade: prompts muito amplos, prompt injections ou mesmo bugs no UX não levam a ações incontroláveis, porque na base da pirâmide ainda existem verificações rigorosas de scopes, tenant e confirmações manuais para ações críticas.
9. Erros típicos ao projetar permissões e segmentação
Erro nº 1: Um scope “gordo” como app:full_access.
Essa abordagem é conveniente em demos, mas perigosa em produção. Perdeu um token — perdeu tudo. Não dá para revogar ou proibir uma operação sem quebrar as demais. Separe os direitos por domínios e tipos de operações (read/write/critical).
Erro nº 2: Verificar permissões apenas “na entrada” e não dentro das ferramentas.
Às vezes fazem assim: “se o ChatGPT obteve um token, então já pode tudo”. E então a ferramenta createOrder é chamada mesmo que aquele token não tenha orders:write. A abordagem correta é verificar scopes em cada ferramenta (ou ao menos por um middleware centralizado para operações mutáveis).
Erro nº 3: Não marcar ferramentas perigosas e não exigir confirmação.
Se a ferramenta cobra dinheiro, apaga dados ou muda acessos, ela não deve parecer ao modelo igual à listCatalog. A ausência de anotações explícitas e confirmações de UX aumenta a chance de o modelo chamá-la “porque parece lógico”. No mínimo, separe ferramentas read‑only e destrutivas e marque explicitamente as segundas.
Erro nº 4: Confiar no tenantId dos argumentos da ferramenta.
Um antipadrão comum: a ferramenta getOrders({ tenantId }), onde o tenantId vem do modelo. Se você usá-lo como está, um usuário do tenantA pode acessar dados do tenantB apenas informando outro identificador. O tenant deve vir do token verificado e ser imposto a todas as consultas ao BD e serviços externos; valores fornecidos pelo usuário são ignorados ou validados para coincidência.
Erro nº 5: MCP/BD acessíveis diretamente da internet.
Em protótipos simples, às vezes o servidor MCP e o BD ficam expostos na internet em HTTP/5432. Em produção isso é inadmissível: todo acesso deve passar por um gateway/proxy protegido, e o BD deve viver em rede privada. Caso contrário, qualquer endpoint vulnerável ou webhook mal configurado é caminho direto para os dados.
Erro nº 6: Usar os mesmos scopes/segredos em dev e prod.
É o jeito preferido de causar exclusão acidental de dados de produção durante uma demo local de dev. Cada ambiente deve ter suas próprias chaves, scopes e BDs. Mesmo que alguém obtenha acesso a um token de dev, não conseguirá prejudicar dados de prod.
Erro nº 7: Relutar em “negar ao modelo”.
Alguns desenvolvedores se preocupam: “Se eu retornar com frequência insufficient_scope ou forbidden, o modelo vai funcionar pior”. Na prática, isso é normal e esperado: o modelo aprende quais ações estão disponíveis e quais exigem permissões adicionais ou confirmação. Pior é quando ele “consegue” fazer o que não deveria — por exemplo, processar um segundo pagamento.
GO TO FULL VERSION