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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,81 @@ All notable changes to this module are recorded here. Format follows

Nothing yet.

## [1.5.0] - 2026-09-29

### Added

- **A seventh provider: OneLogin.** Seeds four custom user fields, 321 people, four roles, five
office groups, two security policies, five apps with two app rules, two API authorization servers
with their scopes, claims and seeded clients, two user mappings, a disabled Smart Hook, a disabled
self-registration profile and pre-enrolled MFA factors where the account offers them into one
OneLogin account through its API as an
API credential, reports on them, verifies them against the seed data, repairs them and removes
them again. `Connect-TestEnvironment -Provider OneLogin -Subdomain <account>` takes the bare name,
the host or the portal URL; `-SaveSecret` writes the credential through the shared record writer
and `-UseStoredCredential` reads it back; `Disconnect-TestEnvironment` revokes the token, which
otherwise lives ten hours. `New-OneLoginCustomAttribute`, `New-OneLoginRole`,
`New-OneLoginGroup`, `New-OneLoginPolicy`, `New-OneLoginApp`, `New-OneLoginAppRule`,
`New-OneLoginApiAuthorization`, `New-OneLoginMapping`, `New-OneLoginSmartHook`,
`New-OneLoginSelfRegistration`, `New-OneLoginUser` and `New-OneLoginMfaFactor` build one object
type at a time. Every person carries the directory identifiers a synchronised account would -
sAMAccountName, user principal name, distinguished name, member_of, external id, phone - and one
person is locked.

It is built to be run against an account real people sign in to. A OneLogin role, group and
mapping carry nothing but a name, so each is proved by what it holds: a role or group by having
at least one member and nothing but seeded people (and, for a role, seeded apps) in it, a mapping
by the seed-tag condition and roles that are themselves proved. A prefixed role holding one real
person, or an empty one, is left alone and reported with the reason. People are proved by the tag
in a custom field the seed creates and the prefix on the username; apps by the tag in the
description and the prefix on the name, as are API servers and the sign-up profile; a policy by
the seeded groups using it, an app rule by its seeded app, and a Smart Hook by a marker line first
in its code. Teardown proves everything before deleting anything, and `-Keep` keeps whatever the
kept type is proved by and says so.

Safety properties with no parameter: every seeded mapping carries a condition that the seed-tag
field holds the tag, with match all, so an enabled mapping can act on seeded people and nobody
else; no role or group is created that nobody being seeded will hold; no app's client secret is
kept; and every id the seed sends is one it created or proved, so no real person is added to a
role or group, given a manager or made one. An app's client secret, which OneLogin shows only on
create, is dropped unless `-SaveAppSecret` asks for it: then the two confidential apps' secrets are
kept through the shared record writer (DPAPI, or the SecretStore with `-UseSecretStore`),
`Get-OneLoginAppCredential` returns them as credentials, the report shows which apps have one, and
teardown deletes each with its app and any whose app is gone. Nothing seeded reaches anything real, and nothing real
is pulled into the seed: the Smart Hook is always disabled and gated on a seeded role; the
sign-up profile is always disabled, moderated, lab-domain only and given no default role or group;
a policy is never the default and lands on seeded groups only; only seeded apps are clients of the
seeded API servers; an app rule sits on a seeded app, names seeded roles and has a fixed action; no
MFA factor is turned on for the account, and one is enrolled verified on seeded people only; and
every directory identifier is under the prefix or the lab domain, with phone numbers in
555-0100 to 555-0199, so nothing joins to a real account. Devices and risk rules are not seeded:
OneLogin has no API to create a device, and a risk rule cannot be scoped to seeded people.

The seed data is sized to a OneLogin trial - five roles with Default among them, five apps, twelve
user licences with the owner among them - and built around what OneLogin does without an error,
each found against a live trial: a person is approved only while a licence is free and is
otherwise made Unlicensed while the create answers as if they were approved; a role grant is kept
only for an approved person whose status is Active, Suspended, Locked, PasswordExpired or
AwaitingPasswordReset; a rejected person is kept out of groups too; Unactivated and Unapproved do
not stay put, nor does a Locked status sent as a status, so the locked person is locked through
the lock call; a role grant becomes visible seconds or minutes after it is answered, and sometimes
only when sent again; and a user listing leaves the custom fields out unless they are asked for by
name. So ten people are
Approved, every Bulk person is Unlicensed on purpose and spends no licence, roles go only to people
who can hold them, the users step reads the people back and names anyone left unlicensed, and it
waits until every role grant it sent is visible. The shared people carry exactly the shared names,
including the writing-system cohort, and a 5.1 round trip of every Core name came back identical by
codepoint.

Verified live against a trial from both editions: a full seed of every object type in 1 minute
47 seconds on Windows PowerShell 5.1 and 5 minutes 49 on PowerShell 7 (OneLogin was slower to
show role grants that run), all 23 verification checks passing, and a teardown of 347 objects in
about two minutes that left nothing behind.

### Fixed

- The module help page listed no PingOne command; it lists the six now, beside the OneLogin ones.

## [1.4.0] - 2026-09-19

### Added
Expand Down
74 changes: 70 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# TestEnvironment

Seeds a realistic identity test environment - Entra ID, Active Directory, Okta, Authentik,
FreeIPA or PingOne - and tears it down again cleanly, proving ownership before deleting anything. Published
FreeIPA, PingOne or OneLogin - and tears it down again cleanly, proving ownership before deleting anything. Published
to the PowerShell Gallery.

## Where the conventions live
Expand Down Expand Up @@ -133,8 +133,8 @@ they need escaping in an LDAP distinguished name and are rejected in an Entra
`mailNickname`. Where the tag is *stored* differs per provider (`adminDescription`,
`description`, a custom Okta profile attribute, the free-form `attributes` of an Authentik
user or group and the bracketed tag in an Authentik application's description, and a custom
`zzTestSeedTag` user attribute on PingOne, because a PingOne user has no description field), but
the value never does.
`zzTestSeedTag` user attribute on PingOne, because a PingOne user has no description field, and a
custom `zztest_seed_tag` user field on OneLogin for the same reason), but the value never does.

### The shared people's names live in one file, and bulk membership is sampled by hash

Expand Down Expand Up @@ -193,6 +193,18 @@ native application with no secret) is created without S256 PKCE. Neither has a p
`New-PingOnePopulation.Tests.ps1` and `New-PingOneApplication.Tests.ps1` assert both. PingOne's own
applications and built-in resources are matched by type, never name, and never touched.

The OneLogin analogue: nothing seeded can reach a real person and nothing real is pulled into the
seed. No mapping is created without a condition that the `zztest_seed_tag` field holds the tag, with
match all, so an enabled mapping can act on seeded people and nobody else. The Smart Hook is always
disabled and gated on a seeded role; the self-registration profile is always disabled, moderated,
lab-domain only and given no default role or group; a policy is never the default and is attached to
seeded groups only; only seeded apps are clients of seeded API servers; an app rule sits on a seeded
app, names seeded roles and has a fixed action; an MFA factor is never turned on for the account and
is enrolled verified on seeded people only; and every directory identifier a person carries is under
the prefix or the lab domain, with phone numbers in 555-0100 to 555-0199 only, so nothing joins to a
real account. None of it has a parameter, and `New-OneLoginStep.Tests.ps1` asserts each and that no
step has a parameter that could loosen it.

### The Entra connect reads the tenant's licences once, and unknown means attempt everything

`Get-EntraCapability` runs inside `Connect-EntraEnvironment` and puts `Capabilities` on the
Expand Down Expand Up @@ -251,6 +263,58 @@ or a bad secret. The connection therefore carries `AuthEnvironmentId` separately
the one the tests mock; it follows the encoding rules above, and it emits paginated items one by one
rather than as a wrapped array, because a wrapped array survives `foreach` and breaks `| Where-Object`.

### OneLogin proves most objects by what they hold, and is sized to a trial

A OneLogin role, group, policy, mapping, app rule and Smart Hook have nothing but a name, or not even
that, and the provider is meant to be safe in an account real people sign in to, so
`Get-OneLoginSeededObject` proves each by its contents: a role by the prefix, no administrators, at
least one member, and nothing but proved seeded people and apps in it; a group likewise, with no
policy but a prefixed non-default one; a policy by being used by proved seeded groups alone; a
mapping by the seed-tag condition, match all, and add-role actions; an app rule by the seeded app it
sits on; a hook by the marker line first in its code. A mapping, rule or hook may name a seeded role
or one that no longer exists - teardown deletes roles, and a re-run must still prove what is left -
but never one that exists and is somebody else's. An empty prefixed role is refused like one holding
a real person. Group detail and hook code are read one by one, because the listings leave out
administrators and code.

That has three consequences that look like over-engineering until you know why: the seed never
creates a role, group or policy nobody in the chosen tiers will hold (`Get-OneLoginSeedScope`),
teardown proves everything before it deletes anything, and `-Keep` keeps whatever the kept type is
proved by, closed over `$script:OneLoginProofDependency`, and names what it added. The seed steps
reuse a prefixed role, group or policy only under `-AllowEmpty`, which accepts an empty one and
nothing else foreign; teardown never passes it.

OneLogin does several things without an error, each found against the live trial, and the seed data
and `SeedData.Tests.ps1` are built around them: a person is approved only while a licence is free
(a trial has twelve, the owner among them) and is otherwise made Unlicensed while the create answers
Approved; a role grant is accepted for anyone and kept only for an Approved person whose status is
1 to 5 (`$script:OneLoginRoleHolderStatus`); a rejected person is kept out of groups too;
Unactivated and Unapproved do not stay put, and nor does a Locked status sent on a create or update,
so the Locked person is created Active and locked for a year through v1 `lock_user`, which holds on a
licensed person only; and a trial allows five roles, Default among them, and five apps. So ten Core
people are Approved, the writing-system cohort and every Bulk person are Unlicensed, and roles go
only to people who can hold them. The user listing leaves out `custom_attributes`, `role_ids`, the
manager and the directory fields unless `fields=` names them, and without the custom field no seeded
user can be proved - the first live seed proved nobody for exactly that reason, so every listing
names `$script:OneLoginUserFields`. A role grant becomes visible some seconds after it is answered,
sometimes minutes, and occasionally only when sent again; `New-OneLoginUser` waits up to four minutes
and re-sends, so the teardown or verification that follows finds provable roles. A body that is a
JSON array of ids is sent pre-serialised, because a one-element array piped to `ConvertTo-Json` is
the bare number. Mappings and app rules list only enabled ones unless `enabled=false` is asked for
too; the two lists are joined with `@(& $list a) + @(& $list b)`, because a single result from
either is not an array and `+` then fails or concatenates the wrong thing - that once left a mapping
and a hook orphaned. A Smart Hook DELETE answers 202 with a plain-text body, which
`Invoke-OneLoginRequest` returns as a string rather than parsing.

An app's client secret is in the answer to its creation and in no later read, so it is dropped
unless `-SaveAppSecret` asks for it, and then kept for the two confidential clients only, one
record per app id through `Export-TestCredentialRecord`. Teardown is what stops those records
building up: it deletes each with its app and, against a listing of every app in the account,
any whose app is gone; never under `-WhatIf` or `-Keep Apps`, never another account's, never a
file whose content disagrees with its name. `OneLoginAppSecret.Tests.ps1` writes the records for
real into TestDrive, so every other OneLogin suite mocks `Get-OneLoginAppSecretRecord` and none
reads the real credential folder.

### The FreeIPA provider talks HTTP through a compiled certificate validator

A FreeIPA server presents a certificate from the realm's own CA, which the machine running the
Expand Down Expand Up @@ -312,7 +376,9 @@ the description of everything else, and asks for staged and preserved users sepa
`zzTestSeedTag` attribute only once the schema confirms that attribute exists (a filter naming a
missing attribute is refused with `REQUEST_FAILED`), requires the tag and the prefix together on
everything else, and removes the attribute last because PingOne will not delete one a user still
holds. Nothing is deleted for merely matching a name pattern, and the fallback
holds; OneLogin proves a person by the tag in its custom field and the prefix, an app by the tag in its
description and the prefix, and a role, group or mapping by what it holds (see below). Nothing is
deleted for merely matching a name pattern, and the fallback
paths that run when a container is gone still refuse objects that are not ours. `-WhatIf` beats
`-Force` on every destructive command, and the Remove suites pin that, because `-Force` defeating
`-WhatIf` was the worst defect the AD module ever shipped.
Expand Down
3 changes: 3 additions & 0 deletions Providers/OneLogin/Data/OneLoginApiAuthorizations.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Key","Name","Path","TokenMinutes","Scopes","Claims","Clients","Description","Tier","Purpose"
"orders-api","Orders API","orders","60","orders:read=Read orders|orders:write=Create and change orders|orders:admin=Administer orders","department=department|cost_center=custom_attribute_zztest_cost_center","expenses=orders:read|contractor-portal=orders:read orders:write","Seeded API","Core","Three scopes, one of them granted to no client, and a claim read from a custom field, which is what an API access review actually reads"
"reports-api","Reports API","reports","10","reports:read=Read reports","department=department","payroll=reports:read","Seeded API","Core","One scope and a ten-minute token, so two servers do not look alike"
3 changes: 3 additions & 0 deletions Providers/OneLogin/Data/OneLoginAppRules.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Key","App","Name","Enabled","Role","Operator","Expression","Tier","Purpose"
"expenses-groups","expenses","Directory groups for staff","TRUE","all-staff","ri","CN=([^,]+)","Core","Enabled: anybody in All Staff gets their directory group names in the token, so a claim changes with group membership"
"payroll-dormant","payroll","Groups for everyone outside Finance","FALSE","finance","rin",".*","Core","Disabled, and would hand payroll every group of everyone who is not in Finance if anybody enabled it"
6 changes: 6 additions & 0 deletions Providers/OneLogin/Data/OneLoginApps.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"Key","Name","Connector","AppType","TokenAuth","LoginUrl","RedirectUri","Audience","ConsumerUrl","Visible","Roles","Description","Tier","Purpose"
"expenses","Expenses Web","OIDC","Web","Basic","https://expenses.{domain}/","https://expenses.{domain}/callback","","","TRUE","all-staff","Seeded confidential web app","Core","The ordinary confidential web app, granted through a role rather than to people"
"payroll","Payroll Console","OIDC","Web","Post","https://payroll.{domain}/","https://payroll.{domain}/callback","","","TRUE","finance","Seeded confidential web app","Core","Granted to Finance, whose audience a mapping widens, and posting its secret in the body rather than a header"
"contractor-portal","Contractor Portal SPA","OIDC","Web","None","","https://portal.{domain}/","","","TRUE","contractors","Seeded public client","Core","A public client with PKCE and no secret, granted only to contractors"
"field-native","Field App","OIDC","Native","None","","com.example.field://callback","","","FALSE","","Seeded native client","Core","A native client with a custom scheme redirect, hidden from the portal and granted to no role, which an inventory still has to list and a review still has to explain"
"wiki-saml","Wiki SAML","SAML","","","https://wiki.{domain}/login","","https://wiki.{domain}/sp","https://wiki.{domain}/acs","TRUE","all-staff;contractors","Seeded SAML app","Core","SAML rather than OIDC, so anything that assumes every app has a client id has one that does not; granted to two roles"
5 changes: 5 additions & 0 deletions Providers/OneLogin/Data/OneLoginCustomAttributes.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"Shortname","Name","Tier","Purpose"
"zztest_seed_tag","ZZ-TEST seed tag","Core","The ownership marker. A OneLogin user has no description to carry one, so the seed creates this field, writes the tag into it on every user, and teardown removes it last"
"zztest_badge_id","ZZ-TEST badge id","Core","An identifier outside the directory's own, absent on some users, so a report has to cope with a field that is simply empty"
"zztest_contractor","ZZ-TEST contractor","Core","A boolean stored as text, because OneLogin fields are text. The string false is not falsy in PowerShell, so anything casting this rather than comparing it reads every person as a contractor"
"zztest_cost_center","ZZ-TEST cost center","Core","A value shared by whole departments, which a mapping or report can group on"
6 changes: 6 additions & 0 deletions Providers/OneLogin/Data/OneLoginGroups.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"Key","Name","Tier","Purpose"
"seattle-hq","Seattle HQ","Core","Where most of the seeded people are, which makes it the group a per-group report is dominated by"
"london","London","Core","An office outside the US, holding a person awaiting a password reset and an unlicensed partner"
"new-york","New York","Core","A second US office, so anything that assumes one is wrong"
"us-regional","US Regional Offices","Core","Several small offices in one group, including the suspended manager"
"remote","Remote Workers","Core","Contractors with no office"
2 changes: 2 additions & 0 deletions Providers/OneLogin/Data/OneLoginHooks.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"Key","Type","Role","Tier","Purpose"
"contractor-preauth","pre-authentication","contractors","Core","A disabled pre-authentication hook gated on a seeded role, which a review of what runs at sign-in has to find"
3 changes: 3 additions & 0 deletions Providers/OneLogin/Data/OneLoginMappings.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Key","Name","Enabled","Conditions","Role","Tier","Purpose"
"finance-dept","Finance department gets Finance","TRUE","department|=|Finance","finance","Core","Enabled, so Finance holds a person the data never lists there, and verification has to judge role membership on what is missing only"
"contractor-eng","Contractors get Engineering","FALSE","custom_attribute_zztest_contractor|=|true","engineering","Core","Disabled, and would put every contractor into Engineering if anyone enabled it; the dormant rule an access review has to find"
3 changes: 3 additions & 0 deletions Providers/OneLogin/Data/OneLoginPolicies.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"Key","Name","Groups","MinimumPasswordLength","PasswordExpirationDays","PasswordsRemembered","MaximumInvalidLoginAttempts","LockEffectiveMinutes","Tier","Purpose"
"strict-office","Strict Office","seattle-hq;new-york","14","60","24","5","30","Core","Long passwords and a lockout for the two biggest offices, so most of the seeded people are under a stricter rule than the account default"
"contractor-access","Contractor Access","remote","12","30","6","3","60","Core","Shorter password life and a harder lockout for contractors; London and the regional offices have no policy of their own and fall back to the default"
Loading
Loading