CodeGym /Cours /ChatGPT Apps /Contrôle d’accès et minimisation des droits : scopes, seg...

Contrôle d’accès et minimisation des droits : scopes, segmentation, autorisations par outil

ChatGPT Apps
Niveau 15 , Leçon 0
Disponible

1. Pourquoi réfléchir aux droits dans une ChatGPT‑App (et quel risque particulier ici)

Dans une application web « ordinaire », il n’y a que quelques couches entre l’utilisateur et votre base : front‑end, API, BD. Dans une ChatGPT‑App, un autre acteur actif apparaît entre l’utilisateur et l’API — le LLM. Et ce n’est pas juste un « filtre de texte », mais une entité qui :

  • choisit elle‑même quels outils appeler et avec quels arguments ;
  • peut être leurrée par une injection de prompt présente dans les données ;
  • peut « confondre » les outils ou inventer des arguments inattendus.

Si vous donnez trop de privilèges au LLM, vous obtenez le problème classique du Confused Deputy : le modèle exécute consciencieusement ce que lui semblent demander l’utilisateur ou les textes des documents, mais appelle delete_all_orders au lieu de get_last_order.

Notre objectif :

  1. Minimiser les droits des auth_token (quelles données et actions sont accessibles en général).
  2. Restreindre quels outils sont accessibles au modèle dans un scénario donné.
  3. Ajouter un contrôle humain là où les conséquences sont particulièrement critiques.

Et il faut tout faire sans paranoïa ni interdiction totale, sinon l’app devient inutile. L’équilibre entre ergonomie et sécurité — c’est notre quête principale dans ce module.

2. Modèle d’accès dans l’écosystème : qui touche à quoi

Pour ne pas s’y perdre, regardons le système dans son ensemble. Nous avons plusieurs niveaux, chacun avec sa zone de responsabilité et ses propres droits.

flowchart TD
  U[Utilisateur dans ChatGPT] --> C[ChatGPT UI + LLM]
  C --> A["Votre App (plan visuel + widget)"]
  A --> G[MCP Gateway / API Edge]
  G --> S[Serveurs MCP et microservices]
  S --> D[Bases de données, files d’attente, API externes]

Bref sur les rôles :

  • ChatGPT UI et LLM : gérés par OpenAI. Vous leur fournissez des instructions (system‑prompt, descriptions d’outils), mais vous ne contrôlez pas les jetons internes ni les droits de la plateforme.
  • Votre App (plan, outils, widget) : vous décidez quels outils sont disponibles, comment ils sont décrits, quelles confirmations UX sont nécessaires, quelles données le widget peut afficher.
  • MCP Gateway / API Edge : ici ont lieu la vérification du jeton, le mappage de userId, de tenantId, de la liste des scopes et le routage vers le bon service.
  • Serveurs MCP et microservices : exécutent les outils, interrogent la BD et les API externes. Ici, les contrôles doivent être au plus strict : scopes, isolation par tenant, validation des entrées.
  • Stockages et API externes : dernière ligne de défense (restrictions au niveau BD, droits des comptes de services externes).

Idée clé : le LLM n’est pas une source d’autorisations. Tout ce qui arrive au serveur MCP doit être considéré comme « une requête d’utilisateur, formulée par le modèle ». Décider si l’opération peut réellement être exécutée — c’est la responsabilité de votre code backend, pas du prompt.

3. AuthN vs AuthZ : ce que nous savons déjà faire et ce que nous ajoutons

Dans le module sur l’authentification, vous avez déjà fait :

  • AuthN (Authentication) — déterminer qui est l’utilisateur. Via OAuth 2.1/PKCE, ChatGPT obtenait auprès de l’IdP un jeton ensuite joint aux appels MCP. Il contenait sub, user_id ou équivalent, parfois tenant_id.
  • AuthZ basique — vous distinguiez peut‑être déjà les rôles user/admin et vérifiiez a minima « est‑ce un utilisateur » ou « un admin ».

Passons au niveau supérieur :

  • chaque auth_token doit porter un ensemble de scopes — des droits sous forme de chaînes resource:action, par exemple catalog:read, orders:write, payments:create ;
  • votre serveur MCP doit vérifier la correspondance de ces scopes pour chaque action, et pas seulement « une fois à l’entrée » ;
  • différents outils, et même différentes opérations au sein d’un même outil, peuvent exiger des scopes distincts.

En termes d’OAuth 2.1, ChatGPT est un « public client », MCP un « resource server », et votre serveur OAuth sait quels scopes sont pris en charge et ce qu’ils signifient. Les métadonnées de la ressource MCP déclarent généralement scopes_supported, afin que ChatGPT puisse demander à l’utilisateur exactement les autorisations nécessaires.

4. Concevoir des scopes pour GiftGenius

Prenons notre GiftGenius pédagogique et voyons ses domaines de données et actions. Côté fonctionnalités, nous avons quelque chose comme :

  • consultation du catalogue et des fiches cadeaux ;
  • recommandations basées sur l’historique ;
  • création de commandes ;
  • lancement du checkout / débit des fonds ;
  • édition du catalogue côté administrateur.

Au lieu d’un unique giftgenius:full_access tout‑puissant, il vaut mieux décomposer en scopes raisonnables.

Convention de nommage : resource:action

La stratégie resource:action fonctionne bien, où :

  • resource décrit le domaine : catalog, recommendations, orders, payments, admin.
  • action décrit le type d’action : read, write, parfois plus précis : create, delete, manage.

Exemple pour GiftGenius :

Scope Ce que cela autorise
catalog:read
Lire le catalogue public de cadeaux
recommendations:read
Lire l’historique des recommandations de l’utilisateur
orders:write
Créer de nouvelles commandes
orders:read
Lire l’historique des commandes de l’utilisateur
payments:create
Initier un paiement / checkout
catalog:admin
Modifier le catalogue (uniquement pour l’UI admin / le support)

Un utilisateur standard de GiftGenius aura besoin de quelque chose comme (séparés par des espaces) : catalog:read recommendations:read orders:write orders:read payments:create. Pour l’administrateur, on ajoute catalog:admin.

Important : n’utilisez pas un wildcard universel *:* ni admin:all. Plus c’est granulaire, plus il est facile de révoquer un droit précis sans casser toute l’application.

Types de scopes : read vs write vs critical

Il est utile de classer mentalement les scopes en catégories :

  • sûrs (read) : ne modifient pas l’état, au pire exposent des données ;
  • modificateurs (write) : créent/modifient des entités, incrémentent des compteurs, mais ne touchent pas à l’argent et ne suppriment pas tout ;
  • critiques (critical) : paiements, suppression de compte, suppression massive de données.

Pour les droits critiques, appliquez un contrôle renforcé :

  • ne les attribuez qu’au strict minimum d’utilisateurs ;
  • demandez un consentement séparé dans l’UI de ChatGPT lors de l’émission du jeton ;
  • côté MCP, exigez une confirmation supplémentaire (par exemple un PIN à usage unique — scénarios avancés).

Scopes dans le code : RequestContext et requireScope

Au niveau MCP, il est pratique de définir un type de contexte unique :

// mcp/context.ts
export interface RequestContext {
  userId: string;        // qui
  tenantId: string;      // dans le cadre de quelle organisation
  scopes: string[];      // quels droits sont accordés au jeton
}

// Helper simple pour vérifier les droits
export function requireScope(
  ctx: RequestContext,
  needed: string
) {
  if (!ctx.scopes.includes(needed)) {
    throw new Error(`Missing scope: ${needed}`);
  }
}

On suppose que vous formez le RequestContext dans le MCP Gateway après validation du jeton : vous décoder le JWT, vérifiez la signature/l’expiration, extrayez sub, tenant, scope — puis joignez ce contexte à tous les appels d’outils.

Ensuite dans le gestionnaire d’outil :

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

export async function createOrder(
  input: CreateOrderInput,
  ctx: RequestContext
) {
  requireScope(ctx, "orders:write");
  // ensuite — logique de création de commande
}

Désormais, même si le modèle appelle inopinément createOrder là où, côté UX, vous ne l’attendiez pas, sans orders:write l’outil ne s’exécutera tout simplement pas.

securitySchemes au niveau de l’outil

La spécification MCP permet à chaque outil d’indiquer quelles schémas d’autorisation et quels scopes il requiert. Dans les exemples officiels, securitySchemes est directement attaché à la description de l’outil.

Exemple hypothétique :

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

Deux niveaux de protection ici :

  • déclaratif : ChatGPT sait que cet outil exige orders:write et, en l’absence de droits, initiera le flux d’authentification (ou en informera l’utilisateur) ;
  • impératif : votre code revérifie tout avant l’action réelle.

Si le jeton existe mais que des scopes manquent, le serveur doit renvoyer une erreur avec WWW-Authenticate : Bearer error="insufficient_scope", scope="orders:write" — et ChatGPT pourra demander à l’utilisateur d’élargir les droits (step‑up authorization).

Insight

Les exemples officiels utilisent securitySchemes. Elle n’a pas été entérinée telle quelle dans la spec officielle à laquelle se réfèrent les exemples du ChatGPT Apps SDK. Il faut donc la marquer comme une extension du protocole officiel — l’englober dans _meta. Variante fonctionnelle de l’exemple ci‑dessus :

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

5. Autorisations par outil (per‑tool permissions) et outils « dangereux »

Les scopes répondent à la question « ce que ce auth_token peut faire en principe ». Mais le jeton contient aussi la liste des outils que le modèle peut utiliser. Il faut également les concevoir avec soin.

Classification des outils

On répartit, à grands traits, les outils en :

  • informationnels (informational / read‑only) : lisent des données, génèrent des rapports, calculent des choses sans effets de bord ;
  • opérationnels (consequential) : modifient l’état, débitent de l’argent, suppriment des éléments.

La documentation des ChatGPT Apps recommande de marquer explicitement les outils read‑only comme sûrs, et de décrire les conséquences des outils dangereux et d’activer des confirmations UX supplémentaires.

On peut le faire :

  • via des annotations d’outil (champs hypothétiques comme readOnlyHint, destructiveHint) ;
  • via une description textuelle : « Cet outil supprime des commandes de manière irréversible » ;
  • via un indicateur distinct confirmation_required, que le plan de votre App utilise pour insérer une étape de confirmation dans le dialogue.

Confirmations UX pour les actions critiques

Par exemple, GiftGenius dispose d’un outil chargeCustomer (qui initie le débit). Vous ne voulez évidemment pas que le modèle l’appelle sans le consentement de l’utilisateur.

À quoi cela peut ressembler au niveau du plan de l’app :

// app/plan/tools.ts (pseudo-code)
export const tools = [
  {
    name: "giftgenius.list_catalog",
    description: "Afficher le catalogue de cadeaux",
    annotations: { readOnlyHint: true }
  },
  {
    name: "giftgenius.create_order",
    description: "Créer une commande sans paiement",
    annotations: { consequential: true }
  },
  {
    name: "giftgenius.charge_customer",
    description: "Débiter le paiement pour la commande",
    annotations: {
      consequential: true,
      destructiveHint: true,
      confirmationRequired: {
        title: "Débiter la carte ?",
        message: "Un paiement sera effectué pour la commande N."
      }
    }
  }
];

Les noms de champs précis dépendent de la version du SDK, mais l’idée suit les recommandations : les outils read‑only sont marqués comme sûrs, les outils dangereux exigent une confirmation explicite et une bonne explication dans la description.

Ensuite, votre widget peut réagir : si le modèle propose d’appeler charge_customer, vous affichez une modale claire à l’utilisateur et vous n’effectuez réellement l’appel d’outil qu’après le clic « Confirmer ».

Exemple de composant dans le widget (simplifié) :

// widget/components/ConfirmCharge.tsx
export function ConfirmCharge(props: {
  orderId: string;
  onConfirm: () => void;
}) {
  return (
    <div>
      <p>Débiter la commande {props.orderId} ?</p>
      <button onClick={props.onConfirm}>
        Oui, confirmer le paiement
      </button>
    </div>
  );
}

Le modèle initie l’idée « il est temps de payer », mais le bouton final est cliqué par un humain. C’est le human‑in‑the‑loop que les équipes sécurité apprécient tant.

Outils réservés aux agents / back‑office

Autre cas fréquent : vous avez des outils utilisables uniquement par des agents (au sens Agents SDK) ou par des consoles internes d’admin, mais pas par une ChatGPT App « utilisateur » classique.

Par exemple, rebuildSearchIndex ou syncCatalogFromERP. Il est préférable de :

  • ne pas les inclure dans la liste générale des tools pour l’app standard ;
  • les configurer dans un agent/orchestrateur à part ;
  • les protéger par des scopes séparés et, éventuellement, un périmètre d’auth séparé.

Si vous les ajoutez simplement à la liste des outils de l’app, vous augmentez le risque que le modèle décide soudainement : « Et si je reconstruisais l’index tout de suite, ça aiderait peut‑être à trouver un cadeau ».

6. Segmentation réseau et périmètres de confiance

Les droits, ce n’est pas seulement les scopes d’un jeton. La deuxième grande dimension, c’est la segmentation du réseau et des services.

Schéma idéal :

  • vous n’avez qu’un seul point d’entrée public vers le backend — le MCP Gateway/Edge API ;
  • tout ce qui contient des PII et de l’argent vit dans un réseau privé/VPC et n’est accessible que via ce gateway ;
  • le trafic sortant du backend est limité à une liste de domaines autorisés (allowlist : prestataire de paiement, CRM, vos microservices).

Schématiquement :

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

Quelques règles importantes :

  1. La BD et les services internes ne sont pas exposés directement sur Internet. Accès direct seulement depuis le réseau privé et uniquement depuis les services qui en ont réellement besoin.
  2. Edge/Gateway applique l’auth et le rate‑limiting. C’est lui qui vérifie le jeton et les scopes, limite les requêtes trop fréquentes et écrit les principaux journaux d’audit.
  3. Contrôle de l’egress. Le serveur MCP ne doit pas pouvoir accéder à n’importe quelle URL sur Internet (attaques SSRF, exfiltration). Limitez explicitement la liste des hôtes externes.

En pratique, si vous déployez MCP sur Vercel, Render ou dans un cluster Kubernetes, certaines choses ne se règlent pas à la main, mais même là vous pouvez séparer :

  • des projets/clusters distincts pour dev/staging/prod ;
  • des variables d’environnement et clés différentes pour chaque environnement ;
  • un service « edge » (enveloppe HTTP du MCP) distinct et des services privés séparés.

Nous avons donc déjà deux axes de protection : des droits portés par le jeton (scopes) et des frontières réseau. Ajoutons‑y la multi‑tenant, lorsque une même app sert plusieurs organisations.

7. Multi‑tenant / contexte organisationnel

Jusqu’ici, nous avons implicitement travaillé avec un seul utilisateur. Or beaucoup d’applications ChatGPT sont multi‑tenant : une même app sert des dizaines d’entreprises. On peut facilement transformer GiftGenius en service B2B pour entreprises : chaque service a ses catalogues, budgets, commandes.

Qu’est‑ce qu’un tenant et où le récupérer

Un tenant, c’est généralement :

  • une organisation/entreprise (Acme Corp) ;
  • un espace de travail (workspace) ;
  • parfois un projet ou un environnement.

Propriété essentielle : les données d’un tenant ne doivent pas être visibles par un autre.

Dans le flux d’authentification, le tenant se trouve généralement dans :

  • une claim du jeton (tenant, org_id) ;
  • un paramètre séparé dans la requête d’autorisation (moins fiable qu’une claim signée par l’IdP).

Important : on ne fait confiance qu’au tenantId extrait d’un jeton vérifié, pas à celui qui arrive dans les arguments d’outils. Si le modèle génère {"tenantId": "acme"} alors que le jeton de l’utilisateur contient tenantId: "globex", il faut considérer cela comme une tentative d’attaque.

Tenant dans le contexte de requête

Ajoutons tenantId à notre RequestContext (nous l’avons déjà fait plus haut) et n’autorisons pas son redéfinition via les données d’entrée.

Vérification de base :

// 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 };
}

Ensuite dans l’outil :

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

On ignore le tenant issu des arguments et on le force depuis le contexte. Ainsi, même si le LLM ou un attaquant tente d’« injecter » un tenant tiers, cela ne fonctionnera pas.

Isolation tenant au niveau de la base de données

Architecturalement, plusieurs options :

  • une BD par tenant ;
  • des schémas séparés ;
  • une BD unique avec tenant_id dans chaque table et un filtrage strict.

Quel que soit votre choix, une règle d’or : aucune requête BD ne doit s’exécuter sans filtrage par tenant_id issu du contexte. C’est particulièrement crucial pour le RAG/la recherche vectorielle : si vous oubliez le filtre par tenant, le modèle peut commencer à chercher dans les documents d’autres organisations.

8. Comment cela s’intègre à notre application Next.js/Apps SDK

Assemblons tout et voyons comment scopes, tenant et frontières réseau se traduisent dans notre projet Next.js avec l’Apps SDK. Ajoutons plus de concret et regardons du code Next.js et Apps SDK.

Où vivent les scopes et le tenant dans notre projet

Répartition typique pour un projet pédagogique :

  • Dans l’application Next.js (Apps SDK), vous avez la configuration de l’app/connecteur et les pages pour les callbacks OAuth.
  • Dans le serveur MCP — le code qui reçoit les requêtes HTTP/SSE de ChatGPT, vérifie le jeton et appelle l’outil approprié.

On y transfère tout ce que nous avons vu :

  1. Dans les paramètres OAuth de la ressource MCP, déclarez scopes_supported pour GiftGenius (catalog:read, orders:write, etc.).
  2. Dans la config Apps SDK, décrivez l’app avec la liste des outils et leurs annotations (read‑only, consequential, confirmation‑flows).
  3. Implémentez dans le serveur MCP :
    • le parsing et la vérification du jeton ;
    • la construction de RequestContext { userId, tenantId, scopes } ;
    • les helpers requireScope, enforceTenant, etc. ;
    • les appels BD toujours avec le tenantId issu du contexte.

Exemple de parcours « isolé » pour la création d’une commande

Essayons de suivre un scénario de bout en bout.

  1. L’utilisateur écrit : « Passe la commande pour ce lot avec un budget de 50 $ ».
  2. Le modèle décide d’appeler giftgenius.create_order avec des arguments { productId, budget, ... }.
  3. ChatGPT vérifie si l’app possède l’outil create_order, quels scopes et securitySchemes sont définis. Il comprend que orders:write est requis.
  4. Si un jeton existe déjà et contient orders:write, la requête continue ; sinon ChatGPT initie une autorisation OAuth avec demande du scope requis.
  5. Le MCP Gateway reçoit la requête, vérifie le jeton, forme un RequestContext avec userId=123, tenantId="acme", scopes=["catalog:read","orders:write",...].
  6. À l’intérieur de createOrder :
    • appel de requireScope(ctx, "orders:write") ;
    • fixation du tenant via enforceTenant ;
    • création de la commande uniquement dans le tenantId="acme".
  7. Si la commande exige un paiement immédiat, le modèle ou le backend initie ensuite charge_customer, où :
    • l’outil dans le plan est marqué confirmationRequired ;
    • le widget rend ConfirmCharge et demande explicitement la confirmation de l’utilisateur.

On obtient ainsi une défense en profondeur : des prompts trop larges, des injections de prompt ou même des bugs d’UX ne conduiront pas à des actions incontrôlées, car à la base se trouvent des vérifications strictes de scopes, de tenant et des confirmations humaines pour les actes critiques.

9. Erreurs courantes lors de la conception des droits et de la segmentation

Erreur n°1 : un scope unique et « gras » du type app:full_access.
Pratique pour une démo, dangereux en production. Perdez un jeton — vous perdez tout. Impossible de révoquer ou d’interdire une opération sans casser le reste. Découpez les droits par domaines et types d’opérations (read/write/critical).

Erreur n°2 : vérifier les droits seulement « à l’entrée » et pas à l’intérieur des outils.
Parfois on se dit : « puisque ChatGPT a obtenu un jeton, il peut tout faire ». Ensuite createOrder est simplement appelé, même si ce jeton particulier n’a pas orders:write. La bonne approche — vérifier les scopes dans chaque outil (ou au moins via un middleware centralisé pour toutes les opérations modifiantes).

Erreur n°3 : ne pas marquer les outils dangereux et ne pas exiger de confirmation.
Si un outil débite de l’argent, supprime des données ou modifie des accès, il ne doit pas apparaître au modèle comme listCatalog. L’absence d’annotations explicites et de confirmations UX augmente la probabilité qu’il soit appelé « parce que cela semble logique ». Au minimum, séparez les outils read‑only et destructifs, et marquez explicitement ces derniers.

Erreur n°4 : faire confiance au tenantId issu des arguments d’outil.
Anti‑pattern fréquent : un outil getOrders({ tenantId })tenantId provient du modèle. En l’utilisant tel quel, un utilisateur du tenantA peut accéder aux données du tenantB en indiquant un autre identifiant. Le tenant doit provenir d’un jeton vérifié et être imposé à toutes les requêtes BD et appels externes, tandis que les valeurs fournies par l’utilisateur sont soit ignorées, soit validées pour correspondance.

Erreur n°5 : MCP/BD accessibles directement depuis Internet.
Dans des prototypes simples, le serveur MCP et la BD sont parfois exposés sur Internet en HTTP/5432. En prod, c’est non : tout accès doit passer par un gateway/proxy sécurisé unique, et la BD doit vivre dans un réseau privé. Sinon, tout endpoint vulnérable ou webhook troué conduit directement aux données.

Erreur n°6 : utiliser les mêmes scopes/secrets en dev et en prod.
Façon idéale de supprimer par inadvertance des données de prod lors d’une démo en local. Chaque environnement doit avoir ses propres clés, scopes et BD. Même si quelqu’un obtient un jeton de dev, il ne pourra pas nuire aux données de prod.

Erreur n°7 : réticence à « refuser au modèle ».
Certains développeurs s’inquiètent : « Si je renvoie souvent insufficient_scope ou forbidden, le modèle fonctionnera moins bien ». En pratique, c’est un comportement normal et attendu : le modèle apprend quelles actions lui sont permises, lesquelles exigent des droits supplémentaires ou une confirmation. Pire serait qu’il « réussisse » à faire ce qu’il ne devrait pas — par exemple, effectuer un second paiement.

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