GuíasGuides · Nivel 2Level 2

Python y Microsoft Graph: un informe de usuarios y dispositivos con MSAL, paginación, límites 429 y un 403 Authorization_RequestDeniedPython and Microsoft Graph: a users and devices report with MSAL, paging, 429 throttling and a 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.The app registration with read-only application permissions, the MSAL client credentials token, paging with @odata.nextLink, 429s with Retry-After, the users and devices CSV, the secret kept out of the code, and the two classic tickets: only 100 users and the 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.

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 msal and requests (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.

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

On the app's page note the Application (client) ID and the Directory (tenant) ID. Then the permissions:

it-reports-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, not Delegated. Delegated ones are for when a user is signed in; with client credentials they do nothing.
  • Read-only: User.Read.All and Device.Read.All. No ReadWrite in 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.Read permission 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:

bash · srv-scripts-01
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:39

Upload 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:

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

If 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

graph_report.py
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_client requests a token for the application with the https://graph.microsoft.com/.default scope, which means "the application permissions already granted". If it fails, MSAL does not raise an exception: it returns a dictionary with error and error_description, which is why it is checked.
  • $select asks only for the columns you will use. Less data, faster, and less chance of Graph throttling you.
  • $top=999 is the maximum page size for users. Even so, with more than 999 there are several pages.
  • @odata.nextLink is 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-sig writes the BOM so Excel opens accented names such as "Pérez" and "Muñoz" correctly.

4. Running and scheduling it

bash · srv-scripts-01
cd /opt/it-reports && . .venv/bin/activateset -a && . /etc/it-reports/graph.env && set +apython graph_report.pyusers.csv: 2847 rowsdevices.csv: 3112 rows

To 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:

old_report.py · the bug
r = requests.get(f"{GRAPH}/users", headers=headers)users = r.json()["value"]print(len(users))100

100 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

script output
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:

python · which permissions the token carries
import base64, jsonpayload = token.split(".")[1]payload += "=" * (-len(payload) % 4)print(json.loads(base64.urlsafe_b64decode(payload)).get("roles"))None

None means the token carries no application permissions at all. The causes, most frequent first:

  1. The permission was added as Delegated instead of Application. In API permissions, the Type column tells you.
  2. Admin consent is missing: the Status column says Not granted for Contoso. Ask whoever holds the role.
  3. You added the permission to another app with a similar name: compare the client ID in graph.env with the one in the portal.
  4. You are asking for data that needs another permission. For example, signInActivity on /users also needs AuditLog.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.All and /deviceManagement/managedDevices gives 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 →