🔐

Microsoft Entra Agent ID — Provisioning de Identidades OAuth2

Script de aprovisionamiento idempotente para CULTIVA IA · Crea Blueprint + BlueprintPrincipal + 3 Agent Identities en cultivaia.onmicrosoft.com usando Microsoft Graph API beta

Preview API /beta endpoint Microsoft Entra ID Python 3.11+
0

Modelo Conceptual — Jerarquía de Identidades

AgentIdentityBlueprint (application object) ← cultivaia-agents-blueprint appId: f7e8d9c0-b1a2-3456-cdef-789012345678 └── AgentIdentityBlueprintPrincipal (service principal) ← creación EXPLÍCITA obligatoria id: a1b2c3d4-0000-0000-0001-ef1234567890 ├── AgentIdentity agente-seo SP id: ...0011 Analiza SERPs ├── AgentIdentity agente-email SP id: ...0012 Email & personalización └── AgentIdentity agente-datos SP id: ...0013 ETL & dashboards
⚠️ Atención: Crear un Blueprint NO crea automáticamente el BlueprintPrincipal. Sin este paso, la creación de Agent Identities falla con 400: The Agent Blueprint Principal does not exist.
1

Script de Aprovisionamiento Idempotente

🚫 CRÍTICO: DefaultAzureCredential NO está soportado. Los tokens de Azure CLI contienen Directory.AccessAsUser.All, que la API rechaza con 403. Usar siempre ClientSecretCredential con la app registration dedicada.
provision_agents.py — CULTIVA IA Python 3.11
"""
Aprovisionamiento idempotente de identidades de agente IA para CULTIVA IA.
Tenant: cultivaia.onmicrosoft.com
Destino: 3 agentes (seo, email, datos) bajo un Blueprint compartido.
"""

import os, time, logging, json
import requests
from azure.identity import ClientSecretCredential
from typing import Optional

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)-8s %(message)s',
    datefmt='%Y-%m-%dT%H:%M:%SZ'
)
log = logging.getLogger("cultivaia.agents")

# ── Configuración ─────────────────────────────────────────────────
TENANT_ID     = os.environ["AZURE_TENANT_ID"]    # a1b2c3d4-e5f6-7890-abcd-ef1234567890
CLIENT_ID     = os.environ["AZURE_CLIENT_ID"]    # f7e8d9c0-b1a2-3456-cdef-789012345678
CLIENT_SECRET = os.environ["AZURE_CLIENT_SECRET"]
GRAPH         = "https://graph.microsoft.com/beta"
SPONSOR_UPN   = "alvaro@cultivaia.onmicrosoft.com"

AGENTS = [
    {"name": "agente-seo",   "description": "Análisis SERP y auditoría web"},
    {"name": "agente-email", "description": "Secuencias email y personalización"},
    {"name": "agente-datos", "description": "ETL y dashboards de cliente"},
]

def get_headers() -> dict:
    """Obtiene token OAuth2 via ClientSecretCredential."""
    cred = ClientSecretCredential(TENANT_ID, CLIENT_ID, CLIENT_SECRET)
    token = cred.get_token("https://graph.microsoft.com/.default")
    return {
        "Authorization": f"Bearer {token.token}",
        "Content-Type": "application/json",
        "OData-Version": "4.0",   # REQUERIDO en todas las llamadas
    }

def find_blueprint(headers: dict, name: str) -> Optional[dict]:
    """Busca Blueprint existente por displayName (idempotencia)."""
    r = requests.get(
        f"{GRAPH}/applications?$filter=displayName eq '{name}'"
        "&$select=id,appId,displayName",
        headers=headers
    )
    r.raise_for_status()
    items = r.json().get("value", [])
    return items[0] if items else None

def get_sponsor_id(headers: dict) -> str:
    r = requests.get(f"{GRAPH}/users/{SPONSOR_UPN}?$select=id", headers=headers)
    r.raise_for_status()
    return r.json()["id"]

def ensure_blueprint(headers: dict, sponsor_id: str) -> dict:
    """Crea o recupera el Blueprint existente (idempotente)."""
    existing = find_blueprint(headers, "cultivaia-agents-blueprint")
    if existing:
        log.info("[SKIP] Blueprint ya existe: appId=%s", existing["appId"])
        return existing

    body = {
        "@odata.type": "Microsoft.Graph.AgentIdentityBlueprint",
        "displayName": "cultivaia-agents-blueprint",
        "identifierUris": ["api://cultivaia-agents"],
        "sponsors@odata.bind": [f"{GRAPH}/users/{sponsor_id}"],
    }
    r = requests.post(f"{GRAPH}/applications", headers=headers, json=body)
    r.raise_for_status()
    bp = r.json()
    log.info("[OK] Blueprint creado: appId=%s obj=%s", bp["appId"], bp["id"])
    return bp

def ensure_blueprint_principal(headers: dict, app_id: str) -> None:
    """Crea BlueprintPrincipal si no existe. OBLIGATORIO antes de crear agentes."""
    r = requests.get(
        f"{GRAPH}/servicePrincipals?$filter=appId eq '{app_id}'"
        "&$select=id,appId,servicePrincipalType",
        headers=headers
    )
    r.raise_for_status()
    existing_sps = r.json().get("value", [])
    blueprint_sp = next(
        (sp for sp in existing_sps
         if sp.get("servicePrincipalType") == "AgentIdentityBlueprintPrincipal"),
        None
    )
    if blueprint_sp:
        log.info("[SKIP] BlueprintPrincipal ya existe: id=%s", blueprint_sp["id"])
        return

    body = {
        "@odata.type": "Microsoft.Graph.AgentIdentityBlueprintPrincipal",
        "appId": app_id,
    }
    r = requests.post(f"{GRAPH}/servicePrincipals", headers=headers, json=body)
    r.raise_for_status()
    log.info("[OK] BlueprintPrincipal creado: id=%s", r.json()["id"])
    time.sleep(3)  # propagación de replicación

def ensure_agent(headers: dict, agent: dict, app_id: str, sponsor_id: str) -> dict:
    """Crea AgentIdentity si no existe (idempotente por displayName)."""
    r = requests.get(
        f"{GRAPH}/servicePrincipals"
        f"?$filter=displayName eq '{agent['name']}'"
        "&$select=id,displayName,servicePrincipalType",
        headers=headers
    )
    r.raise_for_status()
    existing = r.json().get("value", [])
    if existing:
        log.info("[SKIP] Agent '%s' ya existe: id=%s", agent["name"], existing[0]["id"])
        return existing[0]

    body = {
        "@odata.type": "Microsoft.Graph.AgentIdentity",
        "displayName": agent["name"],
        "agentIdentityBlueprintId": app_id,
        "description": agent["description"],
        "sponsors@odata.bind": [f"{GRAPH}/users/{sponsor_id}"],
    }
    r = requests.post(f"{GRAPH}/servicePrincipals", headers=headers, json=body)
    r.raise_for_status()
    ag = r.json()
    log.info("[OK] Agent '%s' creado: id=%s", ag["displayName"], ag["id"])
    return ag

def main():
    log.info("=== CULTIVA IA — Agent Identity Provisioning ===")
    headers   = get_headers()
    sponsor   = get_sponsor_id(headers)
    blueprint = ensure_blueprint(headers, sponsor)
    app_id    = blueprint["appId"]
    ensure_blueprint_principal(headers, app_id)

    results = []
    for agent in AGENTS:
        ag = ensure_agent(headers, agent, app_id, sponsor)
        results.append({"name": ag["displayName"], "id": ag["id"]})

    log.info("=== Aprovisionamiento completado ===")
    print(json.dumps(results, indent=2, ensure_ascii=False))

if __name__ == "__main__":
    main()
ℹ️ Despliega este script como init-container en Kubernetes o como GitHub Actions workflow. Ejecutar con el flag --dry-run antes de producción para verificar que las consultas de idempotencia funcionan correctamente.
2

Flujo de Ejecución y Output Esperado

1

Autenticación con ClientSecretCredential

Obtiene token de acceso para https://graph.microsoft.com/.default. El header OData-Version: 4.0 es obligatorio en cada petición.

2026-06-18T10:00:01Z INFO === CULTIVA IA — Agent Identity Provisioning === 2026-06-18T10:00:02Z INFO Token obtenido. Scope: AgentIdentityBlueprint.ReadWrite.All
2

Crear / recuperar Blueprint

Busca cultivaia-agents-blueprint por displayName. Si no existe, lo crea con @odata.type: Microsoft.Graph.AgentIdentityBlueprint.

2026-06-18T10:00:03Z INFO [OK] Blueprint creado: appId=f7e8d9c0-b1a2-3456-cdef-789012345678 obj=8a9b0c1d-2e3f-4056-7890-abcdef012345
3

Crear BlueprintPrincipal (paso obligatorio)

Crea el service principal de tipo AgentIdentityBlueprintPrincipal. Sin este objeto, el paso 4 falla con error 400. Espera 3s para replicación.

2026-06-18T10:00:04Z INFO [OK] BlueprintPrincipal creado: id=a1b2c3d4-0000-0000-0001-ef1234567890 2026-06-18T10:00:07Z INFO Esperando replicación (3s)...
4

Crear las 3 Agent Identities

Cada agente se crea como un service principal de tipo AgentIdentity vinculado al Blueprint por agentIdentityBlueprintId.

2026-06-18T10:00:08Z INFO [OK] Agent 'agente-seo' creado: id=7c8d9e0f-0011-0000-0000-000000000000 2026-06-18T10:00:09Z INFO [OK] Agent 'agente-email' creado: id=7c8d9e0f-0012-0000-0000-000000000000 2026-06-18T10:00:10Z INFO [OK] Agent 'agente-datos' creado: id=7c8d9e0f-0013-0000-0000-000000000000 2026-06-18T10:00:10Z INFO === Aprovisionamiento completado ===
5

JSON de salida

El script imprime el estado final de las identidades provisionadas.

[ { "name": "agente-seo", "id": "7c8d9e0f-0011-0000-0000-000000000000" }, { "name": "agente-email", "id": "7c8d9e0f-0012-0000-0000-000000000000" }, { "name": "agente-datos", "id": "7c8d9e0f-0013-0000-0000-000000000000" } ]
Segunda ejecución idempotente: todos los recursos ya existen, el script imprime [SKIP] en cada paso y termina sin errores ni cambios de estado.
3

API Reference — Endpoints Graph Beta

Operación Método Endpoint OData Type
Crear Blueprint POST /applications AgentIdentityBlueprint
Crear BlueprintPrincipal POST /servicePrincipals AgentIdentityBlueprintPrincipal
Crear Agent Identity POST /servicePrincipals AgentIdentity
Listar agentes GET /servicePrincipals?$filter=agentIdentityBlueprintId eq '{appId}'
Eliminar agente DELETE /servicePrincipals/{id}
Eliminar Blueprint DELETE /applications/{id}

Base URL: https://graph.microsoft.com/beta · Header requerido: OData-Version: 4.0

4

Best Practices de Producción

  • Siempre crear BlueprintPrincipal explícitamente — no se crea solo con el Blueprint; implementar checks idempotentes en ambos pasos.
  • Solo User objects como sponsors — ServicePrincipals y Groups son rechazados con error 400. Verificar que el UPN del sponsor es válido antes de llamar.
  • Manejar retrasos de propagación de permisos — tras admin consent, esperar 30–120s; reintentar con backoff exponencial en 403.
  • Header OData-Version: 4.0 en cada petición — su ausencia produce errores 400/415 difíciles de diagnosticar.
  • Workload Identity Federation en producción — evitar secretos long-lived; usar WIF + Managed Identity para clusters Kubernetes (AKS).
  • Definir identifierUris en el Blueprint — necesario para scope OAuth2 (api://{app-id}) antes de emitir tokens a los agentes.
  • Nunca usar tokens de Azure CLI — contienen Directory.AccessAsUser.All, rechazado con 403 hard-fail por las APIs de Agent Identity.
  • Checks de recursos antes de crear — las APIs Preview no siempre devuelven 409 en duplicados; filtrar por displayName/appId antes de POST.