CodeGym /Cursos /ChatGPT Apps /Control de acceso y minimización de privilegios: scopes, ...

Control de acceso y minimización de privilegios: scopes, segmentación, permisos por herramienta (per‑tool permissions)

ChatGPT Apps
Nivel 15 , Lección 0
Disponible

1. Por qué pensar en permisos en una ChatGPT‑App (y qué riesgo particular hay)

En una aplicación web «normal» entre el usuario y tu base solo hay un par de capas: frontend, API, BD. En una ChatGPT‑App aparece un participante activo más entre el usuario y tu API — la LLM. Y no es simplemente un «filtro de texto», sino una entidad que:

  • elige por sí misma qué herramientas invocar y con qué argumentos;
  • puede ser engañada por una inyección de prompt en los datos;
  • puede «confundir» herramientas o inventarse argumentos que no esperabas.

Si das a la LLM demasiadas atribuciones, obtienes el problema clásico de Confused Deputy: el modelo ejecuta de buena fe lo que cree que pide el usuario o el texto de los documentos, pero invoca delete_all_orders en lugar de get_last_order.

Por tanto, nuestro objetivo:

  1. Minimizar los privilegios de los auth_token (qué datos y acciones están disponibles en general).
  2. Restringir qué herramientas están disponibles para el modelo en un escenario concreto.
  3. Añadir control humano donde las consecuencias sean especialmente críticas.

Y todo esto hay que hacerlo sin paranoia ni prohibiciones totales, de lo contrario la App se vuelve inútil. El equilibrio entre comodidad y seguridad — nuestro principal reto en este módulo.

2. Modelo de acceso en el ecosistema: quién accede a qué

Para no perdernos, miremos el sistema en su conjunto. Tenemos varios niveles, cada uno con su zona de responsabilidad y sus permisos.

flowchart TD
  U[Usuario en ChatGPT] --> C[ChatGPT UI + LLM]
  C --> A["Tu App (plan visual + widget)"]
  A --> G[MCP Gateway / API Edge]
  G --> S[Servidores MCP y microservicios]
  S --> D[Bases de datos, colas, API externas]

Breve resumen de roles:

  • ChatGPT UI y LLM: gestionados por OpenAI. Tú defines sus instrucciones (system‑prompt, descripciones de herramientas), pero no controlas los tokens internos ni los permisos de la plataforma.
  • Tu App (plan, tools, widget): decides qué herramientas están disponibles, cómo se describen, qué confirmaciones de UX son necesarias y qué datos puede mostrar el widget.
  • MCP Gateway / API Edge: aquí se verifica el token, se hace el mapeo de userId, tenantId, la lista de scopes y la ruta hacia el servicio adecuado.
  • Servidores MCP y microservicios: ejecutan las herramientas, hacen consultas a la BD y a API externas. Aquí deben existir las comprobaciones más estrictas: scopes, aislamiento por tenant, validación de entradas.
  • Almacenes y API externas: la última línea de defensa (restricciones al nivel de BD, permisos de cuentas de servicios externos).

Idea clave: la LLM no es una fuente de permisos de acceso. Todo lo que llega al servidor MCP lo consideramos como «una solicitud del usuario redactada por el modelo». Decidir si realmente se puede ejecutar la operación es responsabilidad de tu código backend, no del prompt.

3. AuthN vs AuthZ: qué ya sabemos y qué añadimos

En el módulo sobre autenticación ya hicisteis:

  • AuthN (Authentication): averiguar quién es el usuario. Mediante OAuth 2.1/PKCE, ChatGPT obtenía del IdP un token que luego se adjuntaba a las llamadas a MCP. En él aparecían sub, user_id o similar, a veces tenant_id.
  • AuthZ básico: quizá ya distinguíais roles user/admin y comprobabais al menos si «es usuario» o «es admin».

Ahora complicamos el panorama:

  • cada auth_token debe llevar un conjunto de scopes — permisos en forma de cadena resource:action, por ejemplo catalog:read, orders:write, payments:create;
  • tu servidor MCP debe comprobar la correspondencia de esos scopes para cada acción, y no solo «una vez a la entrada»;
  • distintas herramientas e incluso distintas operaciones dentro de una misma herramienta pueden requerir scopes diferentes.

En términos de OAuth 2.1, ChatGPT es un «public client», MCP es un «resource server», y tu servidor OAuth sabe qué scopes se admiten y qué significan exactamente. Los metadatos del recurso MCP suelen declarar scopes_supported para que ChatGPT pueda solicitar al usuario exactamente los permisos necesarios.

4. Diseñamos scopes para GiftGenius

Tomemos nuestro GiftGenius didáctico y veamos qué dominios de datos y acciones tiene. En cuanto a funcionalidad, algo como:

  • visualización del catálogo y de las fichas de regalo;
  • recomendaciones basadas en el historial;
  • creación de pedidos;
  • inicio de checkout / cobro;
  • edición administrativa del catálogo.

En lugar de crear un único y todopoderoso giftgenius:full_access, mejor desglosarlo en scopes sensatos.

Convención de nombres: resource:action

Funciona bien la estrategia resource:action, donde:

  • resource describe el dominio: catalog, recommendations, orders, payments, admin.
  • action describe el tipo de acción: read, write, a veces más concreto: create, delete, manage.

Ejemplo para GiftGenius:

Scope Qué permite
catalog:read
Leer el catálogo público de regalos
recommendations:read
Leer el historial de recomendaciones del usuario
orders:write
Crear nuevos pedidos
orders:read
Leer el historial de pedidos del usuario
payments:create
Iniciar el pago / checkout
catalog:admin
Editar el catálogo (solo para UI de admin/soporte)

Un usuario normal de GiftGenius requerirá algo como (se enumeran separados por espacios): catalog:read recommendations:read orders:write orders:read payments:create. Al administrador le añadimos catalog:admin.

Importante: no hacemos un *:* o admin:all universal. Cuanto más granular, más fácil revocar un permiso concreto sin romper toda la aplicación.

Tipos de scopes: read vs write vs critical

Es útil marcar mentalmente los scopes por categorías:

  • seguros (read): no cambian el estado, como mucho exponen datos;
  • mutadores (write): crean/modifican entidades, incrementan contadores, pero no tocan dinero ni eliminan masivamente;
  • críticos (critical): pagos, eliminación de cuenta, borrado masivo de datos.

Para privilegios críticos puedes aplicar controles elevados:

  • concederlos al mínimo número de usuarios;
  • pedir al usuario un consentimiento separado en el UI de ChatGPT al emitir el token;
  • en el lado de MCP exigir confirmación adicional (por ejemplo, un PIN de un solo uso; ya escenarios avanzados).

Scopes en el código: RequestContext y requireScope

A nivel de MCP conviene definir un tipo de contexto común:

// mcp/context.ts
export interface RequestContext {
  userId: string;        // quién
  tenantId: string;      // en el ámbito de qué organización
  scopes: string[];      // qué permisos tiene el token
}

// Helper sencillo para comprobar permisos
export function requireScope(
  ctx: RequestContext,
  needed: string
) {
  if (!ctx.scopes.includes(needed)) {
    throw new Error(`Missing scope: ${needed}`);
  }
}

Se presupone que RequestContext lo formas en el MCP Gateway tras validar el token: decodificaste el JWT, verificaste firma/expiración, extrajiste sub, tenant, scope — y después adjuntas ese contexto a todas las llamadas de herramientas.

Luego, en el handler de la tool:

// mcp/tools/createOrder.ts
import { requireScope, RequestContext } from "../context";

export async function createOrder(
  input: CreateOrderInput,
  ctx: RequestContext
) {
  requireScope(ctx, "orders:write");
  // a continuación, la lógica de creación del pedido
}

Ahora, incluso si el modelo invoca createOrder donde, por UX, no lo esperabas, sin orders:write la herramienta simplemente no se ejecutará.

securitySchemes a nivel de herramienta

La especificación de MCP permite que cada herramienta indique qué esquemas de autorización y scopes necesita. En los ejemplos oficiales, securitySchemes se acoplan directamente a la descripción de la herramienta.

Ejemplo 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");
    // ...
  }
);

Aquí hay dos niveles de protección:

  • declarativo: ChatGPT sabe que para esta herramienta se necesita orders:write y, si faltan permisos, iniciará el flujo de auth (o se lo comunicará al usuario);
  • imperativo: tu código vuelve a comprobar todo antes de la acción real.

Si hay token pero faltan scopes, el servidor debe devolver un error con WWW-Authenticate: Bearer error="insufficient_scope", scope="orders:write" — y ChatGPT podrá pedir al usuario ampliar permisos (autorización escalonada, step‑up authorization).

Insight

En los ejemplos oficiales se usa securitySchemes. No estaba aprobada en la especificación oficial en la forma en que aparece en los ejemplos de ChatGPT Apps SDK. Por eso hay que marcarla como una extensión del protocolo oficial — envolviéndola en _meta. Una versión funcional del ejemplo anterior:

// mcp/server.ts
server.registerTool(
  "createOrder",
  {
    title: "Create order",
    description: "Creates a new order for current user",
    inputSchema: {/*...*/},
    _meta: {										// así
      securitySchemes: [
        { type: "oauth2", scopes: ["orders:write"] }
      ]          
    }
  },
  async ({ input }, ctx: RequestContext) => {
    requireScope(ctx, "orders:write");
    // ...
  }
);

5. Per‑tool permissions y herramientas «peligrosas»

Los scopes responden a la pregunta «qué puede hacer en principio este auth_token». Pero dentro del token también hay una lista de herramientas que el modelo puede utilizar. Estas también hay que diseñarlas con cuidado.

Clasificación de herramientas

Dividimos las herramientas, de forma aproximada, en:

  • informativas (informational / read‑only): leen datos, generan informes, calculan algo sin efectos secundarios;
  • consecuenciales (consequential): alteran el estado, cobran dinero, eliminan cosas.

La documentación de ChatGPT Apps recomienda marcar explícitamente como seguras las herramientas de solo lectura y, para las peligrosas, describir sus consecuencias e incluir confirmaciones adicionales de UX.

Esto puede hacerse:

  • mediante anotaciones en la herramienta (campos como readOnlyHint, destructiveHint);
  • mediante una descripción textual: «Esta herramienta elimina pedidos de forma irreversible»;
  • mediante un flag específico confirmation_required, que tu plan de App usa para insertar un paso de confirmación en el diálogo.

Confirmaciones UX para acciones críticas

Por ejemplo, GiftGenius tiene la herramienta chargeCustomer (inicia el cobro). Evidentemente, no quieres que el modelo la invoque sin consentimiento del usuario.

Cómo podría verse en el plan de la App:

// app/plan/tools.ts (pseudocódigo)
export const tools = [
  {
    name: "giftgenius.list_catalog",
    description: "Mostrar el catálogo de regalos",
    annotations: { readOnlyHint: true }
  },
  {
    name: "giftgenius.create_order",
    description: "Crear un pedido sin pago",
    annotations: { consequential: true }
  },
  {
    name: "giftgenius.charge_customer",
    description: "Cargar el importe del pedido",
    annotations: {
      consequential: true,
      destructiveHint: true,
      confirmationRequired: {
        title: "¿Cargar el importe a la tarjeta?",
        message: "Se realizará el pago del pedido N."
      }
    }
  }
];

Los nombres concretos de los campos dependen de la versión del SDK, pero la idea coincide con las recomendaciones: las herramientas de solo lectura se marcan como seguras, las peligrosas como que requieren confirmación explícita y una buena explicación en la descripción.

Después tu widget puede reaccionar: si el modelo propone invocar charge_customer, muestras al usuario una ventana modal con una redacción clara y solo tras pulsar «Confirmar» haces realmente la llamada de herramienta.

Ejemplo de componente en el widget (simplificado):

// widget/components/ConfirmCharge.tsx
export function ConfirmCharge(props: {
  orderId: string;
  onConfirm: () => void;
}) {
  return (
    <div>
      <p>¿Cargar el importe del pedido {props.orderId}?</p>
      <button onClick={props.onConfirm}>
        Sí, confirmar el pago
      </button>
    </div>
  );
}

El modelo inicia la idea de «es hora de pagar», pero el botón final lo pulsa una persona. Eso es human‑in‑the‑loop, algo muy apreciado por los equipos de seguridad.

Herramientas solo para agentes/back‑office

Otro caso frecuente: tienes herramientas que solo pueden usar agentes (en el sentido de Agents SDK) o paneles internos de admin, pero no la ChatGPT App «normal» del usuario.

Por ejemplo, rebuildSearchIndex o syncCatalogFromERP. Lo mejor es:

  • no incluirlas en la lista general de tools para la App normal;
  • configurarlas en un agente/orquestador aparte;
  • protegerlas con scopes separados y, quizá, un circuito de Auth distinto.

Si simplemente las añades a la lista de herramientas accesibles de la App, aumentas el riesgo de que el modelo decida de repente: «Voy a reconstruir el índice ahora mismo, quizá ayude a encontrar un regalo».

6. Segmentación de red y perímetros de confianza

Los permisos no son solo scopes en el token. El segundo gran eje es la segmentación de red y servicios.

La imagen ideal:

  • tienes una única entrada pública al backend — MCP Gateway/Edge API;
  • todo lo que almacena PII y dinero vive en una red privada/VPC y solo es accesible a través de ese gateway;
  • el tráfico saliente desde el backend está limitado a una lista de dominios permitidos (allowlist: pasarela de pagos, CRM, tus microservicios).

Esquemáticamente:

flowchart LR
  ChatGPT -- HTTPS --> Edge[API Gateway / MCP Endpoint]
  Edge -- private network --> MCP[MCP server]
  MCP -- private --> DB[(BD con PII)]
  MCP -- private --> SVC[Microservicios internos]
  MCP -- HTTPS (allow) --> Stripe[Payments API]

Aquí hay varias reglas importantes:

  1. La BD y los servicios internos no están expuestos directamente a Internet. Acceso directo solo desde la red privada y únicamente desde los servicios que realmente lo necesitan.
  2. Edge/Gateway realiza auth y rate‑limiting. Es ahí donde se verifica el token y los scopes, se limitan las solicitudes demasiado frecuentes y se registran los audit logs principales.
  3. Control de egress. El servidor MCP no debe poder acceder a cualquier URL de Internet (ataques SSRF, fugas de datos). La lista de hosts externos mejor limitarla de forma explícita.

En la práctica, si despliegas MCP en Vercel, Render o en un clúster de Kubernetes, parte de estas cosas no se configuran a mano, pero incluso ahí puedes separar:

  • proyectos/clústeres distintos para dev/staging/prod;
  • variables de entorno y claves diferentes para cada entorno;
  • un servicio «edge» separado (envoltura HTTP de MCP) y servicios privados aparte.

En resumen, ya tenemos dos ejes de protección: permisos en el token (scopes) y límites de red. Añadamos otro — la multiarquitectura de inquilinos (multi‑tenant), cuando una misma App da servicio a varias organizaciones.

7. Multi‑tenant / contexto organizativo

Hasta ahora pensábamos en un único usuario. Pero muchas aplicaciones de ChatGPT son multi‑tenant: una misma App da servicio a decenas de empresas. GiftGenius se puede convertir fácilmente en un servicio B2B para corporaciones: cada departamento con sus catálogos, presupuestos y pedidos.

Qué es un tenant y de dónde se obtiene

Un tenant suele ser:

  • una organización/empresa (Acme Corp);
  • un espacio de trabajo (workspace);
  • a veces un proyecto o un entorno.

Propiedad principal: los datos de un tenant no deben ser visibles para otro.

En el flujo de auth, el tenant suele incluirse en:

  • un claim del token (tenant, org_id);
  • un parámetro independiente en la solicitud de autorización (pero es menos fiable que un claim firmado por el IdP).

Importante: confiamos solo en el tenantId del token verificado, no en los argumentos de las herramientas. Si el modelo genera {"tenantId": "acme"}, pero en el token del usuario pone tenantId: "globex", debe considerarse un intento de intrusión.

Tenant en el contexto de la solicitud

Añadimos tenantId a nuestro RequestContext (ya lo hicimos arriba) y no permitimos que se sobrescriba desde la entrada.

Comprobación 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 };
}

Luego, en la herramienta:

// 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 el tenant de los argumentos y lo imponemos desde el contexto. Así, aunque la LLM o un atacante intenten «inyectar» un tenant ajeno, no funcionará.

Aislamiento por tenant a nivel de BD

Arquitectónicamente hay varias opciones:

  • una BD separada por tenant;
  • esquemas separados;
  • una sola BD con tenant_id en cada tabla y filtrado estricto.

Sea cual sea la opción, hay una regla de oro: ninguna consulta a la BD debe ejecutarse sin un filtro por tenant_id extraído del contexto. Esto es especialmente importante en RAG/búsqueda vectorial: si olvidas el filtro por tenant, el modelo puede empezar a buscar en documentos de otras organizaciones.

8. Cómo encaja con nuestra aplicación Next.js/Apps SDK

Juntémoslo todo y veamos cómo los scopes, el tenant y los límites de red aterrizan en nuestro proyecto Next.js con Apps SDK. Añadamos más concreción y veamos código de Next.js y del Apps SDK.

Dónde viven los scopes y el tenant en nuestro proyecto

Distribución típica para un proyecto didáctico:

  • En la aplicación Next.js (Apps SDK) tienes la configuración de la App/conector y las páginas para los callbacks de OAuth.
  • En el servidor MCP — el código que recibe peticiones HTTP/SSE desde ChatGPT, valida el token e invoca la herramienta necesaria.

Trasladamos allí todo lo visto:

  1. En la configuración OAuth del recurso MCP declaramos scopes_supported para GiftGenius (catalog:read, orders:write, etc.).
  2. En la configuración del Apps SDK describimos la App enumerando las herramientas y sus anotaciones (solo lectura, consecuenciales, flujos de confirmación).
  3. En el servidor MCP implementamos:
    • parseo y verificación del token;
    • formación de RequestContext { userId, tenantId, scopes };
    • helpers requireScope, enforceTenant, etc.;
    • accesos a BD siempre a través del tenantId del contexto.

Ejemplo de recorrido «aislado» para crear un pedido

Intentemos seguir un escenario end‑to‑end.

  1. El usuario escribe: «Tramita un pedido de este set con un presupuesto de 50 $».
  2. El modelo decide que debe invocar giftgenius.create_order con argumentos { productId, budget, ... }.
  3. ChatGPT comprueba si la App tiene la herramienta create_order, qué scopes y securitySchemes se han definido para ella. Entiende que necesita orders:write.
  4. Si el token ya existe y contiene orders:write, la solicitud continúa; si no, ChatGPT inicia la autorización OAuth solicitando el scope necesario.
  5. El MCP Gateway acepta la solicitud, verifica el token, forma el RequestContext con userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
  6. createOrder dentro de MCP:
    • hace requireScope(ctx, "orders:write");
    • fija el tenant mediante enforceTenant;
    • crea el pedido solo dentro de tenantId="acme".
  7. Si el pedido requiere pago inmediato, el modelo o el backend inician charge_customer, donde:
    • la herramienta en el plan está marcada con confirmationRequired;
    • el widget renderiza ConfirmCharge y pide al usuario confirmar explícitamente el cobro.

Así logramos defensa en profundidad: prompts demasiado amplios, inyecciones de prompt o incluso bugs en el UX no conducirán a acciones incontroladas, porque en la base de la pirámide siguen estando las comprobaciones estrictas de scopes, tenant y confirmaciones manuales para acciones críticas.

9. Errores típicos al diseñar permisos y segmentación

Error n.º 1: Un scope único y enorme tipo app:full_access.
Este enfoque es cómodo en una demo, pero peligroso en producción. Si pierdes un token, lo pierdes todo. No se puede revocar o prohibir una sola operación sin romper las demás. Divide los permisos por dominios y tipos de operación (read/write/critical).

Error n.º 2: Comprobar permisos solo «a la entrada» y no dentro de las herramientas.
A veces se hace así: «si ChatGPT obtuvo un token, ya puede hacerlo todo». Y luego la herramienta createOrder se invoca aunque ese token no tenga orders:write. El enfoque correcto es comprobar los scopes en cada herramienta (o al menos con un middleware centralizado para todas las operaciones mutadoras).

Error n.º 3: No marcar herramientas peligrosas ni exigir confirmación.
Si una herramienta cobra dinero, elimina datos o cambia accesos, no debe verse para el modelo igual que listCatalog. La ausencia de anotaciones y confirmaciones de UX explícitas aumenta la probabilidad de que el modelo la invoque «simplemente porque parece lógico». Como mínimo, separa herramientas de solo lectura y destructivas y marca explícitamente las segundas.

Error n.º 4: Confiar en tenantId de los argumentos de la herramienta.
Antipatrón muy frecuente: la herramienta getOrders({ tenantId }), donde tenantId llega desde el modelo. Si lo usas tal cual, un usuario de tenantA puede acceder a datos de tenantB indicando otro identificador. El tenant debe venir de un token verificado e imponerse a todas las consultas a la BD y a servicios externos; los valores aportados por el usuario o se ignoran o se validan para coincidir.

Error n.º 5: MCP/BD accesibles directamente desde Internet.
A veces, en prototipos sencillos, el servidor MCP y la BD están expuestos a Internet en HTTP/5432 sin protección. En producción no se puede hacer: todo el acceso debe pasar por un único gateway/proxy protegido, y la BD vivir en una red privada. De lo contrario, cualquier endpoint vulnerable o webhook mal protegido es una puerta directa a los datos.

Error n.º 6: Usar los mismos scopes/secretos en dev y prod.
Forma favorita de provocar un borrado repentino de datos de producción durante una demo del entorno de desarrollo local. Para cada entorno deben existir sus propias claves, scopes y BD. Incluso si alguien accede a un token de dev, no podrá dañar los datos de prod.

Error n.º 7: No querer «negarle cosas al modelo».
A veces los desarrolladores se preocupan: «Si devuelvo a menudo errores insufficient_scope o forbidden, el modelo funcionará peor». En la práctica es un comportamiento normal y esperado: el modelo aprende qué acciones tiene disponibles y cuáles requieren permisos o confirmación adicionales. Peor es que «haga con éxito» lo que no debe — por ejemplo, procesar un segundo pago.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION