Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,30 @@ All notable changes to this module are recorded here. Format follows
round trip. The batching stays because it is the right shape and it is what a faster realm would
reward; the number is what the lab measured.

- **`Compare-TestEnvironment` compares the people two connected providers hold, the way a
hybrid identity match would.** The seed puts the same people into every directory it knows so
that identity matching across two of them can be tested; this reads the seeded people from two
providers connected in the same session and reports how they line up. People are matched by login
key first - the login with the provider's additions stripped, so jnino is jnino everywhere the
shared logins are kept - and then by display name, folded for case and Unicode normalisation,
because the Active Directory data logs its people in as first name and initial and agrees with the
others only on the names. What matches in neither way is reported as only on one side and is not a
fault, because the providers hold deliberately different populations. For every matched pair the
names are compared by codepoint - display names where both providers keep one, given name and
surname where both keep only those, never a stored display name against a composed one - and a
name that differs is the one finding the command judges; a decomposed José in one directory and
a precomposed one in the other is found by the folded match and then reported as differing. The
enabled state is compared and reported without a verdict, because the seed hangs different states
on the same person on purpose. Each provider implements `Get-<Provider>IdentitySnapshot`, and a
contract test holds every provider folder on disk to it.

Verified live from one session against the lab tenant and the PingOne sandbox, seeded together:
329 people matched by login key, none left to match by name, 328 names compared as given name
and surname with none differing, one account only in PingOne, no enabled state differing, and
the same answer with the providers the other way round. The tenant compared on the parts
because the environment stores no display name, which is why `Get-EntraSeededObject` now selects
the given name and surname.

### Changed

- **The Entra report takes the shared parameters.** `-Format` and `-Path` are kept as aliases of
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,13 @@ judged on what is missing only, because dynamic groups, AD group rules and FreeI
add members the data never lists. The result shape is built only by `New-TestEnvironmentCheck` and
`New-TestEnvironmentVerification` in `Core/`, so one renderer prints every provider.

`Compare-TestEnvironment` is the cross-provider half: each provider's `Get-<Provider>IdentitySnapshot`
reduces the users teardown would find to a `TestIdentity` whose `Key` is the login with that
provider's additions stripped, and `Compare-TestIdentitySnapshot` matches by key, then by display
name folded for case and Unicode normalisation, and judges only the codepoint equality of the
matched names. A display name is never compared against a name composed from parts; PingOne stores
no display name and a composed one would put the family name last for the Han and Japanese people.

### The SecretStore is shared per user, not per module

SecretStore configuration is per user. One machine has one store shared by everything that
Expand Down
144 changes: 144 additions & 0 deletions Core/Compare-TestIdentitySnapshot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
function Compare-TestIdentitySnapshot {
<#
.SYNOPSIS
Compares the people two providers hold, by login key and then by name
.DESCRIPTION
The comparison behind Compare-TestEnvironment, kept apart from the reading so it can be
tested on identities alone. Each side is a list of identities as Get-<Provider>IdentitySnapshot
returns them: a Key, which is the login with whatever the provider added stripped off - the
seed prefix, the UPN suffix, the email domain - so that jnino is jnino in every provider that
keeps the shared logins; a DisplayName where the provider has one; GivenName and Surname where
it has those; and Enabled.

Matching is by Key first, then by DisplayName among what is left, because Active Directory's
data logs its people in as first name and initial while every other provider uses the shared
keys, and the two agree only on the names. What matches in neither way is reported as only on
one side. That is not a fault: the providers deliberately hold different populations, and the
report says so rather than judging it.

For the pairs that matched, the names are compared by codepoint, never with -eq, which is
what hybrid identity matching across two directories depends on: the display names where both
sides have one, otherwise the given name and surname where both have those, otherwise nothing.
A stored display name is never compared against one composed from parts.
Enabled is compared and reported without a verdict, because the seed hangs different states on
the same person in different providers on purpose. The verdict is the names alone.
.PARAMETER Left
The first provider's identities
.PARAMETER Right
The second provider's identities
.OUTPUTS
PSCustomObject typed TestEnvironmentComparison, with the two sides, MatchedByKey,
MatchedByName, OnlyLeft, OnlyRight, NameMismatch, StateDifference and Passed
.EXAMPLE
PS> Compare-TestIdentitySnapshot -Left $entra -Right $pingOne

Matches 329 people by key, reports the one PingOne-only account, and passes when every
matched pair's names agree.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[PSObject]$Left,

[Parameter(Mandatory = $true)]
[ValidateNotNull()]
[PSObject]$Right
)

$leftIdentities = @($Left.Identities | Where-Object { $null -ne $_ })
$rightIdentities = @($Right.Identities | Where-Object { $null -ne $_ })

$describe = { param($identity) if ($identity.DisplayName) { '{0} ({1})' -f $identity.Key, $identity.DisplayName } else { $identity.Key } }

# By key first.
$rightByKey = @{}
foreach ($identity in $rightIdentities) {
if ($identity.Key -and -not $rightByKey.ContainsKey($identity.Key)) { $rightByKey[[string]$identity.Key] = $identity }
}
$pairs = New-Object System.Collections.Generic.List[object]
$matchedRight = New-Object 'System.Collections.Generic.HashSet[object]'
$unmatchedLeft = New-Object System.Collections.Generic.List[object]
foreach ($identity in $leftIdentities) {
if ($identity.Key -and $rightByKey.ContainsKey($identity.Key)) {
$other = $rightByKey[[string]$identity.Key]
$pairs.Add([PSCustomObject]@{ Left = $identity; Right = $other; By = 'Key' })
$null = $matchedRight.Add($other)
}
else { $unmatchedLeft.Add($identity) }
}

# Then by display name among what is left on both sides. Matched on the normalised,
# case-folded form, so that a name one directory stored decomposed still finds its person
# and the codepoint difference is then reported as the finding it is, rather than the two
# spellings passing as two people who happen to be missing from each other's directory.
$fold = { param($name) ([string]$name).Normalize([System.Text.NormalizationForm]::FormC).ToLowerInvariant() }
$rightByName = @{}
foreach ($identity in $rightIdentities) {
if ($matchedRight.Contains($identity)) { continue }
if (-not $identity.DisplayName) { continue }
$name = & $fold $identity.DisplayName
if (-not $rightByName.ContainsKey($name)) { $rightByName[$name] = $identity }
}
$onlyLeft = New-Object System.Collections.Generic.List[string]
foreach ($identity in $unmatchedLeft) {
$name = if ($identity.DisplayName) { & $fold $identity.DisplayName } else { '' }
if ($name -and $rightByName.ContainsKey($name) -and -not $matchedRight.Contains($rightByName[$name])) {
$other = $rightByName[$name]
$pairs.Add([PSCustomObject]@{ Left = $identity; Right = $other; By = 'Name' })
$null = $matchedRight.Add($other)
}
else { $onlyLeft.Add((& $describe $identity)) }
}
$onlyRight = @($rightIdentities | Where-Object { -not $matchedRight.Contains($_) } | ForEach-Object { & $describe $_ })

$nameMismatch = New-Object System.Collections.Generic.List[string]
$stateDifference = New-Object System.Collections.Generic.List[string]
$namesCompared = 0
foreach ($pair in $pairs) {
# Only the same kind of name against the same kind: display names where both sides keep
# one, otherwise the given name and surname where both keep those. A stored display name
# is never compared against one composed from parts, which would call every
# family-name-first person a mismatch.
$leftName = $null
$rightName = $null
if ($pair.Left.DisplayName -and $pair.Right.DisplayName) {
$leftName = [string]$pair.Left.DisplayName
$rightName = [string]$pair.Right.DisplayName
}
elseif (($pair.Left.GivenName -or $pair.Left.Surname) -and ($pair.Right.GivenName -or $pair.Right.Surname)) {
$leftName = ('{0} {1}' -f $pair.Left.GivenName, $pair.Left.Surname).Trim()
$rightName = ('{0} {1}' -f $pair.Right.GivenName, $pair.Right.Surname).Trim()
}
if ($null -ne $leftName) {
$namesCompared++
if (-not [string]::Equals($leftName, $rightName, [StringComparison]::Ordinal)) {
$nameMismatch.Add(("{0}: {1} has '{2}', {3} has '{4}'" -f $pair.Left.Key, $Left.Provider, $leftName, $Right.Provider, $rightName))
}
}
if ($null -ne $pair.Left.Enabled -and $null -ne $pair.Right.Enabled -and ([bool]$pair.Left.Enabled) -ne ([bool]$pair.Right.Enabled)) {
$state = { param($enabled) if ($enabled) { 'enabled' } else { 'disabled' } }
$stateDifference.Add(("{0}: {1} in {2}, {3} in {4}" -f $pair.Left.Key, (& $state $pair.Left.Enabled), $Left.Provider, (& $state $pair.Right.Enabled), $Right.Provider))
}
}

$sort = { param($list) $array = [string[]]@($list); [Array]::Sort($array, [System.StringComparer]::Ordinal); $array }

return [PSCustomObject]@{
PSTypeName = 'TestEnvironmentComparison'
Left = [PSCustomObject]@{ Provider = $Left.Provider; Target = $Left.Target; Count = $leftIdentities.Count }
Right = [PSCustomObject]@{ Provider = $Right.Provider; Target = $Right.Target; Count = $rightIdentities.Count }
ComparedOn = Get-Date
Matched = $pairs.Count
MatchedByKey = @($pairs | Where-Object { $_.By -eq 'Key' }).Count
MatchedByName = @($pairs | Where-Object { $_.By -eq 'Name' }).Count
NamesCompared = $namesCompared
# Typed, so a list of one comes back as a list of one rather than as a string.
OnlyLeft = [string[]]@(& $sort $onlyLeft)
OnlyRight = [string[]]@(& $sort $onlyRight)
NameMismatch = [string[]]@(& $sort $nameMismatch)
StateDifference = [string[]]@(& $sort $stateDifference)
Passed = ($nameMismatch.Count -eq 0)
}
}
82 changes: 82 additions & 0 deletions Core/New-TestIdentity.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
function New-TestIdentity {
<#
.SYNOPSIS
Shapes one seeded person the way Compare-TestEnvironment reads them, whichever provider held them
.DESCRIPTION
Every Get-<Provider>IdentitySnapshot returns a list of these, so the comparison never has to
know where a person came from. The Key is the login with whatever the provider added
stripped off, lower-cased: the seed prefix from a PingOne username, the UPN suffix from an
Entra one, the email domain from an Okta login. DisplayName is $null where the provider
keeps no such field, which is how the comparison knows to fall back to the given name and
surname rather than compose a name the provider never stored.
.PARAMETER Provider
The provider the person was read from
.PARAMETER Login
The login as the provider holds it
.PARAMETER Key
The login with the provider's additions stripped; defaults to the lower-cased login
.PARAMETER DisplayName
The display name as stored, or nothing when the provider has no such field
.PARAMETER GivenName
The given name as stored, where the provider has one
.PARAMETER Surname
The surname as stored, where the provider has one
.PARAMETER Enabled
Whether the account can sign in
.OUTPUTS
PSCustomObject typed TestIdentity
.EXAMPLE
PS> New-TestIdentity -Provider Entra -Login 'ZZ-TEST-jnino@lab.example.com' -Key 'jnino' -DisplayName $user.displayName -Enabled $user.accountEnabled
#>
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
Justification = 'Builds an object in memory and changes nothing outside it.')]
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]$Provider,

[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Login,

[Parameter()]
[AllowEmptyString()]
[string]$Key,

[Parameter()]
[AllowNull()]
[AllowEmptyString()]
[string]$DisplayName,

[Parameter()]
[AllowNull()]
[AllowEmptyString()]
[string]$GivenName,

[Parameter()]
[AllowNull()]
[AllowEmptyString()]
[string]$Surname,

[Parameter()]
[AllowNull()]
[object]$Enabled
)

if (-not $Key) { $Key = $Login }
$enabledValue = $null
if ($null -ne $Enabled) { $enabledValue = [bool]$Enabled }

return [PSCustomObject]@{
PSTypeName = 'TestIdentity'
Provider = $Provider
Key = $Key.ToLowerInvariant()
Login = $Login
DisplayName = $(if ($DisplayName) { $DisplayName } else { $null })
GivenName = $(if ($GivenName) { $GivenName } else { $null })
Surname = $(if ($Surname) { $Surname } else { $null })
Enabled = $enabledValue
}
}
34 changes: 34 additions & 0 deletions Providers/AD/Private/Get-ADIdentitySnapshot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
function Get-ADIdentitySnapshot {
<#
.SYNOPSIS
Reads the seeded users of the domain as identities Compare-TestEnvironment can match
.DESCRIPTION
The users under the seed OU that carry the tag, the way teardown finds them. The key is
the SAM account name, which in this data is first name and initial rather than the shared
login the other providers use, so a comparison against another provider matches most of
these people by display name; the display name is the one the seed wrote, from the same
shared file as everywhere else.
.OUTPUTS
PSCustomObject with Provider, Target and Identities
.EXAMPLE
PS> (Get-ADIdentitySnapshot).Identities.Count
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param()

$domain = Get-ADTestDomain
$seedRoot = "OU=$($script:ADTestRootName),$($domain.DomainDN)"
$identities = @()
# A search base that does not exist is an error to the AD cmdlets rather than an empty result.
$rootExists = @(Get-ADOrganizationalUnit -Filter "Name -eq '$($script:ADTestRootName)'" -SearchBase $domain.DomainDN `
-SearchScope OneLevel -ErrorAction Stop).Count -gt 0
if ($rootExists) {
$identities = foreach ($user in @(Get-ADUser -Filter '*' -SearchBase "OU=Users,$seedRoot" -Properties adminDescription, DisplayName, GivenName, Surname -ErrorAction Stop |
Select-ADTestOwnedObject -Kind user)) {
New-TestIdentity -Provider 'AD' -Login ([string]$user.SamAccountName) -DisplayName ([string]$user.DisplayName) `
-GivenName ([string]$user.GivenName) -Surname ([string]$user.Surname) -Enabled $user.Enabled
}
}
[PSCustomObject]@{ Provider = 'AD'; Target = $domain.DNSName; Identities = @($identities) }
}
24 changes: 24 additions & 0 deletions Providers/Authentik/Private/Get-AuthentikIdentitySnapshot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
function Get-AuthentikIdentitySnapshot {
<#
.SYNOPSIS
Reads the seeded users of the instance as identities Compare-TestEnvironment can match
.DESCRIPTION
The users teardown would find, the module's own service account left out. The key is the
username as stored, which the seed never prefixes, so it is the shared login. Authentik
keeps one name field, so the display name is compared and there is no given name or
surname to fall back to.
.OUTPUTS
PSCustomObject with Provider, Target and Identities
.EXAMPLE
PS> (Get-AuthentikIdentitySnapshot).Identities.Count
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param()

$connection = Get-AuthentikConnection
$identities = foreach ($user in @(Get-AuthentikSeededObject -Type Users -Connection $connection)) {
New-TestIdentity -Provider 'Authentik' -Login ([string]$user.username) -DisplayName ([string]$user.name) -Enabled $user.is_active
}
[PSCustomObject]@{ Provider = 'Authentik'; Target = $connection.BaseUrl; Identities = @($identities) }
}
30 changes: 30 additions & 0 deletions Providers/Entra/Private/Get-EntraIdentitySnapshot.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
function Get-EntraIdentitySnapshot {
<#
.SYNOPSIS
Reads the seeded members of the tenant as identities Compare-TestEnvironment can match
.DESCRIPTION
The users teardown would find, guests left out because an invited guest's login is
minted by Entra and matches nothing anywhere else. The key is the UPN's local part with
the seed prefix stripped, so ZZ-TEST-jnino@lab.example.com is jnino, which is what every
other provider that keeps the shared logins calls the same person.
.OUTPUTS
PSCustomObject with Provider, Target and Identities
.EXAMPLE
PS> (Get-EntraIdentitySnapshot).Identities.Count
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param()

$connection = Get-EntraConnection
$marker = Get-EntraSeedMarker -Connection $connection
$identities = foreach ($user in @(Get-EntraSeededObject -Type Users -Connection $connection)) {
if ($user.userType -eq 'Guest') { continue }
$login = [string]$user.userPrincipalName
$local = ($login -split '@', 2)[0]
if ($local.StartsWith($marker.Prefix, [StringComparison]::OrdinalIgnoreCase)) { $local = $local.Substring($marker.Prefix.Length) }
New-TestIdentity -Provider 'Entra' -Login $login -Key $local -DisplayName ([string]$user.displayName) `
-GivenName ([string]$user.givenName) -Surname ([string]$user.surname) -Enabled $user.accountEnabled
}
[PSCustomObject]@{ Provider = 'Entra'; Target = $connection.TenantId; Identities = @($identities) }
}
2 changes: 1 addition & 1 deletion Providers/Entra/Private/Get-EntraSeededObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
# for the types that cannot belong to one.
$shape = @{
Users = @{ UnitKey = 'Users'; Graph = 'user'; Path = '/users'
Select = 'id,displayName,userPrincipalName,accountEnabled,userType,externalUserState,mail,department,jobTitle,usageLocation,employeeId,employeeType,companyName,onPremisesExtensionAttributes,assignedLicenses,createdDateTime'
Select = 'id,displayName,givenName,surname,userPrincipalName,accountEnabled,userType,externalUserState,mail,department,jobTitle,usageLocation,employeeId,employeeType,companyName,onPremisesExtensionAttributes,assignedLicenses,createdDateTime'
}
Groups = @{ UnitKey = 'Groups'; Graph = 'group'; Path = '/groups'
Select = 'id,displayName,description,mailNickname,groupTypes,securityEnabled,mailEnabled,membershipRule,isAssignableToRole,assignedLicenses,createdDateTime'
Expand Down
Loading
Loading