Private/NC-Hlp.Groups.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: (Private) Group helpers ============================================================================================================== function Get-NCGraphObjectLabel { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$InputObject ) if ($null -eq $InputObject) { return $null } foreach ($name in @('userPrincipalName', 'displayName', 'appDisplayName', 'id')) { $value = Get-NCGraphItemValue -InputObject $InputObject -Name $name if ($value) { return [string]$value } } return [string]$InputObject } function Test-NCGraphItemProperty { <# .SYNOPSIS Tells whether a Graph item (Hashtable from Invoke-MgGraphRequest, or object) carries a property. #> [CmdletBinding()] param( [object]$InputObject, [Parameter(Mandatory = $true)] [string]$Name ) if ($null -eq $InputObject) { return $false } if ($InputObject -is [System.Collections.IDictionary]) { return [bool]$InputObject.Contains($Name) } return ($null -ne $InputObject.PSObject.Properties[$Name]) } function Get-NCGraphItemValue { <# .SYNOPSIS Reads a property of a Graph item (Hashtable from Invoke-MgGraphRequest, or object); $null when absent. #> [CmdletBinding()] param( [object]$InputObject, [Parameter(Mandatory = $true)] [string]$Name ) if (-not (Test-NCGraphItemProperty -InputObject $InputObject -Name $Name)) { return $null } if ($InputObject -is [System.Collections.IDictionary]) { return $InputObject[$Name] } return $InputObject.PSObject.Properties[$Name].Value } function Resolve-NCEntraGroup { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$GroupName, [string]$GroupId ) if (-not [string]::IsNullOrWhiteSpace($GroupId)) { try { return Get-MgGroup -GroupId $GroupId -ErrorAction Stop } catch { Write-NCMessage "Entra group with ID '$GroupId' not found: $($_.Exception.Message)" -Level ERROR return } } $escapedName = $GroupName.Replace("'", "''") try { $resolvedGroup = Get-MgGroup -Filter "displayName eq '$escapedName'" -All -ErrorAction Stop | Select-Object -First 1 } catch { Write-NCMessage "Unable to resolve group '$GroupName': $($_.Exception.Message)" -Level ERROR return } if (-not $resolvedGroup) { try { $resolvedGroup = Get-MgGroup -GroupId $GroupName -ErrorAction Stop } catch { Write-NCMessage "Entra group '$GroupName' not found by name or ID" -Level ERROR return } } return $resolvedGroup } function Resolve-NCEntraOwner { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$OwnerIdentifier, [switch]$TreatInputAsId ) if ([string]::IsNullOrWhiteSpace($OwnerIdentifier)) { return } $owner = $null $trimmed = $OwnerIdentifier.Trim() $looksLikeGuid = $trimmed -match '^[0-9a-fA-F-]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' if ($TreatInputAsId.IsPresent -or $looksLikeGuid) { try { $owner = Get-MgUser -UserId $trimmed -Property Id,UserPrincipalName,DisplayName -ErrorAction Stop } catch { $owner = [pscustomobject]@{ Id = $trimmed UserPrincipalName = $null DisplayName = $trimmed } } } else { try { $owner = Get-MgUser -UserId $trimmed -Property Id,UserPrincipalName,DisplayName -ErrorAction Stop } catch { $resolvedIdentifier = Find-UserRecipient -UserPrincipalName $trimmed -PreferGraphIdentity if ($resolvedIdentifier) { try { $owner = Get-MgUser -UserId $resolvedIdentifier -Property Id,UserPrincipalName,DisplayName -ErrorAction Stop } catch { Write-NCMessage "Unable to resolve owner '$OwnerIdentifier': $($_.Exception.Message)" -Level ERROR return } } else { Write-NCMessage "Owner '$OwnerIdentifier' not found." -Level WARNING return } } } if (-not $owner) { Write-NCMessage "Unable to determine object ID for owner '$OwnerIdentifier'." -Level ERROR return } $ownerLabel = if ($owner.UserPrincipalName) { $owner.UserPrincipalName } elseif ($owner.DisplayName) { $owner.DisplayName } else { $owner.Id } return [pscustomobject]@{ Id = [string]$owner.Id Label = [string]$ownerLabel } } function Resolve-NCEntraGroupUserTarget { <# .SYNOPSIS Resolves group-membership user inputs to object IDs with batched Graph lookups. .DESCRIPTION Object IDs (or every input when -TreatInputAsId is set) pass through unchanged. Other inputs are resolved through Resolve-NCGraphUserBatch; inputs that cannot be resolved are skipped (the resolver already reported them). .PARAMETER UserIdentifier UPNs, mail addresses, display names or object IDs. .PARAMETER TreatInputAsId Treat every input as an object ID. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [string[]]$UserIdentifier, [switch]$TreatInputAsId ) $guidPattern = '^[0-9a-fA-F-]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $isId = { param($value) $TreatInputAsId.IsPresent -or $value -match $guidPattern } $lookup = @($UserIdentifier | Where-Object { -not (& $isId $_) }) $resolved = if ($lookup.Count -gt 0) { Resolve-NCGraphUserBatch -Identifier $lookup -Property @('id', 'userPrincipalName', 'displayName') } else { @{} } foreach ($user in $UserIdentifier) { if (& $isId $user) { [pscustomobject]@{ Input = $user; Id = $user; Label = $user } continue } $match = $resolved[$user] if (-not $match) { continue } if (-not $match.id) { Write-NCMessage "Unable to determine object ID for user '$user'." -Level ERROR continue } $label = if ($match.userPrincipalName) { $match.userPrincipalName } else { $match.displayName } [pscustomobject]@{ Input = $user; Id = $match.id; Label = $label } } } function Resolve-NCEntraDeviceTargetBatch { <# .SYNOPSIS Resolves device inputs (object IDs or display names) with batched Graph lookups. .DESCRIPTION Object IDs (or every input when -TreatInputAsId is set) pass through unchanged. Display names are looked up with batched GET /devices requests; inputs that cannot be resolved are reported and skipped. When several devices match a name the first one is used (with a warning). .PARAMETER DeviceIdentifier Device object IDs or display names. .PARAMETER TreatInputAsId Treat every input as an object ID. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [string[]]$DeviceIdentifier, [switch]$TreatInputAsId ) $guidPattern = '^[0-9a-fA-F-]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $requests = [System.Collections.Generic.List[object]]::new() for ($i = 0; $i -lt $DeviceIdentifier.Count; $i++) { $value = $DeviceIdentifier[$i] if ($TreatInputAsId.IsPresent -or $value -match $guidPattern) { continue } $filter = "displayName eq '$($value.Replace("'", "''"))'" $requests.Add(@{ Id = "d$i"; Method = 'GET'; Url = "/devices?`$filter=$([uri]::EscapeDataString($filter))&`$select=id,displayName,deviceId" }) } $lookup = @{} if ($requests.Count -gt 0) { foreach ($result in @(Invoke-NCGraphBatchCollection -Requests @($requests) -Activity 'Resolving devices')) { $lookup[$result.Id] = $result } } for ($i = 0; $i -lt $DeviceIdentifier.Count; $i++) { $value = $DeviceIdentifier[$i] if ($TreatInputAsId.IsPresent -or $value -match $guidPattern) { [pscustomobject]@{ Input = $value; Id = $value; Label = $value } continue } $result = $lookup["d$i"] if (-not $result.Success) { Write-NCMessage "Unable to resolve device '$value': $($result.ErrorMessage)" -Level ERROR continue } $found = @($result.Items) if ($found.Count -eq 0) { Write-NCMessage "Device '$value' not found" -Level WARNING continue } if ($found.Count -gt 1) { Write-NCMessage "Multiple devices matched '$value'. Using the first result ($($found[0].displayName))" -Level WARNING } $device = $found[0] if (-not $device.id) { Write-NCMessage "Unable to determine object ID for device '$value'." -Level ERROR continue } [pscustomobject]@{ Input = $value; Id = [string]$device.id; Label = $device.displayName } } } function Resolve-NCEntraOwnerBatch { <# .SYNOPSIS Resolves owner inputs with batched Graph lookups, matching Resolve-NCEntraOwner. .DESCRIPTION Every input is looked up with GET /users/{input} in $batch requests. Inputs that Resolve-NCEntraOwner treats as object IDs (GUIDs, or every input with -TreatInputAsId) fall back to an ID-only placeholder when the lookup fails, exactly like Resolve-NCEntraOwner. Any other input the batch could not resolve as a user is handed to Resolve-NCEntraOwner, which keeps its alias/display-name handling and its messages. The batch itself never prints for a failed lookup, so an input is reported at most once. Output objects have the same properties as Resolve-NCEntraOwner (Id, Label), in input order. .PARAMETER OwnerIdentifier UPNs, mail addresses, display names or object IDs. .PARAMETER TreatInputAsId Treat every input as an object ID. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [string[]]$OwnerIdentifier, [switch]$TreatInputAsId ) $guidPattern = '^[0-9a-fA-F-]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' $inputs = @($OwnerIdentifier | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($inputs.Count -eq 0) { return } $requests = @(for ($i = 0; $i -lt $inputs.Count; $i++) { @{ Id = "o$i"; Method = 'GET'; Url = "/users/$([uri]::EscapeDataString($inputs[$i].Trim()))?`$select=id,userPrincipalName,displayName" } }) $lookup = @{} foreach ($result in @(Invoke-NCGraphBatch -Requests $requests -Activity 'Resolving owners')) { $lookup[$result.Id] = $result } for ($i = 0; $i -lt $inputs.Count; $i++) { $original = $inputs[$i] $trimmed = $original.Trim() $isId = $TreatInputAsId.IsPresent -or ($trimmed -match $guidPattern) $result = $lookup["o$i"] if ($result -and $result.Success) { $owner = $result.Body if (-not $owner -or -not $owner.id) { Write-NCMessage "Unable to determine object ID for owner '$original'." -Level ERROR continue } $ownerLabel = if ($owner.userPrincipalName) { $owner.userPrincipalName } elseif ($owner.displayName) { $owner.displayName } else { $owner.id } [pscustomobject]@{ Id = [string]$owner.id; Label = [string]$ownerLabel } continue } if ($isId) { [pscustomobject]@{ Id = $trimmed; Label = $trimmed } continue } Resolve-NCEntraOwner -OwnerIdentifier $original } } function ConvertTo-NCGraphDirectoryObject { <# .SYNOPSIS Wraps a raw Graph directory object like the SDK's DirectoryObject (Id + AdditionalProperties). .DESCRIPTION Batch responses return camelCase JSON objects (pscustomobject or hashtable), while the read functions were written against SDK objects that expose Id and an AdditionalProperties dictionary holding every other key (including '@odata.type'). This wrapper keeps those consumers unchanged. .PARAMETER Item Raw directory object from a Graph collection response. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [AllowNull()] [object]$Item ) $additional = @{} if ($Item -is [System.Collections.IDictionary]) { foreach ($key in $Item.Keys) { if ($key -ne 'id') { $additional[[string]$key] = $Item[$key] } } } elseif ($null -ne $Item) { foreach ($property in $Item.PSObject.Properties) { if ($property.Name -ne 'id') { $additional[$property.Name] = $property.Value } } } [pscustomobject]@{ Id = $Item.id AdditionalProperties = $additional } } |