Guías · Nivel 2

Python y Microsoft Graph: un informe de usuarios y dispositivos con MSAL, paginación, límites 429 y un 403 Authorization_RequestDenied

El registro de aplicación con permisos de aplicación de solo lectura, el token con MSAL por credenciales de cliente, la paginación con @odata.nextLink, los 429 con Retry-After, el CSV de usuarios y dispositivos, el secreto fuera del código y los dos tickets clásicos: solo salen 100 usuarios y el 403.

Qué vas a conseguir

Un script de Python que se conecta a Microsoft Graph sin usuario delante, saca todos los usuarios y todos los dispositivos de Entra ID, y escribe dos CSV listos para Excel. Con permisos de solo lectura, sin la contraseña en el código, y sabiendo qué hacer cuando devuelve 100 filas de 3.000 o un 403.

Antes de empezar

  • Python 3.11 o posterior y un entorno virtual con msal y requests (python -m pip install msal requests), como en la guía de Python para IT.
  • Para crear la aplicación, el rol Application Administrator o Cloud Application Administrator. Para dar el consentimiento de administrador a permisos de aplicación de Graph, Privileged Role Administrator o Global Administrator: normalmente lo pides a quien tenga ese rol.
  • Si ya has usado Graph desde PowerShell (Microsoft Graph con PowerShell), las consultas son las mismas; cambia cómo se obtiene el token.

1. El registro de aplicación

Un script programado no puede iniciar sesión como una persona: se autentica como aplicación, con su propia identidad y sus permisos.

Microsoft Entra admin center › Identity › Applications › App registrations › New registration
Nameit-informes-graph
Accounts in this organizational directory only (Contoso only - Single tenant)
Redirect URI(vacío)
Register

En la página de la aplicación apunta el Application (client) ID y el Directory (tenant) ID. Después, los permisos:

it-informes-graph › API permissions › Add a permission › Microsoft Graph
Application permissions
User.Read.All
Device.Read.All
Add permissions
Grant admin consent for Contoso
  • Application permissions, no Delegated. Los delegados son para cuando hay un usuario conectado; con credenciales de cliente no sirven de nada.
  • Solo lectura: User.Read.All y Device.Read.All. Nada de ReadWrite en un script de informes.
  • La columna Status tiene que decir Granted for Contoso. Sin consentimiento, el permiso está pedido pero no concedido.
  • Puedes quitar el permiso delegado User.Read que la aplicación trae por defecto.

2. La credencial, fuera del código

La aplicación necesita demostrar quién es. Dos opciones en Certificates & secrets:

  • Certificado (preferible): subes la parte pública y el script firma con la clave privada, que nunca viaja. No hay un texto que se pueda copiar a un correo o a un repositorio.
  • Secreto de cliente: una cadena con caducidad (el portal recomienda 180 días; el máximo es 24 meses). Más sencillo, pero es una contraseña.

Un certificado autofirmado vale para esto:

bash · srv-scripts-01
sudo install -d -m 700 -o informes -g informes /etc/it-informessudo -u informes openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=it-informes-graph" -keyout /etc/it-informes/graph.key -out /etc/it-informes/graph.crtsudo -u informes chmod 600 /etc/it-informes/graph.keyopenssl x509 -in /etc/it-informes/graph.crt -noout -fingerprint -sha1sha1 Fingerprint=8A:3F:12:C4:9B:0E:71:5D:22:AF:63:90:1C:E8:47:B2:D5:6A:0F:39

Sube graph.crt en Certificates & secrets › Certificates › Upload certificate. El portal muestra la huella (thumbprint): la misma que acabas de ver, sin los dos puntos.

Los datos que no son secretos van en variables de entorno, en un fichero que solo lee la cuenta del script:

/etc/it-informes/graph.env
GRAPH_TENANT_ID=0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0GRAPH_CLIENT_ID=5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0bGRAPH_CERT_KEY=/etc/it-informes/graph.keyGRAPH_CERT_THUMBPRINT=8A3F12C49B0E715D22AF63901CE847B2D56A0F39

Si usas un secreto, va en ese mismo fichero (GRAPH_CLIENT_SECRET=...) con permisos 600, nunca en el .py ni en git. Y apunta en el calendario su fecha de caducidad: el día que caduca el informe deja de llegar.

3. El script

informe_graph.py
import csvimport osimport sysimport time import msalimport requests GRAPH = "https://graph.microsoft.com/v1.0"  def obtener_token():    with open(os.environ["GRAPH_CERT_KEY"], encoding="utf-8") as f:        clave = f.read()    app = msal.ConfidentialClientApplication(        os.environ["GRAPH_CLIENT_ID"],        authority="https://login.microsoftonline.com/" + os.environ["GRAPH_TENANT_ID"],        client_credential={"private_key": clave, "thumbprint": os.environ["GRAPH_CERT_THUMBPRINT"]},    )    resultado = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"])    if "access_token" not in resultado:        sys.exit(f"Sin token: {resultado.get('error')} · {resultado.get('error_description')}")    return resultado["access_token"]  def leer_todo(sesion, url):    # Recorre todas las páginas y respeta los 429 de Graph    intentos = 0    while url:        r = sesion.get(url, timeout=60)        if r.status_code in (429, 503, 504) and intentos < 5:            espera = int(r.headers.get("Retry-After", "10"))            print(f"Graph pide esperar {espera} s ({r.status_code})", file=sys.stderr)            time.sleep(espera)            intentos += 1            continue        r.raise_for_status()        intentos = 0        datos = r.json()        yield from datos.get("value", [])        url = datos.get("@odata.nextLink")  def escribir_csv(fichero, filas, campos):    with open(fichero, "w", newline="", encoding="utf-8-sig") as f:        w = csv.DictWriter(f, fieldnames=campos, extrasaction="ignore")        w.writeheader()        n = 0        for fila in filas:            w.writerow(fila)            n += 1    print(f"{fichero}: {n} filas")  def main():    sesion = requests.Session()    sesion.headers["Authorization"] = "Bearer " + obtener_token()     campos_u = ["displayName", "userPrincipalName", "accountEnabled", "department", "createdDateTime"]    url_u = f"{GRAPH}/users?$select={','.join(campos_u)}&$top=999"    escribir_csv("usuarios.csv", leer_todo(sesion, url_u), campos_u)     campos_d = ["displayName", "operatingSystem", "operatingSystemVersion", "trustType",                "accountEnabled", "approximateLastSignInDateTime"]    url_d = f"{GRAPH}/devices?$select={','.join(campos_d)}&$top=999"    escribir_csv("dispositivos.csv", leer_todo(sesion, url_d), campos_d)  if __name__ == "__main__":    main()

Lo que hace cada parte:

  • acquire_token_for_client pide un token para la aplicación con el ámbito https://graph.microsoft.com/.default, que significa "los permisos de aplicación ya concedidos". Si falla, MSAL no lanza excepción: devuelve un diccionario con error y error_description, y por eso se comprueba.
  • $select pide solo las columnas que vas a usar. Menos datos, más rápido y menos probabilidad de que Graph te frene.
  • $top=999 es el tamaño de página máximo para usuarios. Aun así, con más de 999 hay varias páginas.
  • @odata.nextLink es la URL de la página siguiente, completa. Mientras exista, hay más. No la construyas tú: úsala tal cual.
  • 429 Too Many Requests: Graph te frena y dice en la cabecera Retry-After cuántos segundos esperar. Se espera eso y se repite la misma URL. Cinco intentos y, si sigue, el script falla con el error a la vista.
  • utf-8-sig escribe la marca BOM para que Excel abra bien los acentos de "Pérez" y "Muñoz".

4. Ejecutarlo y programarlo

bash · srv-scripts-01
cd /opt/it-informes && . .venv/bin/activateset -a && . /etc/it-informes/graph.env && set +apython informe_graph.pyusuarios.csv: 2847 filasdispositivos.csv: 3112 filas

Para programarlo, un temporizador de systemd o cron con la cuenta informes y el EnvironmentFile=/etc/it-informes/graph.env del servicio. Compara el número de filas con lo que dice el portal de Entra (Users › All users muestra el total): es la comprobación más rápida de que el informe está completo.

5. El ticket: "el informe solo trae 100 usuarios" y "da 403"

Solo salen 100 usuarios

Una versión anterior del script hacía una sola petición:

informe_viejo.py · el fallo
r = requests.get(f"{GRAPH}/users", headers=cabeceras)usuarios = r.json()["value"]print(len(usuarios))100

100 es el tamaño de página por defecto de /users. Graph devuelve la primera página y, en el mismo JSON, @odata.nextLink con la siguiente. El script la ignoraba. La solución es la función leer_todo del paso 3: seguir @odata.nextLink hasta que no venga. Con $top=999 hay menos páginas, pero el bucle sigue siendo obligatorio.

Si el número es otro redondo (999, 200), es el mismo problema con otro tamaño de página.

403 Authorization_RequestDenied

salida del script
requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://graph.microsoft.com/v1.0/users?$select=...{"error":{"code":"Authorization_RequestDenied","message":"Insufficient privileges to complete the operation."}}

El token es válido (si no, sería un 401), pero la aplicación no tiene el permiso. Para saber qué permisos lleva de verdad el token, mira su claim roles:

python · qué permisos trae el token
import base64, jsoncarga = token.split(".")[1]carga += "=" * (-len(carga) % 4)print(json.loads(base64.urlsafe_b64decode(carga)).get("roles"))None

None significa que el token no lleva ningún permiso de aplicación. Las causas, por orden de frecuencia:

  1. El permiso se añadió como Delegated en vez de Application. En API permissions, la columna Type lo dice.
  2. Falta el consentimiento de administrador: la columna Status dice Not granted for Contoso. Pídeselo a quien tenga el rol.
  3. Has añadido el permiso a otra aplicación con nombre parecido: compara el client ID de graph.env con el del portal.
  4. Pides un dato que necesita otro permiso. Por ejemplo, signInActivity en /users necesita además AuditLog.Read.All.

Tras corregirlo, una nueva ejecución pide un token nuevo y roles muestra ['User.Read.All', 'Device.Read.All']. Un token emitido antes del cambio no se actualiza: si probabas en una sesión interactiva de Python, vuelve a pedirlo.

En el ticket: qué devolvía, la causa, el cambio en la aplicación y el número de filas final comparado con el portal.

Siguientes pasos

  • La guía de Python para IT cubre el entorno virtual, el logging y el script que falla al programarlo.
  • La de Microsoft Graph con PowerShell hace los mismos informes desde PowerShell.
  • Para Intune, el mismo script con DeviceManagementManagedDevices.Read.All y /deviceManagement/managedDevices da cumplimiento, último contacto y usuario principal de cada equipo.

¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.

Tech Career Lab · newsletter

¿Te ha servido? Cada dos semanas envío una idea práctica para perfiles IT: LinkedIn, proyectos, networking y procesos de selección.

Apúntate y llévate las 33 ideas →