Designing these providers I tried to reduce as much friction as possible to connecting and getting the data in. A tricky problem with these cloud based IDP’s.

The Entra provider gives you nothing you can paste. There is no long-lived personal API key in Entra ID, by design. What replaces it is an application registration with a credential of its own, and you need permissions to create one of those in the first place. The seeding module has to solve its own access problem before it can create a single user.

The Entra provider starts there, and that problem is the most interesting part of it. This is part three of the series. Part one covered what makes seed data useful and part two covered the Active Directory provider. This one is Entra: roughly 1,180 objects into a tenant, held in administrative units, with two states that are deliberately not parameters.

A bootstrap that proves its own handover

1
2
3
4
5
6
7
# Once, as a Global Administrator
Connect-TestEnvironment -Provider Entra -TenantId <tenant> -Interactive
New-TestServiceApp

# Every run afterwards, no human involved
Connect-TestEnvironment -Provider Entra -TenantId <tenant> `
    -ClientId <app-guid> -CertificateThumbprint <thumbprint>

The bootstrap credential is the person at the keyboard, signed in for exactly as long as it takes to create the application that takes over from them.

Sign-in is device code flow. It needs no listener, no reply URL and no application of its own, so it works over SSH, inside a container, and on a box with no browser. The client it signs in with is Microsoft Graph PowerShell’s first-party application, pre-consented in every tenant, so there is no circularity of registering an application in order to register an application.

What happens after the code is entered:

An RSA key pair is generated locally and only the public half is ever sent. The private key never leaves the machine, and a unit test decodes the uploaded blob and asserts HasPrivateKey is false on it. The application is created with that public key as a credential, and its service principal is created next.

Then the permissions get granted, and this is the step that has a person in it.

Who has to sign in, and what they are agreeing to

Consenting to an application permission is a privileged directory write. It is an appRoleAssignment on the Microsoft Graph service principal, granting the new app a role that Graph owns, and no application can hand that to itself. In practice that means the human at the device code prompt has to be a Global Administrator. Everything else in the bootstrap works for an ordinary account that is allowed to register applications; the consent does not.

The module does the granting for you. One POST per permission, as you, using the delegated token from the sign-in:

1
2
3
4
5
6
7
Invoke-EntraRequest -Method POST -Connection $connection -RetryOnNotFound `
    -RetryOnErrorMatch 'does not reference a valid' `
    -Path "/servicePrincipals/$($principal.id)/appRoleAssignments" -Body @{
    principalId = $principal.id
    resourceId  = $graphPrincipal.id
    appRoleId   = $permission.AppRoleId
}

So when the sign-in is a Global Administrator, there is nothing to click in the portal at all. You enter a code, you sign in, and the command finishes. What you are agreeing to on the app’s behalf is nine application permissions: User.ReadWrite.All, Group.ReadWrite.All, Device.ReadWrite.All, Application.ReadWrite.OwnedBy, AdministrativeUnit.ReadWrite.All, Policy.ReadWrite.ConditionalAccess, Policy.Read.All, Directory.Read.All and Organization.Read.All, plus User.Invite.All as an optional one for the guest step. EntraServiceAppPermissions.csv is the authority on that list, with a Purpose column giving a sentence per permission on why it is there and, for the optional one, what happens without it.

RoleManagement.ReadWrite.Directory is granted by default as well and deserves its own thought, because it permits assigning directory roles and not only creating them, which is an escalation path. Pass -Scope with the other permissions and leave it out, then run with -Skip DirectoryRoles, if that is not a trade you want to make in the tenant you are pointed at.

The command does not stop. The application and its certificate are created, each permission that could not be granted produces a warning naming it, and a summary line says how many were refused and that the seeding steps needing them will fail. You get a registration that authenticates and cannot do anything useful, which is deliberate: an application half-authorised and reported as such is easier to finish than one that was rolled back.

Finishing it takes one of two routes.

The straightforward one is to have a Global Administrator run the same two commands with -Force, which deletes the half-authorised registration and its service principal, mints a fresh certificate, and grants the permissions properly. The certificate lands on whichever machine ran the command, so run it where the seeding is going to happen, or use -UseSecretStore and share the vault.

The manual route is the portal, and there is a wrinkle in it. The module grants permissions as direct role assignments and never writes requiredResourceAccess on the application, so the API permissions blade of that registration is empty and the Grant admin consent button has nothing to consent to. Adding them by hand is the equivalent: Entra admin center, App registrations, the ENTRALAB- application, API permissions, Add a permission, Microsoft Graph, Application permissions, tick the list above, then Grant admin consent for the tenant. Afterwards, Get-TestServiceApp -TestCredential confirms that the record, the application and the key all agree, before a seeding run finds out for you.

One failure happens earlier than any of this. If the tenant restricts who may register applications, the very first call is refused and nothing is created at all, so there is no half-built registration to clean up. The command says it could not create the service app and names what Graph told it.

Proving the handover

The handover gets proved before success is reported. The new application acquires a token with its own certificate, and only then does the command say it worked. An application that exists, is consented, and cannot authenticate is the worst of the three outcomes, because it fails later and somewhere else.

The certificate goes into Cert:\CurrentUser\My and a record of what to connect with goes to ~/.testenvironment/<tenant>.serviceapp.json, outside the repository. A path inside the module folder would sit in a working tree, one .gitignore mistake away from being pushed. The private key is not in that file. It is in the certificate store.

1
Get-TestServiceApp -TestCredential

That checks three things independently, because they fail apart: the record on disk, the application in the tenant, and the private key in the store. Any one can be missing while the other two look fine.

Off Windows, X509Store is a file-backed shim whose behaviour varies by distribution, so -UseSecretStore is the portable path and puts the key in a password-protected vault. Connecting answers to -UseStoredCredential as well, the one name every provider that stores a credential accepts, so a script that reconnects to whichever provider it is handed does not need a switch per directory. The record on disk says which of the two is in use, so connecting looks in the right place and never guesses. The vault gets checked before anything is created, so a store that cannot be opened never leaves an orphaned application registration behind.

There is a path with no application at all. Connect-TestEnvironment -Provider Entra -Interactive -FullAccess asks for the delegated form of every permission in the permissions CSV, so a Global Administrator who would rather not leave a registration in the tenant can seed and tear down as themselves. What you trade: the token is yours, so it expires with your session, it is subject to your own Conditional Access, and every deletion is audited as you.

A JWT you sign yourself

RequiredModules is empty. No Graph SDK, no MSAL, nothing. Every call goes through Invoke-WebRequest and the client assertion is signed with in-box .NET types:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)

# GetCertHash() returns the SHA-1 hash as bytes. That is the same value the store renders
# as the hex thumbprint, and it is the bytes Entra wants base64url-encoded here.
$x5t = ConvertTo-TestBase64Url -Bytes $Certificate.GetCertHash()

$header = [ordered]@{ alg = 'RS256'; typ = 'JWT'; x5t = $x5t }

$now = [DateTimeOffset]::UtcNow
$claims = [ordered]@{
    aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    iss = $ClientId
    sub = $ClientId
    jti = [guid]::NewGuid().ToString()
    nbf = $now.AddSeconds(-30).ToUnixTimeSeconds()
    exp = $now.AddMinutes($LifetimeMinutes).ToUnixTimeSeconds()
    iat = $now.ToUnixTimeSeconds()
}

The x5t header is the certificate’s SHA-1 hash, base64url encoded, and it is how Entra knows which of an application’s credentials signed the thing. Same bytes the store shows you as the hex thumbprint, different encoding.

One line in that function does more than it looks like:

1
2
3
# -Compress matters beyond neatness: the signature covers these exact bytes, so any
# whitespace ConvertTo-Json would otherwise insert becomes part of what was signed.
$headerSegment = ConvertTo-TestBase64Url -Bytes ([System.Text.Encoding]::UTF8.GetBytes(($header | ConvertTo-Json -Compress)))

The constraint is deliberate. A lab module that first asks you to install several dozen SDK sub-modules is one more thing to get working before you start, and the SDK’s auth stack is the piece most likely to disagree with whatever else is on the box.

Administrative units are not OUs

Unlike AD, Entra has no organisational units (OU). It has administrative units (AU), and they are close enough to be the primary containment mechanism here. Four get created, one per object class:

1
2
3
4
ENTRALAB-Users          322 members
ENTRALAB-Groups         104 members
ENTRALAB-Devices        694 members
ENTRALAB-Applications     8 members

Teardown asks each container what it holds, and that is authoritative in a way a name match never is. An object is in the unit because this module put it there.

Three properties of an AU differ from an OU and all three shape the design. They do not nest: adding one AU to another is refused, verified live, with a message about the reference target being invalid for the members reference, so the containers are four siblings and not a tree. They are containers and not parents: deleting a unit does not delete its members, so the units get removed last, after their contents, and they exist to identify what to delete and not to do the deleting. And membership is not exclusive, since an object can sit in several units or none and lives in the tenant root regardless. Membership proves this module created something. It is not a location.

Names still get checked as a fallback, for two reasons. Some objects cannot belong to an administrative unit at all, and deleting the container would otherwise strand everything it held.

ObjectIn a unitFallback markerProof required
UsersyesPrefixed UPN on the seed domain, extensionAttribute15either
GroupsyesdisplayName prefix plus the seed tag in descriptionboth
ApplicationsyesdisplayName prefix plus the seed tag in tagsboth
DevicesyesdisplayName prefixname alone
Service principalsnodisplayName prefix plus the seed tag in tagsboth
Named locationsnodisplayName prefixname alone
CA policiesnodisplayName prefixname alone

Every claimed object carries a SeedProof property recording which routes found it, so a report can show whether the container or the name did the work.

The marker cannot simply be queried, and that surprised me. Verified live: employeeType, companyName and every extensionAttribute are writable and not filterable. Graph rejects all of them in $filter with Request_UnsupportedQuery, even with ConsistencyLevel: eventual. Server-side, startswith(userPrincipalName, ...) works for users and startswith(displayName, ...) for everything else, and that is the lot.

The prefix is validated to end in a separator, - or _, which stops ENTRALAB- from matching a real object called ENTRALABORATORY.

Placement is the one part of seeding that fails without anything else being wrong. The $ref POSTs go in seconds after the objects are created, so they lose races with replication, and a batch that exhausts its retries leaves objects that exist, work, and are simply not in their container. Update-TestContainment reconciles the difference, runs as the last step of a seed, and is idempotent:

1
2
3
4
5
6
7
8
Update-TestContainment -PassThru

ObjectType   AdministrativeUnit    Seeded AlreadyContained Missing Placed
----------   ------------------    ------ ---------------- ------- ------
Users        ENTRALAB-Users           322              322       0      0
Groups       ENTRALAB-Groups          104              104       0      0
Devices      ENTRALAB-Devices         694              694       0      0
Applications ENTRALAB-Applications      8                8       0      0

It reconciles in one direction only. An object sitting in a unit that the module cannot otherwise account for is reported and left alone, never removed.

Twenty at a time

Everything that can go through Graph’s $batch endpoint does, in chunks of twenty, since a twenty-first is refused with a message about exceeding the limit. At this volume that is a five-minute run against an hour-long one.

None of the batch semantics behaves like a normal call, and each one bites differently:

  • The outer call returns 200 even when every request inside it failed. The real status is per response, so a caller checking only the outer result sees success while nothing was created.
  • Responses come back in arbitrary order. They are correlated by the id sent with each request, never by position, and the helper returns them keyed by the caller’s own reference.
  • Throttling appears per response as a 429, so those individual requests get retried in a fresh batch and the nineteen that succeeded alongside are not sent twice.

Failures are reported and not thrown. At this volume an individual failure is expected, a name collision or a licence refused for a user with no usage location, and abandoning the run over one of them leaves a half-seeded tenant that is harder to clean up than a complete one.

Retries carry one extra rule, because replication failures do not all arrive as 429:

1
2
3
4
5
# Throttled or transiently failed: retried individually in a later batch, so
# the requests that succeeded alongside it are not sent twice. A 404 counts
# only where the caller says so - for a reference to a just-created object it
# means the replica has not caught up, and without the retry a first pass
# places roughly a quarter of them and reports the rest as failures.

A policy that cannot enforce

Eleven Conditional Access policies get seeded. Ten are report-only and one is deliberately disabled, because real tenants keep retired policies and an inventory script must not count one as active.

What can never appear is enabled:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$requestedState = if ($definition.PSObject.Properties.Name -contains 'State' -and $definition.State) {
    $definition.State.Trim()
}
else { 'enabledForReportingButNotEnforced' }

if ($requestedState -notin @('enabledForReportingButNotEnforced', 'disabled')) {
    Write-Error ("Policy '$($definition.Key)' asks for state '$requestedState'. Only " +
        "'enabledForReportingButNotEnforced' and 'disabled' are permitted, because this module " +
        'must never create an enforcing Conditional Access policy. Refusing to create it.') -ErrorAction Continue
    continue
}

The state is not a parameter. There is no -State, no -Enabled, no -Enforce, and a contract test asserts the absence of all three. A row asking for anything but those two values is refused, so a typo in a CSV cannot produce an enabled policy.

This is the single most consequential decision in the module and it is deliberately not configurable. A report-only policy is fully evaluated and fully logged. It shows up in sign-in logs, What If returns it, and a tool like CaOutcome can fold it into an evaluation. It never denies anything.

Policies are scoped to seeded groups only, never to all users, and a policy that resolves no groups is refused outright, since an unscoped Conditional Access policy applies tenant-wide. The IP ranges in the named locations are IANA documentation blocks reserved by RFC 5737, enforced by a contract test, because a lab location containing a real routable range is a policy that could lock somebody out for real.

The eleven cover the shapes an evaluation tool has to handle: an unsatisfiable compliant-device requirement, an inverted country condition, session controls with no grant control, an authentication strength in place of a plain MFA grant, an AND where satisfying one control is not enough, the only policy that includes locations instead of excluding them, and user risk as distinct from sign-in risk.

Two Graph rules are recorded in the seed data because each cost a live 400 to discover. Error 1066: passwordChange is refused unless combined with a strong-auth control using the AND operator. Error 1092: a policy carrying passwordChange must apply to all client app types and not a named subset.

Eligible, and never active

The counterpart rule, striking the same bargain. Three PIM schedules over the three custom directory roles, all eligible, none active. An eligible schedule confers nothing until a human signs in and activates it, so like a report-only policy it is completely visible to a privilege report and grants nothing at all.

The state is not a parameter here either. No -Active, no -AssignmentType, no -Permanent, and a second test asserts that nothing is ever posted to roleAssignmentScheduleRequests, the endpoint that would make an assignment standing.

The plain case is the whole reason the layer exists. GET /roleManagement/directory/roleAssignments returns nothing for any of the three roles. A standing-privilege report that reads that endpoint and stops, which is most of them, concludes the custom roles are held by nobody, while three principals are one activation away from holding them. The report prints RoleEligibilities and RoleAssignments side by side and the second number is measured. If it is ever non-zero, a human made a standing assignment by hand.

Three properties keep it inert, none configurable. Only seeded custom roles resolve, so there is no path from a CSV row to eligibility for Global Administrator. An AU-scoped row whose unit is missing is skipped and never widened, since falling back to / would silently turn a deliberately narrow grant into a directory-wide one. And each schedule expires on its own after thirty days, so a lab nobody ever tore down stops offering the activation.

Teardown withdraws rather than deletes, because a schedule has no DELETE; the removal is a second request posted with action: adminRemove. It runs before the role definitions, since a definition with a live eligibility pointing at it cannot be deleted, and before the users and groups, since deleting a principal strands its eligibility.

Verified against a live tenant that held five real eligibilities of its own, one of them Global Administrator: creating the three seeded schedules left roleAssignments returning zero for all three custom roles, discovery claimed exactly the three and none of the five, and teardown left the tenant back at five.

Four outsiders, and no property that separates them

The external identities are four rows arranged so that no single property separates the insiders from the outsiders. Most access-review scripts assume there is one.

KeyHow it is madeuserType#EXT# in UPNexternalUserState
gpendingInvitationGuestyesPendingAcceptance
gmemberInvitationGuestyesPendingAcceptance
gconvertedInvitationMemberyesPendingAcceptance
glocalDirect POST /usersGuestnonone

Filter on userType eq 'Guest' and you miss gconverted, a real external identity converted to a member, as happens to every long-running contractor. Filter on #EXT# in the UPN and you miss glocal, where userType is Guest on an otherwise ordinary cloud account. Filter on externalUserState and you see only the invitations nobody redeemed.

gpending carries the case that catches the most reports: enabled, and unable to sign in. The invitation was never redeemed, so anything counting active accounts by accountEnabled counts it, and anything hunting disabled accounts to clean up never finds it.

gmember sits one level down the nesting chain, so all-staff reaches an external identity transitively without holding one directly, and its department satisfies a dynamic group rule whose author never considered guests.

An invitation names a real mailbox and Entra will mail it, so this is the one object in the module that could reach a person who never agreed to be in a lab:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$invitation = Invoke-EntraRequest -Method POST -Path '/invitations' -Connection $connection -Body @{
    invitedUserEmailAddress = $email
    invitedUserDisplayName  = $definition.DisplayName
    invitedUserType         = $definition.UserType
    inviteRedirectUrl       = 'https://example.com/entralab/redeem'

    # Not a parameter, deliberately. See the description: there is no way to
    # make this module send mail.
    sendInvitationMessage   = $false
}

Every address is on example.com, which RFC 2606 reserves and nobody can register, and the redemption redirect points at the same reserved domain. Contract tests pin each of the three.

The guest step also needs the tenant’s permission as well as Graph’s. That is the one prerequisite here that is not a Graph permission at all. POST /invitations is refused with a 403 and “Guest invitations not allowed for your company” whenever allowInvitesFrom restricts who may invite, and adminsAndGuestInviters is enough to block it. Two readings of that message are both wrong, and both were tested live: it is not User.ReadWrite.All missing, because that was granted, consented and present in the token’s roles claim; and it is not the Guest Inviter directory role, because assigning it and re-acquiring the token produced the same 403. What works is User.Invite.All, the permission built for this, requested separately as an optional permission. Without it the step warns, names the setting, and carries on.

One detail about where the prefix goes. Entra derives a B2B UPN by replacing the @ in the invited address:

1
ENTRALAB-gmember@example.com  ->  ENTRALAB-gmember_example.com#EXT#@contoso.onmicrosoft.com

So the prefix survives into the UPN only from the local part. Put it in the domain instead and every guest stops matching the startswith(userPrincipalName, 'ENTRALAB-') query teardown finds users by. A contract test anchors the prefix token at the start.

Ten attributes chosen for type coverage

Directory extensions live on a dedicated schema application, and they were picked for type coverage over realism, because a schema of nothing but strings will not tell you that your export flattened a binary value or that a 64-bit integer lost precision through a double.

labRiskScore is an integer, so a row whose value is zero gets dropped by any check written as if ($value). labHeadcount is a LargeInteger. labBadgePhoto is binary. labContractEndDate is a real date type. Two of the ten sit on groups and devices, not users, and they are namespaced to the owning application as extension_<appId>_<name>, deleting that application takes the attributes and every value in them, so teardown of the schema is a single operation.

They are also the only writable, queryable marker on a user. Verified live: filtering on extension_..._labSeedTag returned all 305 users the seed held at the time, where extensionAttribute15, employeeType and companyName are all rejected.

Three constraints, none documented on the request and all found by live runs. An extension is unusable until the owning application has a service principal: without one the definitions are created, appear on the application, and every PATCH naming them is refused indefinitely. Existing and being writable are different states, minutes apart, so getAvailableExtensionProperties is the authoritative signal and gets polled, and on a brand-new schema application the first run typically populates only some, and the command says so out loud. And directory extensions can only be written to Windows devices, so non-Windows device objects are filtered out before the attempt.

What the extensions cannot give you is an enum type or a multi-valued type. Custom security attributes would, but they need the Attribute Definition Administrator role, which a Global Administrator does not hold by default.

The licence somebody holds twice

One seeded user holds the same SKU directly and inherited from a group. Graph reports both identically in assignedLicenses: the same skuId, listed once. Only licenseAssignmentStates distinguishes them, where the inherited entry carries the group’s object id in assignedByGroup.

1
2
3
4
5
ENTRALAB-Priya Raghunathan
    FLOW_FREE (Direct)
    FLOW_FREE (Inherited from ENTRALAB-Licence Power BI)
ENTRALAB-Marcus Bell
    FLOW_FREE (Inherited from ENTRALAB-Licence Power BI)

A script that reads assignedLicenses and stops there cannot tell “remove this user from the group” from “remove the licence from this user”. There is no way to discover that without an object where both are true at once, and that is the entire argument for designed rows.

Marcus is the other half of the same idea. He is disabled and still licensed and still in his groups, because disabling an account does not release its licence, and he is assigned to the Payroll Console application directly while belonging to no group that has it. A report that expands group assignments and stops there misses him.

The SKU gets chosen at run time from whatever the tenant has spare, preferring the no-cost ones, so this works in a trial tenant with nothing bought.

Replication lag reports itself three ways

This is the Entra behaviour that took me longest to stop misreading, because the same underlying delay arrives wearing three different costumes.

A DELETE, PATCH or $ref POST issued seconds after a create returns 404. Creating a service principal for a just-created application returns 400, with a message about the appId not referencing a valid application object, which by status code alone is indistinguishable from a malformed request. And a read lags too: immediately after placing 305 users into an administrative unit, the unit reported 72 members, then 684, then all of them.

The read case wastes the most time, because a report run too early looks like a failure that never happened.

The same lag runs the other way after a teardown, and that direction is the one most easily mistaken for a defect. A user listing taken immediately after a successful teardown returned 45 seeded users that had already been deleted. The identical query minutes later returned none, with nothing else having run in between. Teardown’s own summary is the authority on what happened, since it reports the result of each DELETE, and a count taken straight afterwards is not evidence that anything was left behind.

Teardown order, and the recycle bin

The order is forced by Entra’s own dependencies, and three steps are not inferable from the API surface.

Conditional Access policies go before named locations, and a trusted location is un-marked as trusted first, because Entra refuses to delete one while it is trusted. Licences come off the licensing group before the group is deleted, because Entra refuses to delete a group that still holds one. Administrative units go last of all, after their contents, because deleting a container first discards the authoritative record of what to delete.

Soft delete then behaves differently by object type. That surprised me the first time a re-seed collided. A deleted user has its UPN rewritten to <id-without-dashes><original-upn>, freeing the original immediately. A deleted group keeps its displayName and mailNickname reserved for the full thirty days. So tear down and immediately re-seed, and the groups collide while the users do not. -PurgeRecycleBin removes them permanently. That is irreversible, and it is why the purge is not the default. It only ever touches objects matching the prefix.

One object is excluded from teardown always: the bootstrapped service app. It carries the prefix and the seed tag like everything else, so without an explicit exclusion the teardown would delete the credential it is authenticating with, halfway through, stranding whatever had not been deleted yet. Removing it is opt-in through -RemoveServiceApp and nothing else.

What the token does not say

The service app holds Application.ReadWrite.OwnedBy and not the tenant-wide .All, pinned by a contract test, which shows up at teardown as a skipped application whenever a seeded one was created by some other identity. The larger point about permissions is one I would not have guessed from reading a token. A token’s roles claim is not a safety limit. A service principal also inherits whatever directory roles it has been assigned, and those do not appear in the token at all. The tenant this was built against holds an app whose Graph permissions are almost entirely *.Read.All and which creates and deletes users, groups, devices and applications perfectly happily, because the service principal is a Global Administrator.

Scopes say which APIs may be called. Directory roles say which objects may be touched. An app can be over-privileged through the second while looking read-only through the first.

The reverse also holds, and people get that one wrong in the other direction. A Global Administrator role is not a substitute for the Graph permission on endpoints that check scopes explicitly. Verified against an app-only token whose service principal was a Global Administrator but held neither permission: POST /invitations answered 401 and POST /roleEligibilityScheduleRequests answered 403 PermissionScopeNotGranted.

Checking the tenant against the data

A seed reports what it created. That is not the same as what the tenant holds, and the gap is where the interesting failures live: an object refused by a permission, a name stored with a replacement character, a membership that never landed.

1
Test-TestEnvironment

It compares the connected provider with the seed data and returns one object: every seeded user, group and named object present and found the way teardown finds them, nothing the module owns that the data does not describe, every name equal to the data by codepoint, and every membership the data lists in place. Names are compared ordinally, because -eq calls a decomposed and a precomposed name equal and the decomposed José exists precisely to catch a directory that mangles one. Memberships are judged on what is missing only, since dynamic groups and rules add members the data never lists. -SkipMembership drops the expensive reads.

What the lab tenant reported is the answer this command exists to give. Every user, group, application and membership present, every name matching, 694 devices the tenant’s permissions refused, and three of four guests uninvited. Nothing there is a surprise once you know the tenant, and none of it was visible from the seed’s own summary.

1
Repair-TestEnvironment -WhatIf

The repair runs the verifier, works out from the failed checks which seed steps own the missing objects, re-runs those steps alone, and verifies again. Every step is idempotent, so what exists is reused. Each provider declares which step owns which check, and on Entra the administrative units and the containment pass run alongside any repair, since placing an object is a separate call from creating it. Anything present that the data does not describe is reported and left alone; removing what the module owns stays teardown’s job.

The third of the trio needs two providers connected at once:

1
Compare-TestEnvironment

The seed puts the same people into every directory it knows, so this reads them from two connected providers and reports how they line up, the way a hybrid identity match would. People are matched by login key first, with each provider’s additions stripped, then by display name folded for case and Unicode normalisation. Names are then compared by codepoint, and a name that differs is the finding. Enabled state is reported without a verdict, because the seed hangs different states on the same person on purpose.

Run against the lab tenant and the PingOne sandbox from one session: 329 people matched by login key, none left over to match by name, 328 names compared as given name and surname with none differing, one account only in PingOne, and the same answer with the providers the other way round.

Running it

1
2
3
4
5
6
Connect-TestEnvironment -Provider Entra -TenantId <tenant> -ClientId <app> -CertificateThumbprint <thumb>

New-TestEnvironment -WhatIf
New-TestEnvironment -ShowProgress
Get-TestEnvironmentReport
Remove-TestEnvironment -WhatIf

A seed is about seven minutes, teardown about two, the report about thirty seconds. -Tier Core builds the eighteen designed users, fourteen groups and six devices in seconds, which is the loop to use while the behaviour is what you are testing and not the scale. -Skip and -Keep take the same names across all fourteen steps, so New-TestEnvironment -Skip NamedLocations, ConditionalAccessPolicies gives you the directory with no policy objects at all, and -Skip Users, Groups, Licenses rebuilds the access layer over a directory that already exists.

Two of those fourteen steps need a licence the tenant may not hold: Conditional Access needs Entra ID P1, and role eligibilities need P2 or Entra ID Governance. The connect reads the tenant’s subscribed SKUs once and records the answer, so a step the tenant cannot hold is skipped once, with one message naming the licence and the step, and its Reason travels in the result. The alternative was nine policy refusals and three eligibility warnings all saying the same thing in Graph’s words. -IncludeUnlicensed attempts them regardless, and a tenant that will not let the app read its SKUs gets every step attempted as before, so the probe can only ever remove noise and never a step that would have worked.

The report takes -OutputFormat Console, JSON, CSV or HTML, with -OutputPath required for the three file formats and -PassThru for the object. CSV is a folder with one file per section. An eligibility count from a tenant that refused the read comes back $null and not zero, so an absent number and a real zero stay distinguishable, which for this provider is the difference between “no standing assignments” and “nobody asked”.

The provider’s page, with the full object inventory and every gotcha in longer form, is at Providers/Entra/README.md.

Next in the series: Okta, where the design problem is the opposite of this one. There is an API token to paste, and a hard ceiling of eight users to design an entire directory around.