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.
What you'll get
A Python script that connects to Microsoft Graph with nobody signed in, pulls every user and every device from Entra ID, and writes two CSV files ready for Excel. With read-only permissions, no password in the code, and knowing what to do when it returns 100 rows out of 3,000 or a 403.
Before you start
- Python 3.11 or later and a virtual environment with
msalandrequests(python -m pip install msal requests), as in the Python for IT guide. - To create the app, the Application Administrator or Cloud Application Administrator role. To grant admin consent for Graph application permissions, Privileged Role Administrator or Global Administrator: usually you ask whoever holds that role.
- If you have already used Graph from PowerShell (Microsoft Graph with PowerShell), the queries are the same; what changes is how you get the token.
1. The app registration
A scheduled script cannot sign in as a person: it authenticates as an application, with its own identity and permissions.
On the app's page note the Application (client) ID and the Directory (tenant) ID. Then the permissions:
- Application permissions, not Delegated. Delegated ones are for when a user is signed in; with client credentials they do nothing.
- Read-only:
User.Read.AllandDevice.Read.All. NoReadWritein a reporting script. - The Status column must say Granted for Contoso. Without consent, the permission is requested but not granted.
- You can remove the delegated
User.Readpermission the app comes with by default.
2. The credential, outside the code
The app has to prove who it is. Two options in Certificates & secrets:
- Certificate (preferred): you upload the public part and the script signs with the private key, which never travels. There is no string anyone can paste into an email or a repository.
- Client secret: a string with an expiry (the portal recommends 180 days; the maximum is 24 months). Simpler, but it is a password.
A self-signed certificate is fine for this:
sudo install -d -m 700 -o reports -g reports /etc/it-reportssudo -u reports openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=it-reports-graph" -keyout /etc/it-reports/graph.key -out /etc/it-reports/graph.crtsudo -u reports chmod 600 /etc/it-reports/graph.keyopenssl x509 -in /etc/it-reports/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:39Upload graph.crt under Certificates & secrets › Certificates › Upload certificate. The portal shows the thumbprint: the same one you just saw, without the colons.
The values that are not secret go into environment variables, in a file only the script's account can read:
GRAPH_TENANT_ID=0b1c2d3e-4f50-6172-8394-a5b6c7d8e9f0GRAPH_CLIENT_ID=5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0bGRAPH_CERT_KEY=/etc/it-reports/graph.keyGRAPH_CERT_THUMBPRINT=8A3F12C49B0E715D22AF63901CE847B2D56A0F39If you use a secret, it goes in that same file (GRAPH_CLIENT_SECRET=...) with 600 permissions, never in the .py or in git. And put its expiry date in the calendar: the day it expires, the report stops arriving.
3. The script
import csvimport osimport sysimport time import msalimport requests GRAPH = "https://graph.microsoft.com/v1.0" def get_token(): with open(os.environ["GRAPH_CERT_KEY"], encoding="utf-8") as f: key = f.read() app = msal.ConfidentialClientApplication( os.environ["GRAPH_CLIENT_ID"], authority="https://login.microsoftonline.com/" + os.environ["GRAPH_TENANT_ID"], client_credential={"private_key": key, "thumbprint": os.environ["GRAPH_CERT_THUMBPRINT"]}, ) result = app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"]) if "access_token" not in result: sys.exit(f"No token: {result.get('error')} · {result.get('error_description')}") return result["access_token"] def read_all(session, url): # Walks every page and honours Graph's 429s attempts = 0 while url: r = session.get(url, timeout=60) if r.status_code in (429, 503, 504) and attempts < 5: wait = int(r.headers.get("Retry-After", "10")) print(f"Graph asks to wait {wait} s ({r.status_code})", file=sys.stderr) time.sleep(wait) attempts += 1 continue r.raise_for_status() attempts = 0 data = r.json() yield from data.get("value", []) url = data.get("@odata.nextLink") def write_csv(filename, rows, fields): with open(filename, "w", newline="", encoding="utf-8-sig") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() n = 0 for row in rows: w.writerow(row) n += 1 print(f"{filename}: {n} rows") def main(): session = requests.Session() session.headers["Authorization"] = "Bearer " + get_token() user_fields = ["displayName", "userPrincipalName", "accountEnabled", "department", "createdDateTime"] users_url = f"{GRAPH}/users?$select={','.join(user_fields)}&$top=999" write_csv("users.csv", read_all(session, users_url), user_fields) device_fields = ["displayName", "operatingSystem", "operatingSystemVersion", "trustType", "accountEnabled", "approximateLastSignInDateTime"] devices_url = f"{GRAPH}/devices?$select={','.join(device_fields)}&$top=999" write_csv("devices.csv", read_all(session, devices_url), device_fields) if __name__ == "__main__": main()What each part does:
acquire_token_for_clientrequests a token for the application with thehttps://graph.microsoft.com/.defaultscope, which means "the application permissions already granted". If it fails, MSAL does not raise an exception: it returns a dictionary witherroranderror_description, which is why it is checked.$selectasks only for the columns you will use. Less data, faster, and less chance of Graph throttling you.$top=999is the maximum page size for users. Even so, with more than 999 there are several pages.@odata.nextLinkis the full URL of the next page. As long as it exists, there is more. Do not build it yourself: use it as it comes.- 429 Too Many Requests: Graph throttles you and says in the Retry-After header how many seconds to wait. Wait that long and repeat the same URL. Five attempts and, if it persists, the script fails with the error in plain sight.
utf-8-sigwrites the BOM so Excel opens accented names such as "Pérez" and "Muñoz" correctly.
4. Running and scheduling it
cd /opt/it-reports && . .venv/bin/activateset -a && . /etc/it-reports/graph.env && set +apython graph_report.pyusers.csv: 2847 rowsdevices.csv: 3112 rowsTo schedule it, a systemd timer or cron with the reports account and the service's EnvironmentFile=/etc/it-reports/graph.env. Compare the row count with what the Entra portal says (Users › All users shows the total): it is the quickest check that the report is complete.
5. The ticket: "the report only brings 100 users" and "it gives 403"
Only 100 users come out
An earlier version of the script made a single request:
r = requests.get(f"{GRAPH}/users", headers=headers)users = r.json()["value"]print(len(users))100100 is the default page size for /users. Graph returns the first page and, in the same JSON, @odata.nextLink with the next one. The script ignored it. The fix is the read_all function from step 3: follow @odata.nextLink until it stops coming. With $top=999 there are fewer pages, but the loop is still mandatory.
If the number is another round one (999, 200), it is the same problem with a different page size.
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."}}The token is valid (otherwise it would be a 401), but the app lacks the permission. To see which permissions the token really carries, look at its roles claim:
import base64, jsonpayload = token.split(".")[1]payload += "=" * (-len(payload) % 4)print(json.loads(base64.urlsafe_b64decode(payload)).get("roles"))NoneNone means the token carries no application permissions at all. The causes, most frequent first:
- The permission was added as Delegated instead of Application. In API permissions, the Type column tells you.
- Admin consent is missing: the Status column says Not granted for Contoso. Ask whoever holds the role.
- You added the permission to another app with a similar name: compare the client ID in
graph.envwith the one in the portal. - You are asking for data that needs another permission. For example,
signInActivityon/usersalso needsAuditLog.Read.All.
Once fixed, a new run requests a new token and roles shows ['User.Read.All', 'Device.Read.All']. A token issued before the change is not updated: if you were testing in an interactive Python session, request it again.
In the ticket: what it returned, the cause, the change on the app, and the final row count compared with the portal.
Next steps
- The Python for IT guide covers the virtual environment, logging and the script that fails once scheduled.
- The Microsoft Graph with PowerShell guide produces the same reports from PowerShell.
- For Intune, the same script with
DeviceManagementManagedDevices.Read.Alland/deviceManagement/managedDevicesgives compliance, last check-in and primary user for each device.
Is something missing, or different in your environment? Get in touch and I'll fix it.
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.Found this useful? Every two weeks I send one practical idea for IT professionals: LinkedIn, projects, networking and hiring processes.
Apúntate y llévate las 33 ideasSign up and get the 33 ideas →