IntuneLaps.psm1
|
#Requires -Version 5.1 <# .SYNOPSIS IntuneLaps root module loader. .DESCRIPTION Dot-sources all Public and Private function files and exports Public functions. Automatically installs Microsoft.Graph.Authentication if not present. #> # ─── Permission model types ─────────────────────────────────────────────────── # IMPORTANT: Defined directly in .psm1, NOT in dot-sourced files. # PowerShell 5.1 and 7 both require types to live in the root .psm1 so that # callers can reference them (e.g. [LapsPermissionLevel]::Full) without # needing 'using module'. Dot-sourced class files would lose type accessibility. enum LapsPermissionLevel { None = 0 Metadata = 1 Full = 2 } class LapsSession { # ── Public properties ──────────────────────────────────────────────────── [string] $Account [string] $TenantId [bool] $Connected [LapsPermissionLevel] $EffectiveLevel [string[]] $ActiveRoles [string[]] $PimEligibleRoles [hashtable[]] $AuScopedRoles # @{ RoleName; AuId; AuDisplayName } # ── Internal gate results (hidden but accessible via direct property notation) hidden [LapsPermissionLevel] $ScopeLevel hidden [LapsPermissionLevel] $RoleLevel hidden [string[]] $GrantedScopes hidden [string[]] $ActiveRoleIds hidden [string[]] $PimEligibleRoleIds # ── Role template GUIDs — stable across all tenants, never change on rename # Source: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference hidden static [string[]] $PasswordRoleIds = @( '62e90394-69f5-4237-9190-012177145e10' # Global Administrator '7698a772-787b-4ac8-901f-60d6b08affd2' # Cloud Device Administrator '3a2c62db-5318-420d-8d74-23affee5d9d5' # Intune Administrator ) hidden static [string[]] $MetadataRoleIds = @( '62e90394-69f5-4237-9190-012177145e10' # Global Administrator '7698a772-787b-4ac8-901f-60d6b08affd2' # Cloud Device Administrator '3a2c62db-5318-420d-8d74-23affee5d9d5' # Intune Administrator '729827e3-9c14-49f7-bb1b-9608f156bbb8' # Helpdesk Administrator '194ae4cb-b126-40b2-bd5b-6091b380977d' # Security Administrator '5d6b6bb7-de71-4623-b4af-96380a352509' # Security Reader 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' # Global Reader ) # ── Display names — used only in user-facing messages, not for gate checks hidden static [string[]] $PasswordRoleNames = @( 'Global Administrator', 'Cloud Device Administrator', 'Intune Administrator' ) hidden static [string[]] $MetadataRoleNames = @( 'Global Administrator', 'Cloud Device Administrator', 'Intune Administrator', 'Helpdesk Administrator', 'Security Administrator', 'Security Reader', 'Global Reader' ) # ── Constructor — called by Build-LapsSession ──────────────────────────── LapsSession([object]$GraphContext, [string[]]$Scopes, [object]$RoleInfo) { $this.Account = $GraphContext.Account $this.TenantId = $GraphContext.TenantId $this.GrantedScopes = $Scopes $this.Connected = $true # Guard against null role info (Get-UserRoleInfo is always best-effort) $this.ActiveRoles = if ($RoleInfo.ActiveRoles) { [string[]]$RoleInfo.ActiveRoles } else { [string[]]@() } $this.ActiveRoleIds = if ($RoleInfo.ActiveRoleIds) { [string[]]$RoleInfo.ActiveRoleIds } else { [string[]]@() } $this.PimEligibleRoles = if ($RoleInfo.PimEligibleRoles) { [string[]]$RoleInfo.PimEligibleRoles } else { [string[]]@() } $this.PimEligibleRoleIds = if ($RoleInfo.PimEligibleRoleIds) { [string[]]$RoleInfo.PimEligibleRoleIds } else { [string[]]@() } $this.AuScopedRoles = if ($RoleInfo.AuScopedRoles) { [hashtable[]]$RoleInfo.AuScopedRoles } else { [hashtable[]]@() } # Dual-gate: effective level is the lower of scope and role $this.ScopeLevel = $this.EvaluateScopeGate() $this.RoleLevel = $this.EvaluateRoleGate() $this.EffectiveLevel = [LapsPermissionLevel][Math]::Min( [int]$this.ScopeLevel, [int]$this.RoleLevel ) } # ── Gate evaluators ────────────────────────────────────────────────────── hidden [LapsPermissionLevel] EvaluateScopeGate() { if ('DeviceLocalCredential.Read.All' -in $this.GrantedScopes) { return [LapsPermissionLevel]::Full } if ('DeviceLocalCredential.ReadBasic.All' -in $this.GrantedScopes) { return [LapsPermissionLevel]::Metadata } return [LapsPermissionLevel]::None } hidden [LapsPermissionLevel] EvaluateRoleGate() { # Compare against stable role template GUIDs — immune to display name renames. # Explicit foreach avoids PS 5.1 edge cases with Where-Object inside class methods. foreach ($Id in $this.ActiveRoleIds) { if ([LapsSession]::PasswordRoleIds -contains $Id) { return [LapsPermissionLevel]::Full } } foreach ($Id in $this.ActiveRoleIds) { if ([LapsSession]::MetadataRoleIds -contains $Id) { return [LapsPermissionLevel]::Metadata } } return [LapsPermissionLevel]::None } # ── Public helpers for cmdlets ─────────────────────────────────────────── [void] AssertMinimumLevel([LapsPermissionLevel]$Required) { if ([int]$this.EffectiveLevel -lt [int]$Required) { [string]$Reason = $this.GetLimitingGateExplanation($Required) throw "Insufficient permissions (current: '$($this.EffectiveLevel)', required: '$Required'). $Reason" } } [bool] CanRetrievePasswords() { return $this.EffectiveLevel -eq [LapsPermissionLevel]::Full } [string] GetLimitingGateExplanation([LapsPermissionLevel]$Target) { $Parts = [System.Collections.Generic.List[string]]::new() if ([int]$this.ScopeLevel -lt [int]$Target) { $Parts.Add("API scope is '$($this.ScopeLevel)' — requires 'DeviceLocalCredential.Read.All' scope") } if ([int]$this.RoleLevel -lt [int]$Target) { [string]$RequiredRoles = if ($Target -eq [LapsPermissionLevel]::Full) { [LapsSession]::PasswordRoleNames -join ', ' } else { [LapsSession]::MetadataRoleNames -join ', ' } $Parts.Add("Entra role is '$($this.RoleLevel)' — requires one of: $RequiredRoles") } return $Parts -join '; and ' } [string[]] GetPimUpgradeHints() { $Hints = [System.Collections.Generic.List[string]]::new() # Iterate by index so we can show the display name while comparing on GUID for ([int]$i = 0; $i -lt $this.PimEligibleRoleIds.Count; $i++) { [string]$Id = $this.PimEligibleRoleIds[$i] [string]$DisplayName = if ($i -lt $this.PimEligibleRoles.Count) { $this.PimEligibleRoles[$i] } else { $Id } [LapsPermissionLevel]$WouldBe = if ([LapsSession]::PasswordRoleIds -contains $Id) { [LapsPermissionLevel]::Full } elseif ([LapsSession]::MetadataRoleIds -contains $Id) { [LapsPermissionLevel]::Metadata } else { continue } if ([int]$WouldBe -gt [int]$this.EffectiveLevel) { $Hints.Add("Activate PIM role '$DisplayName' to reach permission level '$WouldBe'. Go to: https://entra.microsoft.com") } } return $Hints.ToArray() } [bool] HasAuScopedRoles() { return ($this.AuScopedRoles.Count -gt 0) } } # ─── Module-scoped session singleton ───────────────────────────────────────── # Populated by Build-LapsSession (called from Connect-IntuneLaps and Show-IntuneLapsGui). # Cleared by Disconnect-IntuneLaps. Read via Get-CurrentSession. $script:CurrentSession = $null $ErrorActionPreference = 'Stop' # ─── Bootstrap: ensure Microsoft.Graph.Authentication is available ──────────── [string]$GraphAuthModule = 'Microsoft.Graph.Authentication' [version]$GraphAuthMinVersion = '2.0.0' $AvailableVersion = Get-Module -ListAvailable -Name $GraphAuthModule | Where-Object { $_.Version -ge $GraphAuthMinVersion } | Sort-Object Version -Descending | Select-Object -First 1 if (-not $AvailableVersion) { Write-Warning "Installing required module: $GraphAuthModule (minimum v$GraphAuthMinVersion)..." try { Install-Module -Name $GraphAuthModule -Scope CurrentUser -Force -AllowClobber -MinimumVersion $GraphAuthMinVersion.ToString() -ErrorAction Stop } catch { throw "Could not install '$GraphAuthModule' v$($GraphAuthMinVersion)+. Please run: Install-Module $GraphAuthModule -MinimumVersion $GraphAuthMinVersion -Scope CurrentUser" } } if (-not (Get-Module -Name $GraphAuthModule)) { Import-Module -Name $GraphAuthModule -MinimumVersion $GraphAuthMinVersion.ToString() -ErrorAction Stop } # Discover and dot-source Private functions $PrivatePath = Join-Path -Path $PSScriptRoot -ChildPath 'Private' if (Test-Path -Path $PrivatePath) { $PrivateFiles = Get-ChildItem -Path $PrivatePath -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue foreach ($File in $PrivateFiles) { try { . $File.FullName } catch { Write-Error -Message "Failed to import private function [$($File.BaseName)]: $_" } } } # Discover and dot-source Public functions $PublicPath = Join-Path -Path $PSScriptRoot -ChildPath 'Public' if (Test-Path -Path $PublicPath) { $PublicFiles = Get-ChildItem -Path $PublicPath -Filter '*.ps1' -Recurse -ErrorAction SilentlyContinue foreach ($File in $PublicFiles) { try { . $File.FullName } catch { Write-Error -Message "Failed to import public function [$($File.BaseName)]: $_" } } # Exports are controlled by FunctionsToExport in IntuneLaps.psd1 } |