Skip to content
This repository was archived by the owner on Feb 2, 2026. It is now read-only.

Commit 0574a8c

Browse files
authored
Fixed issue with keepInSyncWith and clarified some comments for $fieldsToCheck object (#20)
1 parent 2303b17 commit 0574a8c

File tree

1 file changed

+109
-162
lines changed

1 file changed

+109
-162
lines changed

uniquenessCheck.ps1

Lines changed: 109 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Check if fields are unique
44
# PowerShell V2
55
#################################################
6+
67
# Enable TLS1.2
78
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
89

@@ -15,38 +16,12 @@ $InformationPreference = "Continue"
1516
$WarningPreference = "Continue"
1617

1718
#region functions
18-
function Convert-StringToBoolean($obj) {
19-
if ($obj -is [PSCustomObject]) {
20-
foreach ($property in $obj.PSObject.Properties) {
21-
$value = $property.Value
22-
if ($value -is [string]) {
23-
$lowercaseValue = $value.ToLower()
24-
if ($lowercaseValue -eq "true") {
25-
$obj.$($property.Name) = $true
26-
}
27-
elseif ($lowercaseValue -eq "false") {
28-
$obj.$($property.Name) = $false
29-
}
30-
}
31-
elseif ($value -is [PSCustomObject] -or $value -is [System.Collections.IDictionary]) {
32-
$obj.$($property.Name) = Convert-StringToBoolean $value
33-
}
34-
elseif ($value -is [System.Collections.IList]) {
35-
for ($i = 0; $i -lt $value.Count; $i++) {
36-
$value[$i] = Convert-StringToBoolean $value[$i]
37-
}
38-
$obj.$($property.Name) = $value
39-
}
40-
}
41-
}
42-
return $obj
43-
}
44-
4519
function Resolve-MicrosoftGraphAPIError {
4620
[CmdletBinding()]
4721
param (
4822
[Parameter(Mandatory)]
49-
[object] $ErrorObject
23+
[object]
24+
$ErrorObject
5025
)
5126
process {
5227
$httpErrorObj = [PSCustomObject]@{
@@ -55,36 +30,33 @@ function Resolve-MicrosoftGraphAPIError {
5530
ErrorDetails = $ErrorObject.Exception.Message
5631
FriendlyMessage = $ErrorObject.Exception.Message
5732
}
58-
5933
if (-not [string]::IsNullOrEmpty($ErrorObject.ErrorDetails.Message)) {
6034
$httpErrorObj.ErrorDetails = $ErrorObject.ErrorDetails.Message
6135
}
62-
elseif ($ErrorObject.Exception -is [System.Net.WebException] -and $ErrorObject.Exception.Response) {
63-
$streamReaderResponse = [System.IO.StreamReader]::new($ErrorObject.Exception.Response.GetResponseStream()).ReadToEnd()
64-
if (-not [string]::IsNullOrEmpty($streamReaderResponse)) {
65-
$httpErrorObj.ErrorDetails = $streamReaderResponse
36+
elseif ($ErrorObject.Exception.GetType().FullName -eq 'System.Net.WebException') {
37+
if ($null -ne $ErrorObject.Exception.Response) {
38+
$streamReaderResponse = [System.IO.StreamReader]::new($ErrorObject.Exception.Response.GetResponseStream()).ReadToEnd()
39+
if (-not [string]::IsNullOrEmpty($streamReaderResponse)) {
40+
$httpErrorObj.ErrorDetails = $streamReaderResponse
41+
}
6642
}
6743
}
68-
6944
try {
70-
$errorObjectConverted = $httpErrorObj.ErrorDetails | ConvertFrom-Json -ErrorAction Stop
45+
$errorObjectConverted = $ErrorObject | ConvertFrom-Json -ErrorAction Stop
7146

72-
if ($errorObjectConverted.error_description) {
47+
if ($null -ne $errorObjectConverted.error_description) {
7348
$httpErrorObj.FriendlyMessage = $errorObjectConverted.error_description
7449
}
75-
elseif ($errorObjectConverted.error) {
76-
$httpErrorObj.FriendlyMessage = $errorObjectConverted.error.message
77-
if ($errorObjectConverted.error.code) {
78-
$httpErrorObj.FriendlyMessage += " Error code: $($errorObjectConverted.error.code)."
79-
}
80-
if ($errorObjectConverted.error.details) {
81-
if ($errorObjectConverted.error.details.message) {
82-
$httpErrorObj.FriendlyMessage += " Details message: $($errorObjectConverted.error.details.message)"
83-
}
84-
if ($errorObjectConverted.error.details.code) {
85-
$httpErrorObj.FriendlyMessage += " Details code: $($errorObjectConverted.error.details.code)."
50+
elseif ($null -ne $errorObjectConverted.error) {
51+
if ($null -ne $errorObjectConverted.error.message) {
52+
$httpErrorObj.FriendlyMessage = $errorObjectConverted.error.message
53+
if ($null -ne $errorObjectConverted.error.code) {
54+
$httpErrorObj.FriendlyMessage = $httpErrorObj.FriendlyMessage + " Error code: $($errorObjectConverted.error.code)"
8655
}
8756
}
57+
else {
58+
$httpErrorObj.FriendlyMessage = $errorObjectConverted.error
59+
}
8860
}
8961
else {
9062
$httpErrorObj.FriendlyMessage = $ErrorObject
@@ -93,174 +65,147 @@ function Resolve-MicrosoftGraphAPIError {
9365
catch {
9466
$httpErrorObj.FriendlyMessage = $httpErrorObj.ErrorDetails
9567
}
96-
9768
Write-Output $httpErrorObj
9869
}
9970
}
10071

101-
function New-AuthorizationHeaders {
102-
[CmdletBinding()]
103-
[OutputType([System.Collections.Generic.Dictionary[[String], [String]]])]
104-
param(
105-
[parameter(Mandatory)]
106-
[string]
107-
$TenantId,
108-
109-
[parameter(Mandatory)]
110-
[string]
111-
$ClientId,
112-
113-
[parameter(Mandatory)]
114-
[string]
115-
$ClientSecret
116-
)
117-
try {
118-
Write-Verbose "Creating Access Token"
119-
$baseUri = "https://login.microsoftonline.com/"
120-
$authUri = $baseUri + "$TenantId/oauth2/token"
121-
122-
$body = @{
123-
grant_type = "client_credentials"
124-
client_id = "$ClientId"
125-
client_secret = "$ClientSecret"
126-
resource = "https://graph.microsoft.com"
127-
}
128-
129-
$Response = Invoke-RestMethod -Method POST -Uri $authUri -Body $body -ContentType 'application/x-www-form-urlencoded'
130-
$accessToken = $Response.access_token
131-
132-
#Add the authorization header to the request
133-
Write-Verbose 'Adding Authorization headers'
134-
135-
$headers = [System.Collections.Generic.Dictionary[[String], [String]]]::new()
136-
$headers.Add('Authorization', "Bearer $accesstoken")
137-
$headers.Add('Accept', 'application/json')
138-
$headers.Add('Content-Type', 'application/json')
139-
# Needed to filter on specific attributes (https://docs.microsoft.com/en-us/graph/aad-advanced-queries)
140-
$headers.Add('ConsistencyLevel', 'eventual')
141-
142-
Write-Output $headers
143-
}
144-
catch {
145-
throw $_
146-
}
147-
}
148-
149-
function Resolve-HTTPError {
150-
[CmdletBinding()]
151-
param (
152-
[Parameter(Mandatory,
153-
ValueFromPipeline
154-
)]
155-
[object]$ErrorObject
156-
)
157-
process {
158-
$httpErrorObj = [PSCustomObject]@{
159-
FullyQualifiedErrorId = $ErrorObject.FullyQualifiedErrorId
160-
MyCommand = $ErrorObject.InvocationInfo.MyCommand
161-
RequestUri = $ErrorObject.TargetObject.RequestUri
162-
ScriptStackTrace = $ErrorObject.ScriptStackTrace
163-
ErrorMessage = ''
164-
}
165-
if ($ErrorObject.Exception.GetType().FullName -eq 'Microsoft.Powershell.Commands.HttpResponseException') {
166-
$httpErrorObj.ErrorMessage = $ErrorObject.ErrorDetails.Message
167-
}
168-
elseif ($ErrorObject.Exception.GetType().FullName -eq 'System.Net.WebException') {
169-
$httpErrorObj.ErrorMessage = [System.IO.StreamReader]::new($ErrorObject.Exception.Response.GetResponseStream()).ReadToEnd()
72+
function Convert-StringToBoolean($obj) {
73+
foreach ($property in $obj.PSObject.Properties) {
74+
$value = $property.Value
75+
if ($value -is [string]) {
76+
try {
77+
$obj.$($property.Name) = [System.Convert]::ToBoolean($value)
78+
}
79+
catch {
80+
# Handle cases where conversion fails
81+
$obj.$($property.Name) = $value
82+
}
17083
}
171-
Write-Output $httpErrorObj
17284
}
85+
return $obj
17386
}
17487
#endregion functions
17588

17689
#region Fields to check
17790
$fieldsToCheck = [PSCustomObject]@{
178-
"userPrincipalName" = [PSCustomObject]@{
91+
"userPrincipalName" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields.
17992
accountValue = $actionContext.Data.userPrincipalName
180-
keepInSyncWith = @("mail", "mailNickname") # The properties to keep in sync with, if one of these properties isn't unique, this property wil be treated as not unique as well
181-
crossCheckOn = @("mail") # The properties to keep in cross-check on
93+
keepInSyncWith = @("mail", "mailNickname") # Properties to synchronize with. If any of these properties are not unique, this property will also be treated as non-unique.
94+
crossCheckOn = @("mail") # Properties to cross-check for uniqueness.
18295
}
183-
"mail" = [PSCustomObject]@{ # This is the value that is returned to HelloID in NonUniqueFields
96+
"mail" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields.
18497
accountValue = $actionContext.Data.mail
185-
keepInSyncWith = @("userPrincipalName", "mailNickname") # The properties to keep in sync with, if one of these properties isn't unique, this property wil be treated as not unique as well
186-
crossCheckOn = @("userPrincipalName") # The properties to keep in cross-check on
98+
keepInSyncWith = @("userPrincipalName", "mailNickname") # Properties to synchronize with. If any of these properties are not unique, this property will also be treated as non-unique.
99+
crossCheckOn = @("userPrincipalName") # Properties to cross-check for uniqueness.
187100
}
188-
"mailNickname" = [PSCustomObject]@{ # This is the value that is returned to HelloID in NonUniqueFields
101+
"mailNickname" = [PSCustomObject]@{ # Value returned to HelloID in NonUniqueFields.
189102
accountValue = $actionContext.Data.mailNickname
190-
keepInSyncWith = @("userPrincipalName", "mail") # The properties to keep in sync with, if one of these properties isn't unique, this property wil be treated as not unique as well
191-
crossCheckOn = $null # The properties to keep in cross-check on
103+
keepInSyncWith = @("userPrincipalName", "mail") # Properties to synchronize with. If any of these properties are not unique, this property will also be treated as non-unique.
104+
crossCheckOn = $null # Properties to cross-check for uniqueness.
192105
}
193106
}
194107
#endregion Fields to check
195108

196109
try {
197-
#region Create authorization headers
198-
$actionMessage = "creating authorization headers"
199-
200-
$authorizationHeadersSplatParams = @{
201-
TenantId = $actionContext.Configuration.TenantID
202-
ClientId = $actionContext.Configuration.AppId
203-
ClientSecret = $actionContext.Configuration.AppSecret
110+
#region Create access token
111+
$actionMessage = "creating access token"
112+
113+
$createAccessTokenBody = @{
114+
grant_type = "client_credentials"
115+
client_id = $actionContext.Configuration.AppId
116+
client_secret = $actionContext.Configuration.AppSecret
117+
resource = "https://graph.microsoft.com"
204118
}
119+
120+
$createAccessTokenSplatParams = @{
121+
Uri = "https://login.microsoftonline.com/$($actionContext.Configuration.TenantID)/oauth2/token"
122+
Headers = $headers
123+
Body = $createAccessTokenBody
124+
Method = "POST"
125+
ContentType = "application/x-www-form-urlencoded"
126+
Verbose = $false
127+
ErrorAction = "Stop"
128+
}
129+
130+
$createAccessTokenResonse = Invoke-RestMethod @createAccessTokenSplatParams
131+
132+
Write-Verbose "Created access token. Expires in: $($createAccessTokenResonse.expires_in | ConvertTo-Json)"
133+
#endregion Create access token
134+
135+
#region Create headers
136+
$actionMessage = "creating headers"
137+
138+
$headers = @{
139+
"Accept" = "application/json"
140+
"Content-Type" = "application/json;charset=utf-8"
141+
"Mwp-Api-Version" = "1.0"
142+
}
143+
144+
Write-Verbose "Created headers. Result (without Authorization): $($headers | ConvertTo-Json)."
205145

206-
$headers = New-AuthorizationHeaders @authorizationHeadersSplatParams
207-
208-
Write-Verbose "Created authorization headers. Result: $($headers | ConvertTo-Json)"
209-
#endregion Create authorization headers
146+
# Add Authorization after printing splat
147+
$headers['Authorization'] = "Bearer $($createAccessTokenResonse.access_token)"
148+
#endregion Create headers
210149

211150
if ($actionContext.Operation.ToLower() -ne "create") {
212151
#region Verify account reference
213152
$actionMessage = "verifying account reference"
153+
214154
if ([string]::IsNullOrEmpty($($actionContext.References.Account))) {
215155
throw "The account reference could not be found"
216156
}
217157
#endregion Verify account reference
218158
}
159+
219160
foreach ($fieldToCheck in $fieldsToCheck.PsObject.Properties | Where-Object { -not[String]::IsNullOrEmpty($_.Value.accountValue) }) {
220-
#region Get Microsoft Entra ID account
221-
# Microsoft docs: https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0&tabs=http
222-
$actionMessage = "querying Microsoft Entra ID account where [$($fieldToCheck.Name)] = [$($fieldToCheck.Value.accountValue)]"
161+
#region Get account
162+
# API docs: https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0&tabs=http
163+
$actionMessage = "querying account where [$($fieldToCheck.Name)] = [$($fieldToCheck.Value.accountValue)]"
223164

224-
$baseUri = "https://graph.microsoft.com/"
225-
$filter = "$($fieldToCheck.Name) eq '$($fieldToCheck.Value.accountValue)'"
165+
$filter = "$($fieldToCheck.Name) eq '$($fieldToCheck.Value.accountValue)'"
226166
if (($fieldToCheck.Value.crossCheckOn | Measure-Object).Count -ge 1) {
227167
foreach ($fieldToCrossCheckOn in $fieldToCheck.Value.crossCheckOn) {
228168
$filter = $filter + " OR $($fieldToCrossCheckOn) eq '$($fieldToCheck.Value.accountValue)'"
229169
}
230170
}
231-
$getMicrosoftEntraIDAccountSplatParams = @{
232-
Uri = "$($baseUri)/v1.0/users?`$filter=$($filter)&`$select=id,$($fieldToCheck.Name)"
233-
Headers = $headers
171+
172+
$getEntraIDAccountSplatParams = @{
173+
Uri = "https://graph.microsoft.com/v1.0/users?`$filter=$($filter)&`$select=id,$($fieldToCheck.Name)"
234174
Method = "GET"
235175
Verbose = $false
236176
ErrorAction = "Stop"
237177
}
238-
$currentMicrosoftEntraIDAccount = $null
239-
$currentMicrosoftEntraIDAccount = (Invoke-RestMethod @getMicrosoftEntraIDAccountSplatParams).Value
240178

241-
Write-Verbose "Queried Microsoft Entra ID account where [$($fieldToCheck.Name)] = [$($fieldToCheck.Value.accountValue)]. Result: $($currentMicrosoftEntraIDAccount | ConvertTo-Json)."
242-
#endregion Get Microsoft Entra ID account
179+
Write-Verbose "SplatParams: $($getEntraIDAccountSplatParams | ConvertTo-Json)"
180+
181+
# Add header after printing splat
182+
$getEntraIDAccountSplatParams['Headers'] = $headers
183+
184+
$getEntraIDAccountResponse = $null
185+
$getEntraIDAccountResponse = Invoke-RestMethod @getEntraIDAccountSplatParams
186+
$correlatedAccount = $getEntraIDAccountResponse.Value
187+
188+
Write-Verbose "Queried account where [$($fieldToCheck.Name)] = [$($fieldToCheck.Value.accountValue)]. Result: $($correlatedAccount | ConvertTo-Json)"
189+
#endregion Get account
243190

244191
#region Check property uniqueness
245-
$actionMessage = "checking if property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is unique in Microsoft Entra ID"
246-
if (($currentMicrosoftEntraIDAccount | Measure-Object).count -gt 0) {
247-
if ($actionContext.Operation.ToLower() -ne "create" -and $currentMicrosoftEntraIDAccount.id -eq $actionContext.References.Account) {
192+
$actionMessage = "checking if property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is unique"
193+
if (($correlatedAccount | Measure-Object).count -gt 0) {
194+
if ($actionContext.Operation.ToLower() -ne "create" -and $correlatedAccount.id -eq $actionContext.References.Account) {
248195
Write-Verbose "Person is using property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] themselves."
249196
}
250197
else {
251-
Write-Verbose "Property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is not unique in Microsoft Entra ID."
252-
Write-Verbose "In use by: $($currentMicrosoftEntraIDAccount | ConvertTo-Json)."
198+
Write-Verbose "Property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is not unique. In use by account with ID: $($correlatedAccount.id)"
253199
[void]$outputContext.NonUniqueFields.Add($fieldToCheck.Name)
254-
255200
if (($fieldToCheck.Value.keepInSyncWith | Measure-Object).Count -ge 1) {
256-
foreach ($fieldToKeepInSyncWith in $fieldToCheck.Value.keepInSyncWith | Where-Object { $_ -in $actionContext.Data.PsObject.Properties }) {
201+
foreach ($fieldToKeepInSyncWith in $fieldToCheck.Value.keepInSyncWith | Where-Object { $_ -in $actionContext.Data.PsObject.Properties.Name }) {
257202
[void]$outputContext.NonUniqueFields.Add($fieldToKeepInSyncWith)
258203
}
259204
}
260205
}
261206
}
262-
elseif (($currentMicrosoftEntraIDAccount | Measure-Object).count -eq 0) {
263-
Write-Verbose "Property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is unique in Microsoft Entra ID."
207+
elseif (($correlatedAccount | Measure-Object).count -eq 0) {
208+
Write-Verbose "Property [$($fieldToCheck.Name)] with value [$($fieldToCheck.Value.accountValue)] is unique."
264209
}
265210
#endregion Check property uniqueness
266211
}
@@ -274,16 +219,18 @@ catch {
274219
$($ex.Exception.GetType().FullName -eq 'System.Net.WebException')) {
275220
$errorObj = Resolve-MicrosoftGraphAPIError -ErrorObject $ex
276221
$auditMessage = "Error $($actionMessage). Error: $($errorObj.FriendlyMessage)"
277-
Write-Warning "Error at Line [$($errorObj.ScriptLineNumber)]: $($errorObj.Line). Error: $($errorObj.ErrorDetails)"
222+
$warningMessage = "Error at Line [$($errorObj.ScriptLineNumber)]: $($errorObj.Line). Error: $($errorObj.ErrorDetails)"
278223
}
279224
else {
280225
$auditMessage = "Error $($actionMessage). Error: $($ex.Exception.Message)"
281-
Write-Warning "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)"
226+
$warningMessage = "Error at Line [$($ex.InvocationInfo.ScriptLineNumber)]: $($ex.InvocationInfo.Line). Error: $($ex.Exception.Message)"
282227
}
283228

284229
# Set Success to false
285230
$outputContext.Success = $false
286231

232+
Write-Warning $warningMessage
233+
287234
# Required to write an error as uniqueness check doesn't show auditlog
288235
Write-Error $auditMessage
289-
}
236+
}

0 commit comments

Comments
 (0)