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
msalyrequests(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.
En la página de la aplicación apunta el Application (client) ID y el Directory (tenant) ID. Después, los permisos:
- 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.AllyDevice.Read.All. Nada deReadWriteen 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.Readque 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:
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:39Sube 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:
GRAPH_TENANT_ID=0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0GRAPH_CLIENT_ID=5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0bGRAPH_CERT_KEY=/etc/it-informes/graph.keyGRAPH_CERT_THUMBPRINT=8A3F12C49B0E715D22AF63901CE847B2D56A0F39Si 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
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_clientpide un token para la aplicación con el ámbitohttps://graph.microsoft.com/.default, que significa "los permisos de aplicación ya concedidos". Si falla, MSAL no lanza excepción: devuelve un diccionario conerroryerror_description, y por eso se comprueba.$selectpide solo las columnas que vas a usar. Menos datos, más rápido y menos probabilidad de que Graph te frene.$top=999es el tamaño de página máximo para usuarios. Aun así, con más de 999 hay varias páginas.@odata.nextLinkes 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-sigescribe la marca BOM para que Excel abra bien los acentos de "Pérez" y "Muñoz".
4. Ejecutarlo y programarlo
cd /opt/it-informes && . .venv/bin/activateset -a && . /etc/it-informes/graph.env && set +apython informe_graph.pyusuarios.csv: 2847 filasdispositivos.csv: 3112 filasPara 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:
r = requests.get(f"{GRAPH}/users", headers=cabeceras)usuarios = r.json()["value"]print(len(usuarios))100100 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
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:
import base64, jsoncarga = token.split(".")[1]carga += "=" * (-len(carga) % 4)print(json.loads(base64.urlsafe_b64decode(carga)).get("roles"))NoneNone significa que el token no lleva ningún permiso de aplicación. Las causas, por orden de frecuencia:
- El permiso se añadió como Delegated en vez de Application. En API permissions, la columna Type lo dice.
- Falta el consentimiento de administrador: la columna Status dice Not granted for Contoso. Pídeselo a quien tenga el rol.
- Has añadido el permiso a otra aplicación con nombre parecido: compara el client ID de
graph.envcon el del portal. - Pides un dato que necesita otro permiso. Por ejemplo,
signInActivityen/usersnecesita ademásAuditLog.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.Ally/deviceManagement/managedDevicesda 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 →