Modelo Conceptual — Jerarquía de Identidades
400: The Agent Blueprint Principal does not exist.
Script de Aprovisionamiento Idempotente
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.
""" 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()
--dry-run antes de producción para verificar que las consultas de idempotencia funcionan correctamente.
Flujo de Ejecución y Output Esperado
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.
Crear / recuperar Blueprint
Busca cultivaia-agents-blueprint por displayName. Si no existe, lo crea con @odata.type: Microsoft.Graph.AgentIdentityBlueprint.
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.
Crear las 3 Agent Identities
Cada agente se crea como un service principal de tipo AgentIdentity vinculado al Blueprint por agentIdentityBlueprintId.
JSON de salida
El script imprime el estado final de las identidades provisionadas.
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
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.0en 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
identifierUrisen 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.