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. Entra ID has no long-lived personal API key, by design. In its place is an application registration with a credential of its own, and you need permissions to create one of those in the first place. My seeding module has to solve its own access problem before it can create a single user.

So 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 puts roughly 1,180 objects into a tenant, holds them in administrative units, and has two states I refused to make into 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 you, at the keyboard, for as long as it takes to create the application that replaces you.

Sign-in uses 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, which avoids the circularity of registering an application in order to register an application.

After the code is entered, an RSA key pair is generated locally and only the public half is ever sent. The private key stays on 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 follows.

Then the permissions get granted, and that step 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’s 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 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
}

When the sign-in is a Global Administrator, there’s nothing to click in the portal. You enter a code, you sign in, and the command finishes. On the app’s behalf you are agreeing to 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’s there and, for the optional one, what happens without it.

RoleManagement.ReadWrite.Directory is granted by default as well, and I would think about that one separately. It permits assigning directory roles as well as creating them, which is an escalation path. If that’s a trade you don’t want in the tenant you are pointed at, pass -Scope with the other permissions and leave it out, then run with -Skip DirectoryRoles.

The command keeps going. The application and its certificate are created, each permission that couldn’t 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 end up with a registration that authenticates and can’t do anything useful. I prefer that to a rollback: an application half-authorized and reported as such is easier to finish than one that was undone.

Finishing it takes one of two routes.

The straightforward one is to have a Global Administrator run the same two commands with -Force. That deletes the half-authorized 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 it has a wrinkle. The module grants permissions as direct role assignments and doesn’t write 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’s no half-built registration to clean up. The command says it couldn’t create the service app and names what Graph told it.

Proving the handover

The handover gets tested before success is reported. The new application acquires a token with its own certificate, and only then does the command say it worked. Of the three outcomes, an application that exists, is consented, and can’t authenticate is the worst, 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 file holds no private key. That lives in the certificate store.

1
Get-TestServiceApp -TestCredential

That checks three things independently, because they fail independently: 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 behavior varies by distribution, so -UseSecretStore is the portable path and puts the key in a password-protected vault. Connecting also answers to -UseStoredCredential, the one name every provider that stores a credential accepts, so a script that reconnects to whichever provider it is handed needs a single switch. The record on disk says which of the two stores is in use, so connecting looks in the right place without guessing. The vault gets checked before anything is created, so a store that can’t be opened leaves no orphaned application registration behind.

There’s also 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. The trade: the token is yours, so it expires with your session, it’s 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 tells Entra which of an application’s credentials signed the assertion. The store shows you the same bytes as the hex thumbprint, in a different encoding.

One line in that function carries more than its length suggests:

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)))

I kept the dependency list empty for a reason. 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 in place of OUs

Unlike AD, Entra has no organizational units (OU). It has administrative units (AU), and they come close enough to serve as the primary containment mechanism here. I create four, 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 answer is authoritative in a way a name match can’t be. An object is in the unit because this module put it there.

AUs differ from OUs in three ways, and each one shaped the design. They don’t nest: adding one AU to another is refused, which I verified live, with a message about the reference target being invalid for the members reference, so the four containers are siblings. Deleting a unit leaves its members in place, so the units get removed last, after their contents, and their job is to identify what to delete. Membership is also non-exclusive, since an object can sit in several units or none and lives in the tenant root regardless. Membership shows this module created something. It says nothing about location.

Names still get checked as a fallback, because some objects can’t belong to an administrative unit at all, and deleting a 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 can’t simply be queried, and that surprised me. I verified it live: employeeType, companyName and every extensionAttribute are writable and unfilterable. 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’s the lot.

The prefix has 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 with nothing else 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 and work but sit outside their container. Update-TestContainment reconciles the difference. It 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 can’t otherwise account for is reported and left alone.

Batching twenty requests

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 batching turns an hour-long run into a five-minute one.

The batch semantics differ from a normal call in ways that each bite 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. I correlate them by the id sent with each request, 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 aren’t sent twice.

Failures are reported and the run carries on. At this volume an individual failure is expected, a name collision or a license 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 don’t 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.

Report-only Conditional Access

I seed eleven Conditional Access policies. Ten are report-only and one is disabled, because real tenants keep retired policies and an inventory script has to avoid counting one as active.

The one state it refuses to create 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 command has no -State, -Enabled or -Enforce, and a contract test asserts all three are absent. A row asking for anything but those two values is refused, so a typo in a CSV can’t produce an enabled policy.

Of every decision in the module, this one has the largest consequences, and it has no setting. 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 anyone.

Policies are scoped to seeded groups, 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 one satisfied control falls short, 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 me 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, with no named subset.

PIM eligibility with no activation

PIM gets the same bargain. I seed three schedules over the three custom directory roles, all eligible and none active. An eligible schedule confers nothing until a human signs in and activates it, so like a report-only policy it is fully visible to a privilege report and grants nothing.

This state has no parameter either. There’s no -Active, -AssignmentType or -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 why the layer exists. GET /roleManagement/directory/roleAssignments returns nothing for any of the three roles. Most standing-privilege reports read that endpoint and stop, and they conclude 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’s ever non-zero, a human made a standing assignment by hand.

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

Teardown withdraws the schedules, 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 can’t be deleted, and before the users and groups, since deleting a principal strands its eligibility.

I verified this 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 the three and none of the five, and teardown left the tenant back at five.

Four external identities

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

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, which makes this 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 needs the tenant’s permission as well as Graph’s, and it’s the one prerequisite here that isn’t 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. I tested two readings of that message live, and both were wrong. User.ReadWrite.All was already granted, consented and present in the token’s roles claim. Assigning the Guest Inviter directory role 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.

Where the prefix goes decides whether teardown can find a guest. 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

The prefix only survives into the UPN from the local part. Put it in the domain 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 I picked them for type coverage over realism. A schema of nothing but strings won’t 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. All of them are namespaced to the owning application as extension_<appId>_<name>, and deleting that application takes the attributes and every value in them, so tearing down the schema is a single operation.

They’re also the only writable, queryable marker on a user. In a live test, filtering on extension_..._labSeedTag returned all 305 users the seed held at the time, while extensionAttribute15, employeeType and companyName were all rejected.

Live runs turned up three constraints the request documentation leaves out. An extension is unusable until the owning application has a service principal: without one the definitions are created and appear on the application, and every PATCH naming them is refused indefinitely. Existing and being writable are separate 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 of them and says so. Directory extensions can also only be written to Windows devices, so non-Windows device objects are filtered out before the attempt.

The extensions give you no enum type and no multi-valued type. Custom security attributes would, but they need the Attribute Definition Administrator role, which a Global Administrator doesn’t hold by default.

The license 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 tells them apart, 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 can’t tell “remove this user from the group” from “remove the license from this user”. You can only discover that with an object where both are true at once, and that’s my whole 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 keeps its license, 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 Entra behavior took me the 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 looks like a malformed request. Reads lag too: immediately after I placed 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 easiest to mistake 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 run in between. Teardown’s own summary is the authority, since it reports the result of each DELETE, and a count taken straight afterwards proves nothing about leftovers.

Teardown order and the recycle bin

Entra’s own dependencies force the order, and three of the steps can’t be inferred 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’s trusted. Licenses 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, after their contents, because deleting a container first discards the authoritative record of what to delete.

Soft delete then behaves differently by object type, which 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. Tear down and immediately re-seed, and the groups collide while the users don’t. -PurgeRecycleBin removes them permanently. The purge is irreversible, so it’s opt-in, and it only ever touches objects matching the prefix.

Teardown always excludes one object: the bootstrapped service app. It carries the prefix and the seed tag like everything else, so without an explicit exclusion teardown would delete the credential it is authenticating with, halfway through, stranding whatever hadn’t been deleted yet. The only way to remove it is -RemoveServiceApp.

Roles the token leaves out

The service app holds Application.ReadWrite.OwnedBy in place of the tenant-wide .All, pinned by a contract test, which shows up at teardown as a skipped application whenever another identity created a seeded one. The larger point about permissions is one I wouldn’t have guessed from reading a token. A token’s roles claim is no safety limit. A service principal also inherits whatever directory roles it has been assigned, and those don’t appear in the token. The tenant I built this against holds an app whose Graph permissions are almost entirely *.Read.All, and it creates and deletes users, groups, devices and applications perfectly happily, because its 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 holds too, and people get that one wrong in the other direction. On endpoints that check scopes explicitly, a Global Administrator role can’t stand in for the Graph permission. I tested 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, and what the tenant holds can differ. The interesting failures live in that gap: an object refused by a permission, a name stored with a replacement character, a membership that didn’t land.

1
Test-TestEnvironment

It compares the connected provider with the seed data and returns one object. It checks that every seeded user, group and named object is present and found through teardown’s own lookups, that the module owns nothing the data doesn’t describe, that every name matches the data by codepoint, and that every membership the data lists is in place. Names are compared ordinally, because -eq calls a decomposed and a precomposed name equal, and the decomposed José is there to catch a directory that mangles one. Dynamic groups and rules add members the data never lists, so a membership only fails when one the data names is missing. -SkipMembership drops the expensive reads.

What the lab tenant reported is the answer this command exists to give. Every user, group, application and membership was present and every name matched, 694 devices were refused by the tenant’s permissions, and three of four guests were uninvited. None of that surprised me once I knew 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 doesn’t 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 normalization. Names are then compared by codepoint, and a name that differs is the finding. Enabled state is reported without a verdict, because the seed gives the same person different states in different directories. The PingOne post has the result from running it across two providers.

Seeding and reporting

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 takes about seven minutes, teardown about two, and the report about thirty seconds. -Tier Core builds the eighteen designed users, fourteen groups and six devices in seconds, which is the loop I use while I’m testing behavior over 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 license 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 can’t hold is skipped once, with one message naming the license and the step, and its Reason travels in the result. Before this, the same run produced nine policy refusals and three eligibility warnings, all saying the same thing in Graph’s words. -IncludeUnlicensed attempts them regardless, and a tenant that won’t let the app read its SKUs gets every step attempted as before, so the probe can only remove noise and can’t drop 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 in place of zero, so an absent number stays distinguishable from a real zero. For this provider that separates “no standing assignments” from “nobody asked”.

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