# 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](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026), et les slides en PDF sont [ici](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/slides/rex-architectures-ia-entreprise-clair.pdf).

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.

```plaintext
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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/gateway/deploy/backend-llm.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/gateway/deploy/policies.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/gateway/deploy/policies.yaml)

```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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/modules/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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/platform/teams.tf)

```hcl
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.

```yaml
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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/demos/A-ratelimit-entra/3-plafond.yaml)

```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 :

```powershell
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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/demos/A-ratelimit-entra/run.ps1) et [`demos/lib.ps1`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/demos/lib.ps1) ; voici l'essentiel.

```powershell
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.

```powershell
$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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/demos/A-ratelimit-entra/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.

```powershell
$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`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/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

- [`gateway/deploy/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/gateway/deploy) : Gateway, backend, routes et politiques
- [`demos/A-ratelimit-entra/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/demos/A-ratelimit-entra) : la démo en trois manifestes et son script PowerShell
- [`terraform/modules/platform/teams.tf`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/platform/teams.tf) : les clés virtuelles d'équipe
- [`docs/architecture/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/docs/architecture) : les schémas, dont le flux d'une requête

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