I wrote Seeding an Okta Test Tenant When You Only Get Ten Users in August 2025. The constraint in the title hasn’t moved. Okta’s free plan still allows ten active users, your own admin account is still one of them, and the lab still seeds eight people and buys its complexity from the parts of Okta that carry no license cap.

I opened the provider expecting to write that post again with better numbers. The eight users are the same eight users. Two lines in the CSV changed, and one of those is a name.

The module around them is what changed, and the Okta provider gained the most from the merge while changing the least itself. This is part four of the series, after what makes seed data useful, Active Directory and Entra.

The ceiling has a new name

Okta renamed the thing. The old post says trial org; the plan is now the Integrator Free Plan, and the ten-user ceiling came with it. The arithmetic behind eight also changed shape. It used to be ten minus my own admin account, and it’s now ten minus two, leaving a slot for a second administrator so somebody else can be added to the org without a teardown.

The count still comes from GET /api/v1/users, which lists everything that isn’t DEPROVISIONED, the same population the license counts. A deactivated leftover from a previous run eats no headroom. A suspended or staged one does, so the suspended Marcus and the staged Owen come out of the eight. I confirmed both against a real org, and they’re why the module reports Available and AvailableForSeed as separate numbers: a user this module already created needs no new slot, since seeding updates it in place.

August 2025Now
Users88
Groups / group rules17 / 317 / 3
Apps88, plus the service app
Custom attributes1010 definitions, 18 attributes
User types22
Network zones / policies2 / 32 / 3
Trusted origins / event hooks / linked objects2 / 2 / 12 / 2 / 1
Object types the report covers1011

The eight went everywhere else

The biggest change to the seed data is that it stopped belonging to the Okta provider.

Those eight logins, awhitfield, jnino, zmueller, mbell, praghunathan, talvarez, hkobayashi and ofitzgerald, are now the shared core cohort. The same people, under the same keys, are hand-designed rows in Entra, Authentik, FreeIPA, PingOne and OneLogin, and real accounts in the Active Directory data. Core/Data/SeedPeople.csv is the one place their names live, and every provider’s generator reads it and keeps only the fields it has somewhere to put.

Before that file existed I had typed the names into four separate generators, and the drift it caught on its first run is the argument for it. Owen Fitzgerald in Entra and PingOne was Orla in Authentik and FreeIPA. Same key, same manager, same department, a different person depending on which directory you read. Nothing failed and no test covered it, and a cross-directory match would have reported one differing name and looked like a bug in the matcher.

Hana is the other changed row. The nine people written in Han, Cyrillic, Greek, Arabic and Devanagari that the other providers carry can’t come here, because each one costs a license slot and there are none. Okta gets the one it already had, written properly: 小林 花 in kanji, with hkobayashi as the login, which the other five providers now show as well. She keeps the property she was designed for, an entitlements array that is empty. A multi-valued attribute with nothing in it breaks a different set of code paths than one with three values.

That file is a contract now, and I test it like one. SeedPeople.Tests.ps1 pins the codepoints of every name in it and reads every provider’s users file against it, so a person can’t exist in four directories and be missing from the fifth, and an editor can’t normalize a name on save without a test failing. Changing a row in the shared file changes five labs at once. That’s what makes cross-directory matching testable, and it’s also why those eighteen rows are the most carefully guarded in the repository.

The eight are also why -Tier Core means something consistent. On every other provider the core tier is the designed people and the bulk tier is generated volume. Here the core tier is the entire population, so a script written against Entra’s fast loop runs unchanged against an org that has no slow loop to skip.

Everything above the people is unchanged, because it was never capped and had nothing to fix. Seventeen groups with overlapping, empty and rule-driven membership. Three group rules keyed on three different attribute kinds. Eight apps across three sign-on modes, turning “who exists” into “who has access to what”. Two sign-on policies that overlap so precedence has to be read from the org.

Ten definitions, eighteen attributes

The old post counted ten custom attributes. The provider page says eighteen. Both numbers are right, and the gap between them is what the second user type exists to teach.

Eight of the ten definitions are declared with no user type and land on both schemas. Two name Contractor and exist only there:

1
2
"labAgencyName","Lab Agency Name","Staffing agency the contractor came through. Exists only on the Contractor user type.",...,"Contractor"
"labPurchaseOrder","Lab Purchase Order","PO number the contractor is billed against. Only on the Contractor type, so a default-schema export cannot see it.",...,"Contractor"

Eight on each of two types plus two on one of them is eighteen attributes from ten definitions. A script that reads /api/v1/meta/schemas/user/default sees sixteen of them and has no way to know it is missing anything. I made Marcus and Owen contractors for that reason: the two attributes the script can’t see are the two that say who is paying for them.

The types still cover the data shapes an export flattens wrongly. labEntitlements is a multi-valued array. labRiskScore is an integer that is zero for Zoë, because zero is falsy and if ($value) drops it without comment. labClearanceLevel is an enum with three values. labContractEndDate is an ISO 8601 date held as a string, because that’s how Okta stores dates whatever you would prefer.

An empty pattern after the merge

Consolidating three modules into one moved Private, Public and Data from each of them into a provider folder. The Okta module had one thing that lived outside those three folders: a constant in its root module naming the domain its seed URLs are written against.

Nothing failed at import. New-OktaApp went on running, and the -replace that substitutes the seed domain into an app URL now matched an empty pattern.

In a PowerShell -replace, an empty pattern raises no error. It matches at every position, so the replacement is inserted between every character in the string. The app was created, the call succeeded, and the redirect URI came out as the replacement text interleaved with the original one character at a time. One unit test caught it.

Every provider folder now carries an Initialize.ps1, dot-sourced by the root module after that provider’s Private and Public folders. Its constants live there:

1
2
3
# The domain the seed URLs are written against, substituted for the connection's EmailDomain so
# one CSV serves any org.
$script:DefaultSeedDomain = 'oktalab.example.com'

The next provider that needs a constant has an obvious place to put it, inside its own folder.

Verifying an org that could not finish

Test-TestEnvironment arrived with 1.3.0. It reads the users, groups and applications the module owns and compares them with the seed files.

1
Test-TestEnvironment

It judges five checks here: every seeded login present, every display name equal to the data, every group name present, every membership a group row lists in place, and every application label present. Each check also looks the other way, for objects the module owns that the data doesn’t describe, since a leftover from an earlier seed data file is as much a difference as a missing row. -SkipMembership drops the per-group reads, the expensive part against an org that throttles, and -Quiet returns the result object alone for a script to branch on.

Two of its rules are about Unicode and one is about the cap.

Names are compared ordinally, because -eq calls a decomposed and a precomposed name equal while the wire treats them as different strings. A directory that mangles Zoë into Zoe or into a replacement character can pass an -eq check against the wrong string, and catching that is the reason the accented rows exist. Logins are compared case-insensitively, because Okta folds them.

A group rule adds members the data never lists, so the verifier only fails a membership when one the data names is absent.

The cap rule is specific to this provider:

An org whose active-user limit stopped the seed short reports the users it could not create as missing. That is the truth of the org, and the report says so instead of adjusting the expectation.

An easier verifier would compare against what the seed managed to create, and it would always pass. This one compares against what the data describes, so an org holding six of eight seeded users says so, every time, until somebody frees two slots. A verification that adjusts its expectations to match reality can’t tell you anything about reality.

Repairing, where deletion is permanent

Okta has no recycle bin. A deleted user is gone, so teardown establishes ownership through two independent markers before removing anything, and re-running the seed is the normal way back from a partial run.

Repair-TestEnvironment makes that a command. It runs the verifier, works out which seed steps own the failed checks, re-runs those steps alone, and verifies again. Every step is idempotent, so what exists is reused and nothing is duplicated. Each provider declares the map in its Initialize.ps1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Which seed step owns each check Test-OktaEnvironment judges, for Repair-TestEnvironment.
# Memberships live on the groups step, which adds every login a group row lists.
$script:OktaRepairStep = @{
    Step   = @{
        'Users'              = 'Users'
        'User display names' = 'Users'
        'Groups'             = 'Groups'
        'Group memberships'  = 'Groups'
        'Applications'       = 'Apps'
    }
    Always = @()
}

A test holds every provider’s map to covering every check its verifier judges, read from the verifier’s own source, so nobody can add a check without saying which step puts it back. Always is empty here. On Entra it holds the administrative units and the containment pass, because placing an object there is a separate call from creating it.

Anything present that the data doesn’t describe is reported and left alone. Removing objects the module owns stays teardown’s job. On a directory with no undo I would keep that separation even if it cost more code.

Comparing eight people against three hundred

Compare-TestEnvironment reads the seeded people from two providers connected in the same session and reports how they line up, the way a hybrid identity match would.

Okta holds the smallest population the module seeds, and it’s a useful one to point at the others, because its eight are the same eight every other directory holds by hand. The match runs on the login key, and for this provider that is the local part of the login:

1
2
3
4
New-TestIdentity -Provider 'Okta' -Login $login -Key (($login -split '@', 2)[0]) `
    -DisplayName ([string]$user.profile.displayName) `
    -GivenName ([string]$user.profile.firstName) -Surname ([string]$user.profile.lastName) `
    -Enabled ([string]$user.status -eq 'ACTIVE')

jnino is jnino in every directory that keeps the shared logins, so the eight match on the key alone, and their names are then compared codepoint by codepoint. The three hundred people the other provider holds are reported as being on one side only. I sized the populations differently, and the report says so plainly.

Enabled state is compared and reported without a verdict, for a similar reason. Marcus is suspended and Owen is staged, so Enabled is false for both here. Whether their counterparts elsewhere are disabled too is a choice the seed data makes, so the comparison lists it and moves on.

One report shape, eleven files

The report gained the shared parameter set: -OutputFormat Console, JSON, CSV or HTML, with -OutputPath required for the three file formats and -PassThru for the object. Every provider returns the same shape now: Provider, Target, GeneratedOn, Counts with one number per section, Sections in render order, and one property per section.

There are eleven sections, and the eleventh object type in the count came from there: users, groups, group rules, custom attributes, apps, user types, network zones, policies, trusted origins, event hooks and linked objects. The CSV export used to write six files and now writes eleven, one per section, named OktaLab<Section>.csv, so OktaLabGroupRules.csv and OktaLabCustomAttributes.csv replace the older OktaLabRules.csv and OktaLabAttributes.csv. An empty section still writes an empty file, because a missing file and an empty one mean different things to whatever reads them next.

Underneath, the report does what it always did, and the key behavior is still the same: it reads every user type’s schema, the default included, which catches the mistake the second type exists to expose.

It’s also the command most likely to hit a rate limit, and I left it that way. Group and user assignments are fetched per application, so a full run is around forty calls against an org whose per-minute ceiling is low enough to answer some of them with a 429. The retry absorbs it and the report takes longer. A lab that never trips a throttle teaches you nothing about the code path that handles one, and this is the cheapest place in the module to exercise it.

Shared plumbing and a withheld scope

Three things under the provider are no longer the provider’s own code.

Every request goes through Invoke-TestWebRequest in Core, the single place the body is sent as UTF-8 bytes, the response is decoded from its raw bytes, TLS 1.2 is added on the Desktop edition and the progress bar is suppressed. Those four behaviors had been copied into four request functions and three token endpoints, and the newest provider shipped without them because I wrote it from scratch.

The credential record is written and read by Export-TestCredentialRecord and Import-TestCredentialRecord, one implementation for every provider that keeps a durable credential. It handles the protected secret or the vault pointer, writes UTF-8 with no byte order mark, restricts the folder and file to the current user, and refuses outright a record that lies about its protection. The record files themselves didn’t change, so an existing bootstrap keeps working.

Connecting also answers to -UseStoredCredential as well as -ServiceApp, the one name every provider accepts, so a script handed an arbitrary provider needs a single switch.

The bootstrap itself works as the old post describes: paste an SSWS token once, let the module register an OAuth service app with only the okta.* scopes it needs, and connect as the app from then on. One piece of advice in that post has changed, though. It said to revoke the token once the handover works. I withheld okta.clients.manage from the service app, so rotating the app’s own key always needs a token, and an org where every token has been revoked leaves you creating one by hand in the admin console first. Keep one.

Seven providers, one shape

The Okta provider is the smallest surface in the module, so it’s where a shared shape shows most clearly whether it holds.

A set of contract tests reads every provider folder on disk and holds each one to the same commands and parameters: a Test-<Provider>Environment behind the verifier, a Get-<Provider>IdentitySnapshot behind the comparison, a report taking -OutputFormat, -OutputPath and -PassThru with the same four formats, a repair map covering every check its own verifier judges, and -UseStoredCredential accepted by every provider that stores a credential. Active Directory is the one exception to the last rule, because it stores nothing.

Reading the folders on disk is what catches a provider added later and wired up incompletely. A new provider that exports a seeding command and forgets the verifier fails the contract suite before anybody points it at a real directory, and the failure names the missing file.

Three of those six commands didn’t exist when the first Okta post went up. Beyond the verifier, the snapshot and the twelve-line repair map above, none of them needed a line of Okta-specific code, and that ratio is my argument for having merged the modules in the first place.

Setting it up

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Install-PSResource -Name TestEnvironment

$token = Read-Host 'SSWS token' -AsSecureString
Connect-TestEnvironment -Provider Okta -OrgUrl https://trial-123456.okta.com -ApiToken $token
New-TestServiceApp

Connect-TestEnvironment -Provider Okta -OrgUrl https://trial-123456.okta.com -ServiceApp
New-TestEnvironment
Get-TestEnvironmentReport
Test-TestEnvironment
Remove-TestEnvironment -WhatIf

-Tier is accepted here and does nothing interesting. Every other provider splits its people into a designed core and a generated bulk, and eight users leave no room for volume, so every row in OktaUsers.csv carries Core and the parameter binds anyway. A script written against one provider now runs against the others without a switch per directory, and the provider most likely to break that was the one whose license made the distinction meaningless.

The twelve seed steps run in the only order that works, and -Skip and -Keep take the same twelve names. The output numbers them 1 to 12 now, matching the help. They used to start at 0.

The provider’s page is at Providers/Okta/README.md, and the gotchas the first post collected all still apply: the two-step delete on an active user, the org authorization server, the app profile Okta discards on everything except OIDC, and the event hook URL that has to resolve.