# « Agent Ready » : onboarder une équipe sur une plateforme agentique, sans ticket et avec des droits bornés

> Troisième article de la série tirée du talk « IA agentique : vers Azure et au-delà ! » (Genève, 29 octobre 2026). Code complet dans [aloizeau/agentic-ai-azure-and-beyond-geneve-2026](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026), dossiers [`terraform/modules/agent-ready`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/modules/agent-ready) et [`terraform/teams`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/teams).

Une équipe de développement qui veut son agent l'obtient en une exécution de pipeline approuvée : deux identités sans secret, un stockage, un namespace avec quota, une clé de passerelle et un dépôt GitHub prêt à déployer. C'était la démo B du talk. Automatiser la création est simple ; le plus dur est de donner au pipeline juste les droits nécessaires.

Deux mécanismes bornent ces droits : une condition ABAC sur la délégation de rôles Azure, et une ValidatingAdmissionPolicy CEL côté Kubernetes.

## Deux équipes, deux pipelines

| | Équipe IA (plateforme) | Équipe de développement |
| --- | --- | --- |
| Déclenche | [`agent-ready.yml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/.github/workflows/agent-ready.yml) (workflow_dispatch) | un push sur `main` de son dépôt `agent-<équipe>` |
| Approbation | environnement GitHub `ai-team`, relecteur obligatoire | aucune, elle ne touche que son namespace |
| Exécute | Terraform `terraform/teams`, un état par équipe | « 1 · Image » (build vers ACR) puis « 2 · Déploiement » |
| Identité | `id-<base>-ai-team`, fédérée sur l'environnement `ai-team` | `id-<équipe>-pipeline`, fédérée sur la branche `main` de son dépôt |

Les workflows de l'équipe de dev font une dizaine de lignes et appellent les workflows réutilisables de l'équipe IA ([`agent-image.yml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/.github/workflows/agent-image.yml), [`agent-deploy.yml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/.github/workflows/agent-deploy.yml)). L'équipe IA change la façon de construire et de déployer à un seul endroit.

Ce montage tient grâce à un comportement d'OIDC : quand un dépôt appelle un workflow réutilisable d'un autre dépôt, le sujet du jeton reste le dépôt et la branche de l'appelant. La fédération de l'identité d'équipe peut donc viser `repo:<owner>/agent-<équipe>:ref:refs/heads/main`, même si le code exécuté vient du dépôt de la plateforme.

## Ce qu'une équipe reçoit en un apply

Tout est dans [`terraform/teams/main.tf`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/teams/main.tf).

| Couche | Ressources | Usage |
| --- | --- | --- |
| Azure | identité du pipeline, fédérée GitHub | pousser l'image et les documents, déployer |
| Azure | identité du serveur MCP, fédérée AKS (`system:serviceaccount:<équipe>:docs-mcp`) | lire les documents |
| Azure | compte de stockage `shared_access_key_enabled = false` et conteneur `documents` | aucune clé de compte à faire fuiter |
| Azure | 4 attributions de rôle : Blob Data Contributor, Blob Data Reader, AcrPush, AKS Cluster User | les droits des deux identités |
| Kubernetes | namespace `agentic.io/team` et `agentic.io/agent-ready`, ResourceQuota, LimitRange | périmètre et limites de capacité |
| Kubernetes | clé virtuelle `vk-<équipe>` et sa copie `gateway-key` | accès à `/agents/v1` (article 1) |
| Kubernetes | RoleBinding `agentic-team-deployer` pour l'identité du pipeline | déployer dans son namespace |
| GitHub | dépôt privé `agent-<équipe>` créé depuis le modèle, variables Actions, deux pipelines | partir d'un squelette qui déploie déjà |

Chaque équipe a son état Terraform (`teams/<équipe>.tfstate`). Pour retirer une équipe, un `terraform destroy` sur cet état supprime tout ce qu'elle a reçu sans toucher aux autres.

## Borner Azure : la délégation de rôles sous condition ABAC

Pour attribuer quatre rôles aux identités qu'il vient de créer, le pipeline a besoin de Role Based Access Control Administrator. Sans condition, ce rôle lui permettrait de s'attribuer Owner. Azure permet de contraindre la délégation par une condition ABAC, et le module `agent-ready` n'autorise que quatre rôles, vers des principaux de service uniquement.

Fichier : [`terraform/modules/agent-ready/main.tf`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/agent-ready/main.tf)

```hcl
locals {
  delegable = join(", ", [
    local.role_ids.storage_blob_data_contributor, # pipeline d'équipe → ses documents
    local.role_ids.storage_blob_data_reader,      # serveur MCP d'équipe → ses documents
    local.role_ids.acr_push,                      # pipeline d'équipe → registre
    local.role_ids.aks_cluster_user,              # pipeline d'équipe → kubeconfig Entra ID
  ])

  delegation_condition = <<-EOT
    (
     (
      !(ActionMatches{'Microsoft.Authorization/roleAssignments/write'})
     )
     OR
     (
      @Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.delegable}}
      AND
      @Request[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEqualsIgnoreCase {'ServicePrincipal'}
     )
    )
    AND
    (
     (
      !(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'})
     )
     OR
     (
      @Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${local.delegable}}
      AND
      @Resource[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEqualsIgnoreCase {'ServicePrincipal'}
     )
    )
  EOT
}

resource "azurerm_role_assignment" "ai_team_delegation" {
  for_each           = local.delegation_scopes   # rg-<base>-equipes, le registre, le cluster
  scope              = each.value
  role_definition_id = local.role_def.rbac_administrator
  principal_id       = azurerm_user_assigned_identity.ai_team[0].principal_id
  principal_type     = "ServicePrincipal"
  condition_version  = "2.0"
  condition          = local.delegation_condition
}
```

La condition a deux blocs symétriques. Le premier n'autorise l'écriture d'une attribution que si le rôle demandé (`@Request`) fait partie des quatre GUID et si le bénéficiaire est un principal de service ; le pipeline ne peut donc donner aucun droit à une personne ni à un groupe. Le second n'autorise la suppression que sur une attribution existante (`@Resource`) du même type. Sans lui, le pipeline pourrait supprimer l'attribution Owner d'un administrateur.

La portée compte autant que la condition. Le rôle n'est posé que sur le groupe de ressources des équipes, le registre et le cluster, et le pipeline n'est Contributeur que sur `rg-<base>-equipes`.

Pour vérifier, ajoutez ces commandes à une exécution du workflow [`agent-ready.yml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/.github/workflows/agent-ready.yml), qui tourne sous l'identité de l'équipe IA, et tentez une attribution interdite. Elle doit échouer avec une erreur d'autorisation liée à la condition :

```powershell
$rg  = az group show -n rg-<base>-equipes --query id -o tsv
$sp  = az identity show -g rg-<base>-equipes -n id-equipe-docs-pipeline --query principalId -o tsv

# Autorisé : un des quatre rôles, vers un principal de service
az role assignment create --assignee-object-id $sp --assignee-principal-type ServicePrincipal `
  --role "Storage Blob Data Reader" --scope $rg

# Refusé : Contributor ne fait pas partie des rôles délégables
az role assignment create --assignee-object-id $sp --assignee-principal-type ServicePrincipal `
  --role "Contributor" --scope $rg
```

## Borner Kubernetes : RBAC pour les verbes, CEL pour le périmètre

Dans le cluster, le pipeline crée des namespaces, des quotas, des Secrets et des RoleBindings. RBAC sait autoriser la création de Secrets, mais pas la limiter aux namespaces d'équipe ou aux objets qui portent un label donné. La ClusterRole donne donc seulement les verbes :

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_cluster_role" "team_provisioner" {
  metadata { name = "agentic-team-provisioner" }
  rule {
    api_groups = [""]
    resources  = ["namespaces", "resourcequotas", "limitranges", "secrets"]
    verbs      = ["get", "create", "update", "patch", "delete"]
  }
  rule {
    api_groups = ["rbac.authorization.k8s.io"]
    resources  = ["rolebindings"]
    verbs      = ["get", "create", "update", "patch", "delete"]
  }
  rule {
    # Sans bind, Kubernetes refuse un RoleBinding vers des droits
    # que le pipeline ne détient pas lui-même (contrôle d'escalade).
    api_groups     = ["rbac.authorization.k8s.io"]
    resources      = ["clusterroles"]
    verbs          = ["bind"]
    resource_names = ["agentic-team-deployer"]
  }
}
```

Le verbe `bind`, limité à une seule ClusterRole, est peu connu. Il permet au pipeline de lier `agentic-team-deployer` sans en détenir lui-même les droits, et l'empêche de lier `cluster-admin`.

Le périmètre vient d'une `ValidatingAdmissionPolicy` (Kubernetes 1.30 et plus). Elle ne s'applique qu'à l'identité du pipeline et vérifie l'objet avant et après chaque modification :

Fichier : [`terraform/modules/platform/manifests/agent-ready-admission.yaml.tftpl`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/platform/manifests/agent-ready-admission.yaml.tftpl)

```yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: agent-ready-perimetre
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE", "DELETE"]
        resources: ["namespaces", "secrets", "resourcequotas", "limitranges"]
      - apiGroups: ["rbac.authorization.k8s.io"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE", "DELETE"]
        resources: ["rolebindings"]
  matchConditions:
    - name: pipeline-agent-ready
      expression: request.userInfo.username == '${principal_id}'
  variables:
    - name: objets            # le nouvel objet ET l'existant
      expression: >-
        request.operation == 'CREATE' ? [object] :
        (request.operation == 'DELETE' ? [oldObject] : [object, oldObject])
    - name: equipe            # le namespace cible est-il un namespace Agent Ready ?
      expression: >-
        request.resource.resource != 'namespaces' &&
        has(namespaceObject.metadata.labels) &&
        'agentic.io/agent-ready' in namespaceObject.metadata.labels &&
        namespaceObject.metadata.labels['agentic.io/agent-ready'] == 'true'
  validations:
    - expression: >-
        request.resource.resource != 'namespaces' ||
        variables.objets.all(o, has(o.metadata.labels) &&
          'agentic.io/agent-ready' in o.metadata.labels && o.metadata.labels['agentic.io/agent-ready'] == 'true' &&
          'agentic.io/team' in o.metadata.labels && o.metadata.labels['agentic.io/team'] == o.metadata.name)
      message: "namespace hors périmètre Agent Ready"
    - expression: >-
        request.resource.resource != 'secrets' || variables.equipe ||
        (request.namespace == 'agentgateway-system' &&
         variables.objets.all(o, o.metadata.name.startsWith('vk-') && has(o.metadata.labels) &&
           'agentic.io/virtual-key' in o.metadata.labels && o.metadata.labels['agentic.io/virtual-key'] == 'true' &&
           'agentic.io/agent-ready' in o.metadata.labels && o.metadata.labels['agentic.io/agent-ready'] == 'true'))
      message: "Secret hors périmètre : namespace Agent Ready, ou clé virtuelle vk-<équipe> créée par Agent Ready"
    - expression: >-
        request.resource.resource in ['namespaces', 'secrets'] || variables.equipe
      message: "quota, limites et RoleBinding : uniquement dans un namespace Agent Ready"
```

La vérification de `oldObject` empêche un détournement simple. Sans elle, le pipeline pourrait poser `agentic.io/agent-ready=true` sur le namespace `kagent` ou `agentgateway-system`, puis y créer ce qu'il veut. Comme l'objet existant doit déjà être dans le périmètre, ce réétiquetage échoue. La deuxième validation protège les clés de la plateforme : dans `agentgateway-system`, le pipeline n'atteint que les Secrets `vk-*` marqués par Agent Ready.

`kubectl auth can-i` ne teste que RBAC, pas l'admission. Pour tester le périmètre, faites un dry-run côté serveur en vous faisant passer pour l'identité du pipeline, avec le kubeconfig admin. Sur AKS avec Entra ID, Kubernetes connaît une identité managée par son object ID :

```powershell
$oid = az identity show -g <rg-plateforme> -n id-<base>-ai-team --query principalId -o tsv

# Doit passer : un namespace d'équipe correctement marqué
@'
apiVersion: v1
kind: Namespace
metadata:
  name: equipe-test
  labels: { agentic.io/team: equipe-test, agentic.io/agent-ready: "true" }
'@ | kubectl apply --dry-run=server --as=$oid -f -

# Doit être refusé : « namespace hors périmètre Agent Ready »
kubectl label namespace kagent agentic.io/agent-ready=true --dry-run=server --as=$oid

# Doit être refusé : Secret de la plateforme
kubectl -n agentgateway-system create secret generic vk-platform --from-literal=x=y `
  --dry-run=server --as=$oid
```

Dans les pipelines, affichez `kubectl auth whoami`. C'est le moyen le plus rapide de confirmer que Kubernetes voit bien l'object ID attendu par la `matchCondition` et par le RoleBinding.

## Ce que l'équipe déploie

Dans son namespace, l'équipe reçoit la ClusterRole `agentic-team-deployer` : Deployments, Services, ConfigMaps, NetworkPolicies et les ressources kagent `agents`, `remotemcpservers` et `modelconfigs`. Elle n'a accès ni aux Secrets, ni au RBAC, ni aux namespaces.

Pour l'équipe, ajouter un agent revient à ouvrir une pull request. Le `ModelConfig` pointe vers la passerelle, avec la clé créée par Agent Ready :

Fichiers : [`terraform/teams/template/agent/modelconfig.yaml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/teams/template/agent/modelconfig.yaml) et [`agent.yaml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/teams/template/agent/agent.yaml)

```yaml
apiVersion: kagent.dev/v1alpha2
kind: ModelConfig
metadata:
  name: passerelle-llm
spec:
  provider: OpenAI
  model: llm                     # ignoré : la passerelle choisit le déploiement
  apiKeySecret: gateway-key
  apiKeySecretKey: key
  openAI:
    baseUrl: http://agentgateway-proxy.agentgateway-system.svc.cluster.local/agents/v1
---
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
  name: assistant-docs
spec:
  description: Répond aux questions de l'équipe à partir de ses documents
  type: Declarative
  declarative:
    modelConfig: passerelle-llm
    systemMessage: |
      Réponds en français, uniquement à partir des documents de l'équipe :
      cherche d'abord avec « rechercher », lis avec « lire_document »,
      et cite toujours le document source.
    tools:
      - type: McpServer
        mcpServer:
          apiGroup: kagent.dev
          kind: RemoteMCPServer
          name: docs-mcp
          toolNames: [lister_documents, lire_document, rechercher]
```

Le serveur MCP de l'équipe lit son stockage par Workload Identity, sans clé :

Fichier : [`terraform/teams/template/mcp/server.py`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/teams/template/mcp/server.py)

```python
container = ContainerClient(
    account_url=f"https://{ACCOUNT}.blob.core.windows.net",
    container_name=CONTAINER,
    credential=DefaultAzureCredential(exclude_interactive_browser_credential=True),
)
mcp = FastMCP("docs-equipe", host="0.0.0.0", port=8080, stateless_http=True)

@mcp.tool()
def lire_document(nom: str) -> str:
    """Renvoie le contenu d'un document (nom exact, tel que listé)."""
    docs = _documents()                      # cache de 60 s sur le conteneur
    if nom not in docs:
        return f"Document introuvable : {nom}. Documents : {', '.join(sorted(docs))}"
    return docs[nom][:MAX_CHARS]
```

Pour publier ce serveur sur la passerelle, l'équipe n'ouvre aucun ticket. Il suffit d'un label, d'un `appProtocol` et de deux annotations sur le Service :

Fichier : [`terraform/teams/template/deploy/docs-mcp.yaml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/teams/template/deploy/docs-mcp.yaml)

```yaml
apiVersion: v1
kind: Service
metadata:
  name: docs-mcp
  labels:
    agentic.io/publier-mcp: "true"
  annotations:
    agentgateway.dev/mcp-path: /mcp
    agentgateway.dev/mcp-target-name: docs-equipe-docs
spec:
  selector: { app: docs-mcp }
  ports:
    - name: mcp
      port: 8080
      targetPort: mcp
      appProtocol: agentgateway.dev/mcp
```

Côté plateforme, un seul backend découvre ces Services dans tous les namespaces d'équipe :

Fichier : [`gateway/deploy/backend-mcp.yaml`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/gateway/deploy/backend-mcp.yaml)

```yaml
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: mcp-equipes
spec:
  mcp:
    prefixMode: Always      # docs-equipe-docs_rechercher : on voit quelle équipe sert l'outil
    failureMode: FailOpen   # le serveur en panne d'UNE équipe ne coupe pas les autres
    targets:
      - name: equipes
        selector:
          namespaces:
            matchExpressions:
              - { key: agentic.io/team, operator: Exists }
          services:
            matchLabels:
              agentic.io/publier-mcp: "true"
```

Dans LibreChat, les utilisateurs voient sur `/mcp-equipes` les outils de toutes les équipes, préfixés par le nom de l'équipe, avec leur jeton Entra ID. Le label `agentic.io/team` posé par Agent Ready sert à la fois au périmètre d'admission et à cette découverte.

## Le modèle de dépôt rendu par Terraform

Le provider GitHub crée le dépôt `agent-<équipe>` depuis le dossier [`terraform/teams/template`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/teams/template). Les variables y sont des marqueurs `__EQUIPE__`, `__STORAGE__` ou `__CLIENT_ID__` plutôt que `${}`, pour ne pas entrer en collision avec `${{ }}` des workflows ni avec `${VAR}` du shell.

Deux règles de cycle de vie évitent les mauvaises surprises. Avec `lifecycle { ignore_changes = [content] }`, les fichiers appartiennent à l'équipe une fois créés, et relancer Agent Ready n'écrase ni son agent ni ses documents. Les workflows sont écrits en dernier (`depends_on` sur le contenu et les variables), pour qu'aucun run ne parte pendant que le dépôt se remplit.

## Les limites que j'assume

Le pipeline lit les Secrets. Terraform relit ce qu'il gère, d'où le verbe `get`, et l'admission ne filtre pas les lectures ; le pipeline voit donc les Secrets des namespaces d'équipe.

L'état Terraform contient les clés. Un état par équipe limite l'exposition, mais l'accès au stockage d'état doit rester réservé à l'équipe IA.

AcrPush est global au registre, si bien qu'une équipe peut pousser une image sous le nom d'une autre. Les permissions par dépôt d'ACR, ou un registre par équipe, ferment ce point.

Un rôle tout juste attribué peut mettre quelques minutes à s'appliquer ; les pipelines réessaient au lieu d'échouer. Enfin, l'authentification Entra ID d'AKS ne se désactive plus une fois activée. Terraform et le présentateur gardent le kubeconfig admin, et les équipes passent par kubelogin.

## Le code

- [`terraform/modules/agent-ready/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/modules/agent-ready) : identité de l'équipe IA et délégation ABAC
- [`terraform/modules/platform/teams.tf`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/platform/teams.tf) et [`agent-ready-admission.yaml.tftpl`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/blob/main/terraform/modules/platform/manifests/agent-ready-admission.yaml.tftpl) : ClusterRoles et politique d'admission
- [`terraform/teams/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/terraform/teams) : la pile par équipe, son modèle de dépôt et ses tests
- [`.github/workflows/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/.github/workflows) : `agent-ready.yml`, `agent-image.yml`, `agent-deploy.yml`
- [`demos/B-agent-equipe/`](https://github.com/aloizeau/agentic-ai-azure-and-beyond-geneve-2026/tree/main/demos/B-agent-equipe) : la démo de bout en bout

Le prochain article présente un agent d'exploitation qui s'appuie sur les MCP d'entreprise, avec approbation humaine.
