I wrote Seeding an Okta Test Tenant When You Only Get Ten Users in August 2025. The constraint in the title has not 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 licence 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.

What changed is the module around them, 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, and what it is called now

Okta renamed the thing. The old post says trial org; the plan is 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 is 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. It lists everything that is not DEPROVISIONED, and that is the same population the licence counts. A deactivated leftover from a previous run eats no headroom. A suspended or staged one does, which is why the suspended Marcus and the staged Owen are two of the eight and not two extra. Both of those were confirmed against a real org, and they are the reason 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 interesting change to the seed data is that it stopped being the Okta provider’s seed data.

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 and PingOne, 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 the names were typed 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, different person depending on which directory you read. Nothing failed, no test covered it, and a cross-directory match would have reported one name differing 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 cannot come here, because each is a licence slot and there are none. Okta gets the one it already had, written properly: 小林 花 in kanji, with hkobayashi as the login, which is what the other five providers now show as well. She keeps the property she was designed for, an entitlements array that is empty, since 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 it is tested 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 cannot exist in four directories and be missing from the fifth, and a name cannot be quietly normalised by an editor on save. Changing a row in the shared file changes five labs at once, which is the property that makes cross-directory matching testable and also the reason the file is the most carefully guarded eighteen rows in the repository.

The eight are also the reason -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 there was 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 deliberately overlap so precedence has to be read and cannot be assumed.

Ten definitions, eighteen attributes

The old post counted ten custom attributes. The provider page says eighteen. Both numbers are right, and the difference is the thing 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, which is the whole point of Marcus and Owen being contractors: the two attributes it cannot 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 is how Okta stores dates whatever you would prefer.

What the merge cost, exactly once

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 matched an empty pattern.

An empty pattern in a PowerShell -replace does not error and does not match nothing. 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 convention is there so the next provider has an obvious place to put a constant, and not a root module that no longer belongs to it.

Verifying an org that could not finish

Test-TestEnvironment arrived with 1.3.0 and reads the users, groups and applications the module owns, the same way teardown finds them, then 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 does not 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, which are 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, never with -eq, because -eq calls a decomposed and a precomposed name equal while the wire does not. A directory that mangles Zoë into Zoe or into a replacement character passes an -eq check against the right kind of wrong string, and that fault is exactly what the accented rows exist to catch. Logins are compared case-insensitively, because Okta folds them.

Memberships are judged on what is missing only. A group rule adds members the data never lists, and reporting those as unexpected would mean reporting the rules working as a fault.

The cap rule is the one that 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 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 cannot tell you anything about reality.

Repairing, where deletion is permanent

Okta has no recycle bin. A deleted user is gone, which is why teardown proves ownership through two independent markers before removing anything, and why 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 a check cannot be added 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 does not describe is reported and left alone. Removing objects the module owns stays teardown’s job, and on a directory with no undo that separation is the one I would keep 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 is the smallest population the module seeds, and a useful one to point at the others, because the eight it holds are precisely the eight every other directory also 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 and this one cannot are reported as being on one side only, which is not a fault: the populations are deliberately different sizes and saying so is the correct answer.

Enabled state is compared and reported without a verdict, for the same reason. Marcus is suspended and Owen is staged, so Enabled is false for both here, and whether their counterparts elsewhere are disabled too is a design choice the seed makes on purpose, and no discrepancy at all.

One report shape, eleven files

The report gained the shared parameter set, so -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.

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.

What the report does underneath is unchanged and is still the one to know about: it reads every user type’s schema and not only the default, which is the mistake the second type exists to expose.

It is also the command most likely to meet a rate limit, and on purpose. Group and user assignments are asked for 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 one scope deliberately withheld

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 behaviours had been copied into four request functions and three token endpoints, and the newest provider shipped without them because it was written 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: the protected secret or the vault pointer, UTF-8 with no byte order mark, folder and file restricted to the current user, and a record that lies about its protection refused outright. The record files themselves did not change, so an existing bootstrap keeps working.

And connecting answers to -UseStoredCredential as well as -ServiceApp, the one name every provider accepts, so a script handed an arbitrary provider needs one switch and not five.

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 been revised, though. It said to revoke the token once the handover works. okta.clients.manage is deliberately withheld 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 to do it. Keep one.

Six providers, one shape

The Okta provider is the smallest surface in the module, and that makes it the one where a shared shape either holds or visibly does not.

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 single exception to the last one, because it stores nothing.

Tests that read the folders rather than the loaded module are the ones that catch 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 file that is missing.

Three of those six commands did not exist when the first Okta post went up. None of them required a line of Okta-specific code beyond the verifier, the snapshot and the twelve-line repair map above, and that ratio is the argument for having merged the modules in the first place.

Running it

 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, which is deliberate. 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 place that was most likely to break that was the provider whose licence made the distinction meaningless.

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

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

Next in the series: Authentik, which is self-hosted and therefore has no ceiling to design around at all, where the seeding goes after flows, policies, bindings, entitlements and outposts, and where a policy binds to a UUID that is not the application’s primary key.