From 2639b0f36fc61e5d8755a2ffe9b0e1c34037e1c2 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 13 Oct 2026 14:31:02 +0100 Subject: [PATCH] feat(intune): add macOS LAPS retrieval Add macOS LAPS password retrieval for Intune-managed devices, route requests by platform while preserving Windows as the default, and document the new device action. --- .../Public/Get-CIPPMacOSLapsPassword.ps1 | 55 +++++++++++++ .../Get-CIPPSchedulerBlockedCommands.ps1 | 1 + .../MEM/Invoke-ExecGetLocalAdminPassword.ps1 | 17 +++- ...Invoke-ExecGetLocalAdminPassword.Tests.ps1 | 81 +++++++++++++++++++ .../Get-CIPPMacOSLapsPassword.Tests.ps1 | 46 +++++++++++ .../endpoint/mem/devices/README.md | 2 +- .../CippIntuneDeviceActions.jsx | 13 +++ 7 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Get-CIPPMacOSLapsPassword.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecGetLocalAdminPassword.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPMacOSLapsPassword.Tests.ps1 diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPMacOSLapsPassword.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPMacOSLapsPassword.ps1 new file mode 100644 index 0000000000..19864eb777 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CIPPMacOSLapsPassword.ps1 @@ -0,0 +1,55 @@ +function Get-CIPPMacOSLapsPassword { + <# + .SYNOPSIS + Retrieves the macOS LAPS local administrator password for an Intune managed device. + .DESCRIPTION + Reads the password Intune generated for the local admin account created at Apple automated + device enrollment (ADE). Only devices enrolled through an ADE profile that configures the local + admin account have one. Graph does not return the account name; it is set in the ADE profile. + .PARAMETER Device + The Intune managedDevice ID, not the Entra device ID used by Windows LAPS. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Device, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string]$APIName = 'Get macOS LAPS Password', + + [Parameter(Mandatory = $false)] + [object]$Headers + ) + + # $Device comes straight off the request body and is interpolated into the Graph path, so a value + # with / or .. could otherwise address a different resource than the one device the caller named. + $ParsedDeviceId = [guid]::Empty + if (-not [guid]::TryParse($Device, [ref]$ParsedDeviceId)) { + throw [System.ArgumentException]::new("Invalid managed device ID for macOS LAPS password retrieval: $Device") + } + + try { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($ParsedDeviceId)/retrieveMacOSManagedDeviceLocalAdminAccountDetail" -tenantid $TenantFilter -ErrorAction Stop | Where-Object { $_.adminAccountPassword } | Select-Object -First 1 | ForEach-Object { + $RotatedDate = $_.passwordLastRotatedDateTime + [PSCustomObject]@{ + resultText = "macOS LAPS password retrieved, last rotated at $RotatedDate. Copy the password by clicking the copy button" + copyField = $_.adminAccountPassword + passwordLastRotatedDateTime = $RotatedDate + state = 'success' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Could not retrieve macOS LAPS password for $Device. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw "Could not retrieve macOS LAPS password for $Device. Error: $($ErrorMessage.NormalizedError)" + } + if (-not $GraphRequest) { + Write-LogMessage -headers $Headers -API $APIName -message "No macOS LAPS password found for $Device" -Sev 'Info' -tenant $TenantFilter + throw [System.Management.Automation.ItemNotFoundException]::new("No macOS LAPS password found for $Device") + } + Write-LogMessage -headers $Headers -API $APIName -message "Retrieved macOS LAPS password for $Device" -Sev 'Info' -tenant $TenantFilter + return $GraphRequest +} diff --git a/backend/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/backend/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index 602c5b14cd..84f3b234d4 100644 --- a/backend/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/backend/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -68,6 +68,7 @@ function Get-CIPPSchedulerBlockedCommands { # End-tenant secret material - recovery keys & admin passwords, PostExecution would exfiltrate them 'Get-CIPPLAPSPassword' + 'Get-CIPPMacOSLapsPassword' 'Get-CIPPBitlockerKey' 'Search-CIPPBitlockerKeys' 'Get-CIPPFileVaultKey' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecGetLocalAdminPassword.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecGetLocalAdminPassword.ps1 index 2c79e1d2cb..47dc2331c1 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecGetLocalAdminPassword.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecGetLocalAdminPassword.ps1 @@ -5,15 +5,28 @@ Function Invoke-ExecGetLocalAdminPassword { .ROLE Endpoint.Device.Read .DESCRIPTION - Retrieves the Windows LAPS local administrator password for a device, identified by its Entra device GUID. Returns a live credential in plain text; the retrieval is written to the CIPP audit log. + Retrieves the LAPS local administrator password for a device. Platform selects Windows (the default), where guid is the Entra device ID, or macOS, where guid is the Intune managed device ID. Returns a live credential in plain text; the retrieval is written to the CIPP audit log. #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + $Platform = $Request.Body.Platform ?? 'Windows' + + if ($Platform -notin @('Windows', 'macOS')) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = [pscustomobject]@{'Results' = "Invalid Platform specified: $Platform." } + }) + } + try { - $GraphRequest = Get-CIPPLapsPassword -device $($request.body.guid) -tenantFilter $Request.body.TenantFilter -APIName $APINAME -Headers $Request.Headers + $GraphRequest = if ($Platform -eq 'macOS') { + Get-CIPPMacOSLapsPassword -Device $($request.body.guid) -TenantFilter $Request.body.TenantFilter -APIName $APINAME -Headers $Request.Headers + } else { + Get-CIPPLapsPassword -device $($request.body.guid) -tenantFilter $Request.body.TenantFilter -APIName $APINAME -Headers $Request.Headers + } $Body = [pscustomobject]@{'Results' = $GraphRequest } $StatusCode = [HttpStatusCode]::OK diff --git a/backend/Tests/Endpoint/Invoke-ExecGetLocalAdminPassword.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecGetLocalAdminPassword.Tests.ps1 new file mode 100644 index 0000000000..d3f669a026 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecGetLocalAdminPassword.Tests.ps1 @@ -0,0 +1,81 @@ +# Pester tests for Invoke-ExecGetLocalAdminPassword. +# +# Thin endpoint over the two LAPS helpers. The part worth pinning is the Platform routing: existing +# callers send no Platform and must keep getting Windows LAPS, macOS must reach the helper keyed on the +# Intune managed device ID, and an unknown Platform must be refused rather than fall through to Windows. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecGetLocalAdminPassword.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ExecGetLocalAdminPassword.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CIPPLapsPassword { param($Device, $TenantFilter, $APIName, $Headers) } + function Get-CIPPMacOSLapsPassword { param($Device, $TenantFilter, $APIName, $Headers) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CippErrorStatusCode.ps1') + + . $FunctionPath + + function New-LapsRequest { + param($Platform) + $Body = [pscustomobject]@{ TenantFilter = 'contoso.com'; guid = 'device-guid' } + if ($null -ne $Platform) { $Body | Add-Member -NotePropertyName Platform -NotePropertyValue $Platform } + [pscustomobject]@{ + Body = $Body + Query = [pscustomobject]@{} + Params = [pscustomobject]@{ CIPPEndpoint = 'ExecGetLocalAdminPassword' } + Headers = @{} + } + } +} + +Describe 'Invoke-ExecGetLocalAdminPassword' { + BeforeEach { + Mock -CommandName Get-CIPPLapsPassword -MockWith { 'windows' } + Mock -CommandName Get-CIPPMacOSLapsPassword -MockWith { 'macos' } + } + + It 'retrieves Windows LAPS when no Platform is sent' { + $Response = Invoke-ExecGetLocalAdminPassword -Request (New-LapsRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results | Should -Be 'windows' + Should -Invoke Get-CIPPLapsPassword -Times 1 -ParameterFilter { $Device -eq 'device-guid' -and $TenantFilter -eq 'contoso.com' } + Should -Invoke Get-CIPPMacOSLapsPassword -Times 0 + } + + It 'retrieves macOS LAPS when Platform is macOS' { + $Response = Invoke-ExecGetLocalAdminPassword -Request (New-LapsRequest -Platform 'macOS') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results | Should -Be 'macos' + Should -Invoke Get-CIPPMacOSLapsPassword -Times 1 -ParameterFilter { $Device -eq 'device-guid' -and $TenantFilter -eq 'contoso.com' } + Should -Invoke Get-CIPPLapsPassword -Times 0 + } + + It 'returns 400 for an unknown Platform without calling either helper' { + $Response = Invoke-ExecGetLocalAdminPassword -Request (New-LapsRequest -Platform 'Linux') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results | Should -Be 'Invalid Platform specified: Linux.' + Should -Invoke Get-CIPPLapsPassword -Times 0 + Should -Invoke Get-CIPPMacOSLapsPassword -Times 0 + } + + It 'returns 404 when the device has no macOS LAPS password' { + Mock -CommandName Get-CIPPMacOSLapsPassword -MockWith { throw [System.Management.Automation.ItemNotFoundException]::new('No macOS LAPS password found for device-guid') } + + $Response = Invoke-ExecGetLocalAdminPassword -Request (New-LapsRequest -Platform 'macOS') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::NotFound) + $Response.Body.Results | Should -Be 'No macOS LAPS password found for device-guid' + } +} diff --git a/backend/Tests/Private/Get-CIPPMacOSLapsPassword.Tests.ps1 b/backend/Tests/Private/Get-CIPPMacOSLapsPassword.Tests.ps1 new file mode 100644 index 0000000000..db1adcb0c9 --- /dev/null +++ b/backend/Tests/Private/Get-CIPPMacOSLapsPassword.Tests.ps1 @@ -0,0 +1,46 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPMacOSLapsPassword.ps1') + + function New-GraphGetRequest { param($uri, $tenantid) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + + $script:DeviceId = '11111111-2222-3333-4444-555555555555' +} + +Describe 'Get-CIPPMacOSLapsPassword' { + It 'returns the credential when one exists' { + Mock New-GraphGetRequest { [pscustomobject]@{ adminAccountPassword = 'secret'; passwordLastRotatedDateTime = '2026-09-01T00:00:00Z' } } + $Result = Get-CIPPMacOSLapsPassword -Device $script:DeviceId -TenantFilter 'contoso.com' + $Result.state | Should -Be 'success' + $Result.copyField | Should -Be 'secret' + $Result.passwordLastRotatedDateTime | Should -Be '2026-09-01T00:00:00Z' + } + + It 'calls the managed device function for the one device named' { + Mock New-GraphGetRequest { [pscustomobject]@{ adminAccountPassword = 'secret' } } + Get-CIPPMacOSLapsPassword -Device $script:DeviceId -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { + $uri -eq "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($script:DeviceId)/retrieveMacOSManagedDeviceLocalAdminAccountDetail" -and $tenantid -eq 'contoso.com' + } + } + + It 'throws ArgumentException without calling Graph when the device ID is not a GUID' { + Mock New-GraphGetRequest { } + { Get-CIPPMacOSLapsPassword -Device '../../users' -TenantFilter 'contoso.com' } | Should -Throw -ExceptionType ([System.ArgumentException]) + Should -Invoke New-GraphGetRequest -Times 0 + } + + It 'throws ItemNotFoundException when the device has no LAPS password' { + Mock New-GraphGetRequest { [pscustomobject]@{ adminAccountPassword = $null; passwordLastRotatedDateTime = $null } } + { Get-CIPPMacOSLapsPassword -Device $script:DeviceId -TenantFilter 'contoso.com' } | Should -Throw "No macOS LAPS password found for $($script:DeviceId)" -ExceptionType ([System.Management.Automation.ItemNotFoundException]) + } + + It 'throws an untyped error when Graph fails' { + Mock New-GraphGetRequest { throw 'Forbidden' } + $Err = { Get-CIPPMacOSLapsPassword -Device $script:DeviceId -TenantFilter 'contoso.com' } | Should -Throw "Could not retrieve macOS LAPS password for $($script:DeviceId). Error: Forbidden" -PassThru + $Err.Exception | Should -Not -BeOfType ([System.Management.Automation.ItemNotFoundException]) + $Err.Exception | Should -Not -BeOfType ([System.ArgumentException]) + } +} diff --git a/docs/user-documentation/endpoint/mem/devices/README.md b/docs/user-documentation/endpoint/mem/devices/README.md index ed24595f70..eef05a39bb 100644 --- a/docs/user-documentation/endpoint/mem/devices/README.md +++ b/docs/user-documentation/endpoint/mem/devices/README.md @@ -24,6 +24,6 @@ Selecting a row opens a flyout showing the device name and its assigned user. ## Table Actions -
ActionDescriptionBulk Action Available
View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
Change Primary UserSets a different user as the device's primary user.true
Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
Rename DeviceChanges the device's name to one you specify.true
Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
Reboot DeviceRestarts the device.true
Locate DeviceRequests the device's current location.true
Retrieve LAPS passwordRetrieves the local administrator password held for the device by Windows LAPS. Windows devices only.true
Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
Reset PasscodeResets the device's passcode. Android devices only.true
Remove PasscodeRemoves the device's passcode. iOS devices only.true
Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
Offboard from Defender for EndpointOffboards the device from Microsoft Defender for Endpoint, so that it stops reporting to Defender. The device is matched to its Defender record by its Entra ID device ID, and every matching record that is still onboarded is offboarded. Where the device has no Entra ID device ID, or has no matching Defender record that is currently onboarded, the action reports an error and nothing is offboarded. Offboarding cannot be undone from CIPP, and the device has to be re-onboarded to return it to Defender. Windows devices only.true
Fresh StartReinstalls Windows on the device, asking whether to keep or remove the user's data. Windows devices only.true
Wipe DeviceErases all content and settings on the device, which cannot be undone. Asks whether to keep or remove enrolment data: keeping it re-provisions the device through Autopilot, and removing it retires the device fully. Also asks whether to use a standard or protected wipe, where a protected wipe resumes if interrupted but may leave the device unbootable if it fails. Windows devices only.true
Wipe DeviceErases all content and settings on the device, which cannot be undone. A Recovery PIN (optional, 6 digits) can be supplied, and is needed to unlock Intel Macs that have no T2 security chip once the wipe has finished. macOS devices only.true
Autopilot ResetWipes the device, keeping its enrolment data, and re-provisions it through Windows Autopilot. Windows devices only.true
Delete DeviceDeletes the device record from Intune.true
Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
+
ActionDescriptionBulk Action Available
View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
Change Primary UserSets a different user as the device's primary user.true
Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
Rename DeviceChanges the device's name to one you specify.true
Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
Reboot DeviceRestarts the device.true
Locate DeviceRequests the device's current location.true
Retrieve LAPS passwordRetrieves the local administrator password held for the device. On Windows this is the Windows LAPS password. On macOS it is the password Intune set for the local admin account at Automated Device Enrollment, which only exists where the enrolment profile creates that account, otherwise the action reports that none was found. Windows and macOS devices.true
Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
Reset PasscodeResets the device's passcode. Android devices only.true
Remove PasscodeRemoves the device's passcode. iOS devices only.true
Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
Offboard from Defender for EndpointOffboards the device from Microsoft Defender for Endpoint, so that it stops reporting to Defender. The device is matched to its Defender record by its Entra ID device ID, and every matching record that is still onboarded is offboarded. Where the device has no Entra ID device ID, or has no matching Defender record that is currently onboarded, the action reports an error and nothing is offboarded. Offboarding cannot be undone from CIPP, and the device has to be re-onboarded to return it to Defender. Windows devices only.true
Fresh StartReinstalls Windows on the device, asking whether to keep or remove the user's data. Windows devices only.true
Wipe DeviceErases all content and settings on the device, which cannot be undone. Asks whether to keep or remove enrolment data: keeping it re-provisions the device through Autopilot, and removing it retires the device fully. Also asks whether to use a standard or protected wipe, where a protected wipe resumes if interrupted but may leave the device unbootable if it fails. Windows devices only.true
Wipe DeviceErases all content and settings on the device, which cannot be undone. A Recovery PIN (optional, 6 digits) can be supplied, and is needed to unlock Intel Macs that have no T2 security chip once the wipe has finished. macOS devices only.true
Autopilot ResetWipes the device, keeping its enrolment data, and re-provisions it through Windows Autopilot. Windows devices only.true
Delete DeviceDeletes the device record from Intune.true
Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
{% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx index d6be362c30..0b89b92a16 100644 --- a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx +++ b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx @@ -179,6 +179,19 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ hideCondition: (row) => row.operatingSystem !== 'Windows', confirmText: 'Are you sure you want to retrieve the local admin password for [deviceName]?', }, + { + label: 'Retrieve LAPS password', + type: 'POST', + icon: , + url: '/api/ExecGetLocalAdminPassword', + data: { + // macOS LAPS is keyed on the Intune managedDevice id, not azureADDeviceId. + GUID: 'id', + Platform: '!macOS', + }, + hideCondition: (row) => row.operatingSystem !== 'macOS', + confirmText: 'Are you sure you want to retrieve the local admin password for [deviceName]?', + }, { label: 'Rotate Local Admin Password', type: 'POST',