Qué vas a conseguir
Hacer con un script lo que el portal te obliga a hacer a clics: cruzar dispositivos de Intune con usuarios y managers de Entra, sacar un CSV cada lunes y, cuando lo tengas claro, automatizarlo sin que nadie inicie sesión. Es la base de los repos de mi GitHub que leen el tenant.
Antes de empezar
- PowerShell 7 (
pwsh), en Windows, macOS o Linux. - Una cuenta con los roles de lectura necesarios: Global Reader basta para todo lo de esta guía.
- Para la automatización final, permiso para crear un registro de aplicación (o que lo cree el administrador de identidad con los permisos que le pidas).
1. El SDK y la primera conexión
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.DeviceManagement -Scope CurrentUser -ForceConnect-MgGraph -Scopes "User.Read.All","Device.Read.All","DeviceManagementManagedDevices.Read.All" -NoWelcomeGet-MgContext | Select-Object Account, TenantId, @{n="Scopes";e={$_.Scopes -join ", "}}Account TenantId Scopes------- -------- ------inigo.admin@contoso.com 7f1c…-…-… Device.Read.All, DeviceManagementManagedDevices.Read.All, User.Read.All, ...Get-MgUser -UserId laura.prado@contoso.com -Property displayName,department,jobTitle,accountEnabled | Format-List displayName,department,jobTitle,accountEnableddisplayName : Laura Pradodepartment : FinanzasjobTitle : AnalistaaccountEnabled : TrueDos cosas que importan desde el primer día. Instala solo los módulos que necesitas (Microsoft.Graph completo son 40 módulos y tarda diez minutos). Y pide solo los ámbitos que la tarea necesita: -Scopes es lo que un administrador te concede y lo que un auditor verá; Directory.ReadWrite.All para un informe de lectura es lo que no quieres explicar.
2. Encontrar la consulta: Graph Explorer
Graph Explorer es donde pruebas la URL antes de escribir una línea de PowerShell: la pestaña Permisos modificados te dice exactamente qué ámbito necesita y Fragmentos de código te da el cmdlet equivalente. La regla de oro: $select siempre (menos datos, más rápido) y $filter en el servidor, no Where-Object en tu máquina con 5.000 dispositivos descargados.
3. Paginación: lo que rompe los scripts
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName&`$top=999"$r.value.Count; $r.'@odata.nextLink' -ne $null999True# con el cmdlet, -All recorre todas las páginas por ti:$users = Get-MgUser -All -Property id,userPrincipalName,accountEnabled -PageSize 999$users.Count3412Graph devuelve como mucho 999 objetos por llamada y un @odata.nextLink para la siguiente página. Un script que "funcionaba" con 800 usuarios y devuelve 999 de 3.412 cuando la empresa crece no da error: da datos incompletos. -All en los cmdlets o un bucle sobre nextLink con Invoke-MgGraphRequest es obligatorio.
4. El informe: dispositivos sin check-in, con usuario y manager
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.DeviceManagementparam([int]$Days = 30, [string]$Out = "stale-devices-$(Get-Date -Format yyyy-MM-dd).csv") Connect-MgGraph -Scopes "User.Read.All","DeviceManagementManagedDevices.Read.All" -NoWelcome$since = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $devices = Get-MgDeviceManagementManagedDevice -All -Filter "lastSyncDateTime le $since" ` -Property deviceName,userPrincipalName,operatingSystem,osVersion,lastSyncDateTime,complianceState,managedDeviceOwnerType $cache = @{}$rows = foreach ($d in $devices) { $mgr = $null if ($d.UserPrincipalName) { if (-not $cache.ContainsKey($d.UserPrincipalName)) { $u = Get-MgUser -UserId $d.UserPrincipalName -Property accountEnabled,department -ExpandProperty manager -ErrorAction SilentlyContinue $cache[$d.UserPrincipalName] = $u } $u = $cache[$d.UserPrincipalName] $mgr = $u.Manager.AdditionalProperties.userPrincipalName } [pscustomobject]@{ Device = $d.DeviceName; OS = "$($d.OperatingSystem) $($d.OsVersion)"; Owner = $d.ManagedDeviceOwnerType LastSync = $d.LastSyncDateTime; DaysSilent = [int]((Get-Date) - $d.LastSyncDateTime).TotalDays Compliance = $d.ComplianceState; User = $d.UserPrincipalName; UserEnabled = $u.AccountEnabled Department = $u.Department; Manager = $mgr }}$rows | Sort-Object DaysSilent -Descending | Export-Csv $Out -NoTypeInformation -Encoding utf8"$($rows.Count) dispositivos sin check-in en $Days días -> $Out"./Get-StaleDevices.ps1 -Days 3047 dispositivos sin check-in en 30 días -> stale-devices-2026-10-22.csvImport-Csv .\stale-devices-2026-10-22.csv | Where-Object UserEnabled -eq False | Select-Object Device, User, DaysSilent, Manager | Format-TableDevice User DaysSilent Manager------ ---- ---------- -------LAPTOP-3K8MQ2 j.navarro@contoso.com 53 m.ortega@contoso.comDESKTOP-Q71A p.ruiz@contoso.com 41 m.ortega@contoso.comEsto es lo que el portal no te da en una pantalla: el equipo que lleva 53 días sin sincronizar pertenece a un usuario deshabilitado (una baja), y quien tiene que devolverlo es su manager. La caché de usuarios evita 47 llamadas repetidas al mismo manager. El CSV va al ticket de bajas y, con la guía de Jenkins, el script se prueba en cada cambio.
5. Automatizar sin usuario: registro de aplicación
$cert = New-SelfSignedCertificate -Subject "CN=it-reports-graph" -CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy NonExportable -NotAfter (Get-Date).AddYears(1)Export-Certificate -Cert $cert -FilePath .\it-reports-graph.cer # este .cer es el que se sube al registroConnect-MgGraph -ClientId "0a1b…-…" -TenantId "7f1c…-…" -CertificateThumbprint $cert.Thumbprint -NoWelcome(Get-MgContext).AuthTypeAppOnlyPara la tarea programada, el script usa certificado, no secreto de cliente: la clave privada no sale del servidor y no hay nada que copiar en un fichero. Los permisos de aplicación (no delegados) son los que importan aquí, y el consentimiento del administrador queda en el registro de auditoría. El repo 20 de mi GitHub revisa cada mes estos registros: quién tiene qué permiso y cuándo caduca el certificado.
6. Lo que revisas cada mes
- Registros de aplicaciones de IT: permisos concedidos frente a los usados (
Registros de aplicaciones → la app → Permisos de API), certificados que caducan en 90 días. - Que ningún script del equipo usa
Directory.ReadWrite.AlloMail.ReadWrite"por si acaso". - Los
-Scopesinteractivos que has consentido tú mismo:Aplicaciones empresariales → Microsoft Graph Command Line Tools → Permisos; revoca lo que no uses.
Siguientes pasos
- Escritura con cuidado:
Update-MgUser,Invoke-MgSyncDeviceManagementManagedDevice(forzar check-in a los 47 del informe) siempre con-WhatIfprimero y en un grupo pequeño. - Azure Automation o una tarea en el servidor de herramientas para que el informe llegue cada lunes al canal de Teams.
- Las mismas consultas sirven desde Python (
msgraph-sdk) ocurlcon un token: Graph es una API REST, PowerShell es solo la puerta más cómoda para IT.
¿Falta algo o en tu entorno es distinto? Escríbeme y lo corrijo.
What you'll get
Do with a script what the portal makes you do with clicks: join Intune devices with Entra users and managers, produce a CSV every Monday and, once it is clear, automate it without anyone signing in. It is the foundation of the repos on my GitHub that read the tenant.
Before you start
- PowerShell 7 (
pwsh), on Windows, macOS or Linux. - An account with the necessary read roles: Global Reader is enough for everything in this guide.
- For the final automation, permission to create an app registration (or have the identity admin create it with the permissions you ask for).
1. The SDK and the first connection
Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.DeviceManagement -Scope CurrentUser -ForceConnect-MgGraph -Scopes "User.Read.All","Device.Read.All","DeviceManagementManagedDevices.Read.All" -NoWelcomeGet-MgContext | Select-Object Account, TenantId, @{n="Scopes";e={$_.Scopes -join ", "}}Account TenantId Scopes------- -------- ------inigo.admin@contoso.com 7f1c…-…-… Device.Read.All, DeviceManagementManagedDevices.Read.All, User.Read.All, ...Get-MgUser -UserId laura.prado@contoso.com -Property displayName,department,jobTitle,accountEnabled | Format-List displayName,department,jobTitle,accountEnableddisplayName : Laura Pradodepartment : FinancejobTitle : AnalystaccountEnabled : TrueTwo things matter from day one. Install only the modules you need (the full Microsoft.Graph is 40 modules and takes ten minutes). And ask for only the scopes the task needs: -Scopes is what an administrator grants you and what an auditor will see; Directory.ReadWrite.All for a read-only report is what you don't want to explain.
2. Find the query: Graph Explorer
Graph Explorer is where you test the URL before writing a line of PowerShell: the Modify permissions tab tells you exactly which scope it needs and Code snippets gives you the equivalent cmdlet. The golden rule: $select always (less data, faster) and $filter on the server, not Where-Object on your machine with 5,000 downloaded devices.
3. Paging: what breaks scripts
$r = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName&`$top=999"$r.value.Count; $r.'@odata.nextLink' -ne $null999True# with the cmdlet, -All walks every page for you:$users = Get-MgUser -All -Property id,userPrincipalName,accountEnabled -PageSize 999$users.Count3412Graph returns at most 999 objects per call and an @odata.nextLink for the next page. A script that "worked" with 800 users and returns 999 out of 3,412 when the company grows does not error: it gives incomplete data. -All on the cmdlets, or a loop over nextLink with Invoke-MgGraphRequest, is mandatory.
4. The report: devices with no check-in, with user and manager
#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Users, Microsoft.Graph.DeviceManagementparam([int]$Days = 30, [string]$Out = "stale-devices-$(Get-Date -Format yyyy-MM-dd).csv") Connect-MgGraph -Scopes "User.Read.All","DeviceManagementManagedDevices.Read.All" -NoWelcome$since = (Get-Date).AddDays(-$Days).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") $devices = Get-MgDeviceManagementManagedDevice -All -Filter "lastSyncDateTime le $since" ` -Property deviceName,userPrincipalName,operatingSystem,osVersion,lastSyncDateTime,complianceState,managedDeviceOwnerType $cache = @{}$rows = foreach ($d in $devices) { $mgr = $null if ($d.UserPrincipalName) { if (-not $cache.ContainsKey($d.UserPrincipalName)) { $u = Get-MgUser -UserId $d.UserPrincipalName -Property accountEnabled,department -ExpandProperty manager -ErrorAction SilentlyContinue $cache[$d.UserPrincipalName] = $u } $u = $cache[$d.UserPrincipalName] $mgr = $u.Manager.AdditionalProperties.userPrincipalName } [pscustomobject]@{ Device = $d.DeviceName; OS = "$($d.OperatingSystem) $($d.OsVersion)"; Owner = $d.ManagedDeviceOwnerType LastSync = $d.LastSyncDateTime; DaysSilent = [int]((Get-Date) - $d.LastSyncDateTime).TotalDays Compliance = $d.ComplianceState; User = $d.UserPrincipalName; UserEnabled = $u.AccountEnabled Department = $u.Department; Manager = $mgr }}$rows | Sort-Object DaysSilent -Descending | Export-Csv $Out -NoTypeInformation -Encoding utf8"$($rows.Count) devices with no check-in in $Days days -> $Out"./Get-StaleDevices.ps1 -Days 3047 devices with no check-in in 30 days -> stale-devices-2026-10-22.csvImport-Csv .\stale-devices-2026-10-22.csv | Where-Object UserEnabled -eq False | Select-Object Device, User, DaysSilent, Manager | Format-TableDevice User DaysSilent Manager------ ---- ---------- -------LAPTOP-3K8MQ2 j.navarro@contoso.com 53 m.ortega@contoso.comDESKTOP-Q71A p.ruiz@contoso.com 41 m.ortega@contoso.comThis is what the portal does not give you on one screen: the laptop that has not synced for 53 days belongs to a disabled user (a leaver), and the person who has to return it is their manager. The user cache avoids 47 repeated calls for the same manager. The CSV goes to the leavers ticket and, with the Jenkins guide, the script is tested on every change.
5. Automate without a user: app registration
$cert = New-SelfSignedCertificate -Subject "CN=it-reports-graph" -CertStoreLocation Cert:\CurrentUser\My -KeyExportPolicy NonExportable -NotAfter (Get-Date).AddYears(1)Export-Certificate -Cert $cert -FilePath .\it-reports-graph.cer # this .cer is what you upload to the registrationConnect-MgGraph -ClientId "0a1b…-…" -TenantId "7f1c…-…" -CertificateThumbprint $cert.Thumbprint -NoWelcome(Get-MgContext).AuthTypeAppOnlyFor the scheduled task, the script uses a certificate, not a client secret: the private key never leaves the server and there is nothing to copy into a file. Application permissions (not delegated) are what matter here, and the admin consent lands in the audit log. Repo 20 on my GitHub reviews these registrations every month: who has which permission and when the certificate expires.
6. What you review each month
- IT app registrations: permissions granted versus used (
App registrations → the app → API permissions), certificates expiring within 90 days. - That no team script uses
Directory.ReadWrite.AllorMail.ReadWrite"just in case". - The interactive
-Scopesyou consented to yourself:Enterprise applications → Microsoft Graph Command Line Tools → Permissions; revoke what you don't use.
Next steps
- Writing, carefully:
Update-MgUser,Invoke-MgSyncDeviceManagementManagedDevice(force a check-in on the 47 in the report), always with-WhatIffirst and on a small group. - Azure Automation or a task on the tools server so the report reaches the Teams channel every Monday.
- The same queries work from Python (
msgraph-sdk) orcurlwith a token: Graph is a REST API, PowerShell is just the most comfortable door for IT.
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 →