Skip to main content

Une passerelle pour chaque jeton : identité Entra ID, plafonds et bascule de modèle avec agentgateway

🏔️ Chalet Azure Romandie - Tesla Store Genève 29 octobre 2026

Updated 9 min readMarkdown
Part of seriesIA agentique : vers Azure et au-delà !
Une passerelle pour chaque jeton : identité Entra ID, plafonds et bascule de modèle avec agentgateway

Premier article d'une série de cinq tirée du talk « IA agentique : vers Azure et au-delà ! » (Chalet Azure Romandie, Genève, 29 octobre 2026). Tout le code est dans le dépôt aloizeau/agentic-ai-azure-and-beyond-geneve-2026, et les slides en PDF sont ici.

Avec une HTTPRoute et une AgentgatewayPolicy, plus aucun jeton LLM ne passe sans identité ni plafond. C'était la démo A du talk de Genève, et le reste de la plateforme s'appuie dessus.

J'ai ouvert le talk sur un scénario banal. Un vendredi soir, une clé d'API Foundry part dans un dépôt ou un script partagé ; quelqu'un s'en sert tout le week-end et la facture arrive le lundi. Dans ce chemin, rien ne savait qui appelait ni combien. Voici comment le dépôt le ferme, ligne par ligne.

Le principe : personne n'appelle un fournisseur

LibreChat et les agents kagent n'ont ni URL Foundry, ni clé, ni identité Azure. Ils appellent la passerelle, et elle seule parle aux modèles.

Personnes ─► Entra ID ─► LibreChat ──/v1 (jeton utilisateur)──┐
                                                            ├─► agentgateway ──► Foundry : Terra → Luna
Équipes ─► agents kagent ──/agents/v1 (clé d'équipe)──────┘   identité · plafonds · règles · métriques

Le même backend a deux portes, déclarées dans gateway/deploy/routes.yaml. Les personnes entrent par /v1 avec leur jeton d'accès Entra ID, les agents par /agents/v1 avec la clé virtuelle de leur équipe.

Les politiques s'attachent aux routes. Passer de Terra à Luna, ajouter Bedrock ou un modèle auto-hébergé sur AKS ne change donc ni l'authentification, ni les plafonds, ni les métriques.

Le backend : Foundry sans clé, avec bascule automatique

Le backend llm déclare des groupes de fournisseurs par ordre de priorité. Le groupe 0 sert le trafic quotidien et le groupe 1 prend le relais.

Fichier : gateway/deploy/backend-llm.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: llm
spec:
  ai:
    groups:
      - providers:                       # groupe 0 : production quotidienne
          - name: foundry-terra
            azure:
              resourceName: fdy-REPLACE-ME  # terraform output foundry_account_name
              resourceType: Foundry
              projectName: proj-REPLACE-ME
              model: gpt-56-terra-eu      # DataZoneStandard : traitement UE
      - providers:                       # groupe 1 : repli rapide et économique
          - name: foundry-luna
            azure:
              resourceName: fdy-REPLACE-ME
              resourceType: Foundry
              projectName: proj-REPLACE-ME
              model: gpt-56-luna-eu
  policies:
    auth:
      azure:
        workloadIdentity: {}             # aucune clé : identité de charge AKS
    ai:
      routes:
        "/v1/chat/completions": "Completions"

Aucune clé Foundry n'existe : le compte est déployé avec local_auth_enabled = false (module terraform/modules/ai), et le proxy obtient un jeton par Workload Identity grâce au rôle Cognitive Services User sur le compte. Il ne reste aucune clé à faire fuiter.

Le nom de modèle envoyé par le client est ignoré. Le client envoie "model": "llm" et chaque fournisseur épingle son déploiement, si bien que les agents ne connaissent jamais le nom d'un déploiement. Pour ajouter Bedrock ou OVHcloud, on ajoute un groupe, sans rien changer côté agents.

La bascule tient en deux politiques. La première écarte pendant 60 secondes un fournisseur qui répond 404, 429 ou 5xx. La seconde rejoue une fois la requête en échec pour qu'elle atterrisse sur le repli, et ajoute un en-tête qui indique quel modèle a répondu.

Fichier : gateway/deploy/policies.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: llm-resilience
spec:
  targetRefs:
    - { group: agentgateway.dev, kind: AgentgatewayBackend, name: llm }
  backend:
    health:
      unhealthyCondition: "response.code == 404 || response.code == 429 || response.code >= 500"
      eviction:
        consecutiveFailures: 1
        duration: 60s
        restoreHealth: 100
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: llm-retry-tracabilite
spec:
  targetRefs:
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm }
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm-agents }
  traffic:
    retry:
      attempts: 1
      codes: [404, 429, 500, 502, 503]
    transformation:
      response:
        add:
          - name: x-served-model
            value: 'default(llm.responseModel, "unknown")'

Le 429 figure dans la condition parce qu'un quota TPM Foundry épuisé répond 429. La passerelle bascule alors sur Luna pendant une minute au lieu de renvoyer l'erreur à l'utilisateur. L'en-tête x-served-model rend la bascule visible au premier appel, et le tableau de bord des coûts montre la part de Luna qui monte.

Porte 1 : un jeton Entra ID v2, ou rien

Sur la route des personnes, la politique acces-llm exige un jeton d'accès Entra ID valide. Sans lui, la passerelle répond 401 et n'appelle jamais le modèle.

Fichier : gateway/deploy/policies.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: acces-llm
spec:
  targetRefs:
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: llm }
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp }          # les outils aussi
    - { group: gateway.networking.k8s.io, kind: HTTPRoute, name: mcp-equipes }
  traffic:
    jwtAuthentication:
      mode: Strict
      providers:
        - issuer: https://login.microsoftonline.com/TENANT_ID/v2.0
          audiences: [GATEWAY_API_CLIENT_ID]      # jetons v2 : aud = GUID du client ID
          jwks:
            remote:
              url: https://login.microsoftonline.com/TENANT_ID/discovery/v2.0/keys
    rateLimit:
      local:
        - tokens: 50000
          unit: Minutes

Attention à la version du jeton. Par défaut, une inscription d'application Entra ID émet des jetons v1, dont l'émetteur est sts.windows.net/<tenant>/ et l'audience l'URI api://…. La politique attend des jetons v2 ; le module Terraform identity pose donc requested_access_token_version = 2 sur l'application de la passerelle. Avec un jeton v2, l'audience est le GUID du client ID.

La même politique couvre les routes MCP. Un outil appelé depuis LibreChat passe par la même porte qu'un prompt, et la passerelle sait qui a appelé quel outil.

Porte 2 : une clé virtuelle par équipe

Un agent n'a pas d'utilisateur derrière lui. Il s'authentifie avec la clé de son équipe, que Terraform crée en deux exemplaires, l'un pour la passerelle et l'autre pour l'équipe.

Fichier : terraform/modules/platform/teams.tf

resource "kubernetes_secret" "virtual_key" {
  for_each = local.key_holders
  metadata {
    name      = "vk-${each.key}"
    namespace = "agentgateway-system"
    labels    = { "agentic.io/virtual-key" = "true" }
  }
  data = {
    (each.key) = jsonencode({
      key      = "sk-${each.key}-${random_password.team_key[each.key].result}"
      metadata = { team = each.key }          # sert à l'attribution des coûts
    })
  }
}

La politique acces-agents sélectionne les Secrets par label au lieu de lister des clés. Onboarder une équipe ne touche donc jamais la configuration de la passerelle.

traffic:
  apiKeyAuthentication:
    mode: Strict
    secretSelector:
      matchLabels:
        agentic.io/virtual-key: "true"

Le champ metadata.team sert ensuite partout : les métriques (article 5) et la liste blanche d'outils par équipe (article 4) le lisent en CEL sous le nom apiKey.team.

Un plafond en jetons LLM

L'étape 3 de la démo ajoute un plafond à la politique d'identité. Il compte les jetons LLM, en entrée et en sortie, par minute.

Fichier : demos/A-ratelimit-entra/3-plafond.yaml

rateLimit:
  local:
    - tokens: 400            # démo ; la plateforme utilise 50 000
      unit: Minutes

Une requête qui envoie 100 000 jetons de contexte coûte autant que mille petites questions, et un plafond en requêtes HTTP ne la verrait pas. Pour les outils MCP, le coût est l'appel lui-même ; la plateforme les plafonne donc en requêtes (requests: 120 par minute sur la route des outils d'entreprise, voir l'article 4).

Tester depuis un poste Windows

La passerelle est en ClusterIP et rien n'est exposé sur Internet par défaut. On l'atteint par port-forward, dans une fenêtre à part :

kubectl -n agentgateway-system port-forward svc/agentgateway-proxy 8080:80

Le jeton vient d'Azure CLI, pré-autorisée sur l'application de la passerelle. J'utilise HttpClient plutôt qu'Invoke-WebRequest, qui lève une exception sur un 401 ou un 429 sous Windows PowerShell 5.1 ; or c'est justement le code HTTP qu'on veut afficher. Le script complet de la démo, avec ses fonctions communes, est dans demos/A-ratelimit-entra/run.ps1 et demos/lib.ps1 ; voici l'essentiel.

Add-Type -AssemblyName System.Net.Http
$http  = [System.Net.Http.HttpClient]::new()
$scope = terraform -chdir=terraform output -raw gateway_scope
$token = az account get-access-token --scope $scope --query accessToken -o tsv

function Send-Chat([string]$Token) {
  $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post,
         'http://localhost:8080/v1/chat/completions')
  if ($Token) {
    $req.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $Token)
  }
  $body = @{ model = 'llm'; max_tokens = 150
             messages = @(@{ role = 'user'; content = 'Trois phrases sur les Alpes suisses.' }) } |
          ConvertTo-Json -Depth 5 -Compress
  $req.Content = [System.Net.Http.StringContent]::new($body, [Text.Encoding]::UTF8, 'application/json')
  $resp  = $http.SendAsync($req).GetAwaiter().GetResult()
  $model = ($resp.Headers | Where-Object Key -eq 'x-served-model').Value -join ','
  '{0}  modèle servi : {1}' -f [int]$resp.StatusCode, $model
}

Send-Chat                                  # 401 : refusé à la porte
Send-Chat $token                           # 200 : la passerelle sait QUI consomme
1..10 | ForEach-Object { Send-Chat $token } # 429 dès que le plafond est atteint

Si la réponse reste 401 avec un jeton, regardez son contenu avant de chercher ailleurs. La passerelle vérifie la signature ; ce décodage sert seulement au diagnostic.

$p = $token.Split('.')[1].Replace('-', '+').Replace('_', '/')
while ($p.Length % 4) { $p += '=' }
[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) |
  ConvertFrom-Json | Select-Object ver, iss, aud, preferred_username
# attendu : ver 2.0, iss https://login.microsoftonline.com/<tenant>/v2.0, aud = client ID de la passerelle

Repérer les routes sans politique

J'ai mis l'étape 0 dans la démo parce qu'elle arrive vraiment : une HTTPRoute créée « pour aller vite », sans politique, sert le même modèle à n'importe qui (voir 1-route-ouverte.yaml). Les politiques visent les routes par leur nom, et une nouvelle route n'hérite de rien.

Ce contrôle se lance en CI ou avant chaque démo. Il liste les routes du namespace de la passerelle qu'aucune politique d'authentification ne vise. Il ne fait pas partie du dépôt.

$ns     = 'agentgateway-system'
$routes = (kubectl -n $ns get httproute -o json | ConvertFrom-Json).items.metadata.name
$auth   = (kubectl -n $ns get agentgatewaypolicy -o json | ConvertFrom-Json).items |
  Where-Object { $_.spec.traffic.jwtAuthentication -or $_.spec.traffic.apiKeyAuthentication }
$covered = $auth | ForEach-Object { $_.spec.targetRefs } |
  Where-Object kind -eq 'HTTPRoute' | ForEach-Object name
$routes | Where-Object { $_ -notin $covered } |
  ForEach-Object { Write-Warning "Route sans authentification : $_" }

Il ne voit que ce namespace. Une équipe qui attacherait sa propre route au Gateway depuis son namespace passerait à côté ; c'est pourquoi je restreindrais allowedRoutes du Gateway aux namespaces de la plateforme, comme le fait déjà le Gateway public (gateway/public/gateway.yaml) avec le label agentic.io/public=true.

Les limites que j'assume

Le plafond local vaut par route et par réplica du proxy : avec trois réplicas, il triple. Un vrai budget par équipe ou par personne demande le rate limit global d'agentgateway et un service de rate limit externe. Les clés virtuelles portent déjà le nom de l'équipe pour le jour où on le branchera.

LibreChat ne transmet le jeton Entra ID de l'utilisateur qu'à partir de la v0.8.8 (chart 2.0.14). Avant cette version, il faut une clé partagée et l'identité se perd à la porte.

Les proxys utilisent l'identité de charge de la passerelle, et le contrôleur nomme le ServiceAccount de chaque proxy d'après son Gateway. Si Foundry répond 401, vérifiez-le avec kubectl -n agentgateway-system get sa.

Le code

Le prochain article traite des garde-fous de contenu qui suivent le trafic d'un fournisseur à l'autre.

About the author

  • Ayant eu de multiples expériences dans le monde du conseil, j'ai pu acquérir une expertise dans la conception et la construction de services de collaboration d'entreprise. Je suis passionné par les technologies Microsoft et surtout Azure. Aujourd'hui je possède une solide expérience en méthodologie de développement et j'ai mené des équipes de développement technique au succès. Je possède également une solide connaissance de l'infrastructure qui fait de moi une ressource efficace pour mettre en œuvre la transformation numérique vers le cloud Microsoft. Je suis un professionnel efficace et honnête qui aime relever les challenges. Aimant partager mes connaissances; je suis à l'aise en tant que Technical Leader et en tant que membre d'une équipe. Mes compétences techniques sont les suivantes: Azure, DevOps, Architecture Applicative, Développement de solution Cloud Native, écosystème Microsoft... et bien d'autres. Pour voir mes certifications Microsoft : https://www.youracclaim.com/users/antoine-loizeau

No comments yet. Be the first to comment.

IA agentique : vers Azure et au-delà !

Part 1 of 1

Série d'article tirée du talk « IA agentique : vers Azure et au-delà ! » (Chalet Azure Romandie, Genève, 29 octobre 2026). Tout le code est dans le dépôt aloizeau/agentic-ai-azure-and-beyond-geneve-2026.