Every provider before this one gave me somewhere to write a marker. Active Directory has adminDescription, Entra has a directory extension, Okta and Authentik have profile fields, and FreeIPA has userclass. PingOne had nothing on a user, so I created a custom attribute and wrote the tag there.
OneLogin gives a role a name. As far as identifying it goes, that’s all there is. Groups and security policies get the same treatment, and a Smart Hook doesn’t even get the name.
If every OneLogin account were a lab, I would shrug and move on. Plenty of small and mid-sized organizations run OneLogin as the thing everybody signs in through, though, and the account somebody points a seeding tool at is as likely to be production as a sandbox. A tool that deletes things has to be certain. A name starting with ZZ-TEST- gets you a strong hunch at best.
The seventh provider therefore claims ownership a different way: by what each object holds.
This is part eight of the series, after the six provider posts that ended with PingOne, which I described at the time as the last one. It was the last one…for almost 24 hours…
The inventory
| Object | Count |
|---|---|
| Custom user fields | 4, one carrying the seed tag on every seeded person |
| Users | 321, of which 21 are hand-designed and 300 generated |
| Licensed users | 10, and everyone else Unlicensed or Rejected on purpose |
| Roles / groups | 4 / 5, with 14 explicit role memberships and 319 people placed in groups |
| Security policies | 2, on three of the five groups |
| Apps / grants to roles | 5 / 5 |
| App rules | 2, one enabled and one disabled |
| API authorizations | 2, with 4 scopes, 3 claims and 3 seeded apps allowed to ask |
| Mappings | 2, one enabled and one disabled, both gated |
| Smart Hooks / sign-up profiles | 1 disabled / 1 disabled |
| MFA factors | 3, only where the account already offers them |
A seed runs in about two minutes, occasionally up to six, and a teardown in about two. Load has little to do with the spread. The variable is how long OneLogin takes on a given day to show the role grants the users step waits for, and that gets a section of its own below.
I capped licensed users at ten. A trial has twelve user licenses with the account owner holding one, so a seed that approved everybody would exhaust the account. Ten Approved people with the other 311 Unlicensed or Rejected means a seed costs at most ten licenses whatever the plan.
Ownership by contents
Each type is claimed through whatever OneLogin gives it, and for half of them that means contents:
- A user needs the tag in the custom field the seed created, and the prefix on the username. I ask the account server-side for users whose field holds the tag, then check each one again locally, ordinally, so a server that matched loosely still can’t hand teardown somebody else.
- An app, an API authorization server and a sign-up profile need the tag in their description or help text, and the prefix on the name. Either alone falls short: a tag can be pasted into a real object’s description, and a prefix alone is name matching.
- A role needs the prefix, no administrators, at least one user or app, and every user it holds to be a claimed seeded user and every app a claimed seeded app.
- A group is claimed the same way by its members, plus either no security policy or a prefixed one that is not the account’s default.
- A policy is claimed through the groups that use it: prefixed, not the default, used by at least one group and by seeded groups alone.
- A mapping needs the prefix, match
all, the seed-tag condition, and only add-role actions naming seeded roles. - A Smart Hook has no name at all, so the seed writes a marker line as the first line of its code, and the hook’s conditions have to name at least one seeded role.
Two consequences of this look like bugs when you first hit them.
A prefixed role holding one real person is refused, and so is an empty one. An empty role says nothing about who made it, so the seed never creates a role or group that nobody being seeded will hold. If a type can only be claimed through its contents, it can never be allowed to go empty.
The other consequence is ordering. Because roles, groups, policies, mappings, rules and the hook are all claimed through what they hold or name, teardown has to establish ownership of everything before it deletes anything. Delete the people first and every role becomes unclaimable in the same instant.
Failures get reported. Anything carrying the prefix that fails its check is listed with the reason and left in place, so a teardown that skipped something tells you what and why.
-Keep follows the same logic and says so out loud. Keeping people keeps the field that identifies them, keeping roles keeps their people and apps, keeping policies keeps their groups, and keeping mappings or the hook keeps the roles they name. A keep that removed the evidence for its kept object would leave something nothing could ever claim again.
Twenty-one people by hand
The hand-designed people cover every lifecycle status OneLogin keeps: Active, Suspended, Locked, PasswordExpired, PasswordPending and AwaitingPasswordReset, against the Approved, Unlicensed and Rejected states. The useful part is the combinations. A suspended sales director who still holds every role. A person whose password has expired while every grant stays in place. A partner sitting at PasswordPending who never completed anything.
One of them is a service account with no manager and no human name, because a directory report that assumes every account belongs to a person should meet one that does not. Three hundred and seventeen of the 321 have a manager, so the chains resolve several levels deep, and I left the other four without one.
Nine carry the shared set of hard names every provider seeds, from Han with an ideographic space to a Turkish dotless i, with every username in plain ASCII.
Each person also carries the directory identifiers a synchronized OneLogin account has: a sAMAccountName, an external id, a user principal name and a distinguished name. A report reconciling OneLogin against Active Directory reads those fields, and seeding them gives the reconciliation something to match.
A gate on every mapping
A mapping in OneLogin is a rule that acts on users as they’re created and updated. An enabled one acts on everybody it matches, and in a real account that is everybody.
Every mapping the seed writes carries one condition the data has no say over:
| |
With match set to all, every condition has to hold, and one of them is a field only this module writes. I gave the gate no parameter to turn it off, the same shape as the Entra provider’s Conditional Access rule and the Authentik provider’s flows. A hurried afternoon can reach for a switch. It has a much harder time with one that was never written.
A mapping that already exists under a seeded name without the gate is left alone, with a message saying it belongs to somebody else. Reusing it would mean adopting a rule I can’t vouch for.
The Smart Hook and the sign-up profile get the same treatment. The hook is always disabled and always gated on a seeded role, and a re-run disables it again. The profile is always disabled, moderated, open to the lab email domain only and given no default role or group, and a re-run puts all of that back. The data has no way to change either.
Licenses decide who gets approved
OneLogin approves a person only while a license is free. Past that it makes them Unlicensed, and the create call answers as though it had approved them either way.
That combination produces a seed that reports success and an account that disagrees with its own data. The users step now reads its people back afterwards and names anyone who came back unlicensed.
Role grants have a related rule that I needed a live account to pin down. OneLogin accepts a role grant for anybody and keeps it only for an Approved person whose status is Active, Suspended, Locked, PasswordExpired or AwaitingPasswordReset. Grant a role to a Rejected person and the call succeeds, and afterwards the membership is gone. The seed data gives roles to those statuses only, and a test holds it to that, since I don’t trust the data to stay correct on its own.
A Rejected person is kept out of every group as well as every role. The rejected partner in the seed belongs to nothing at all.
Grants that answer 200 and apply nothing
This behavior shaped the users step more than anything else in the provider:
| |
Put that next to the ownership design and the two halves lock together. A role is claimed through its members. A grant that was answered and never applied leaves a role with no members. Teardown then refuses to claim it, correctly, and the account keeps a prefixed role that nothing will ever delete.
The wait counts attempts against a limit and ignores the clock, so it takes the same real time live and runs deterministically under a test that mocks the sleep. I picked four minutes because a live seed once waited more than two.
Re-sending a grant is safe, since adding somebody already in a role changes nothing. The odd detail is that re-sending appears to shake loose the ones already pending. Grants that had sat unapplied for minutes turned up within seconds of the next request going out, and I still have no explanation for it.
The same step has one more trap. roles/{id}/users wants a list, and a one-element array piped through ConvertTo-Json becomes a bare number, so I write that body as JSON by hand.
Three statuses that revert
Three lifecycle values would look fine in seed data if you trusted the API to store what you sent. I checked each against a live account.
Unactivated reads back as PasswordPending moments after creation. Unapproved reads back as Approved. OneLogin keeps neither, so the seed writes neither: a column that is accepted and then discarded misleads everyone who reads the data file.
Locked doesn’t hold when sent as a status either. The locked person is created Active and then locked through OneLogin’s lock call, for a year, which holds on a licensed person. A re-run locks them again when less than a month is left, so a long-lived lab never wakes up with its one locked person unlocked.
The verifier checks that the lock has at least another day to run. A boolean would miss the interesting failure, which is a lock that expired.
Identifiers that only match the seed
I built this seed for an account real people sign in to, so every identifier it creates is chosen to be impossible to confuse with, or join to, anything real.
Each sAMAccountName and external id starts with the prefix. User principal names and distinguished names all sit in the lab domain, and phone numbers stay inside the 555-0100 to 555-0199 range reserved for fiction. A tool that joins a directory export on any of those finds the seed and nothing else.
MFA factors follow the same rule: enrolled on seeded people only, only as already verified so nothing is ever sent anywhere, and only where the account already offers the factor. A trial offers none, so a trial seed enrolls none, and the verifier counts how many people hold a factor without judging the number.
Naming every field in the request
An ownership check only sees what you asked for, and OneLogin’s list endpoints leave out most of what these checks need.
A user listing omits custom fields, roles, manager and directory fields unless you name them. A mapping listing returns only the enabled ones unless you ask for the disabled ones too, and app rules behave the same way. Smart Hooks come back without their code, where the hook’s only marker lives. Groups come back without their administrators, and “no administrators” is part of claiming a group.
Each of those gaps would let a check pass or fail on data that was never fetched. So every user listing names its fields, both mapping queries run, and hooks and groups are read in detail before the provider decides anything about them.
Two more from the same family. An enabled mapping runs as each person is created, and mappings are created before people, so one seeded role holds a member the data never lists. Role membership also settles a few seconds after it is written, so a role checked in the same breath as the seed that filled it may read as empty, and the provider refuses it before it will guess.
App secrets, which OneLogin shows once
OneLogin returns an app’s client secret in the answer to the create call and never again. By default the provider keeps the id and drops the secret, because a lab tool holding credentials it has no use for is a liability.
When asked, the two confidential apps’ secrets are written through the same record writer as the connection’s own credential: DPAPI-protected on Windows, or held in a SecretStore vault. The public and native clients and the SAML app have none to keep.
Teardown deletes each saved secret with its app, along with any saved secret whose app has left the account, so they don’t pile up. -WhatIf lists them and deletes nothing, and -Keep Apps leaves them alone.
An app that already existed has no secret to save, since OneLogin won’t show it again. The seed names that app and warns you, and skips writing a record that could never work.
Verifying the account
Test-TestEnvironment reads the account through the same ownership checks teardown uses. I wanted the verifier and teardown to agree because they share code, with no second implementation that happens to match.
It checks every name by codepoint, every lifecycle status and state, every manager, directory identifier and group placement, each policy on its groups along with the settings inside it, every API scope, claim and client, both app rules, the hook, the sign-up profile, and every role membership and app grant the data lists.
Role memberships only fail when something the data lists is missing. The enabled mapping adds a person the data never mentions, and flagging the mapping for doing its job would be a false fault.
Repair-TestEnvironment re-runs the steps that own any failed check, and Compare-TestEnvironment lines these people up against any other connected provider the same way as for PingOne.
Connecting and seeding
| |
The credential needs the Manage All scope. Manage Users covers people only, and the seed also creates roles, apps, mappings, policies, hooks and custom fields. Every call goes to the account’s own host, which serves the token and the API in every region, so there’s no region to name, and -Subdomain takes the bare name, the host or the portal URL. Disconnect-TestEnvironment revokes the token. Left alone, one lives ten hours.
Room on the plan is tighter here than anywhere else in the module: four roles, five apps and ten licensed people. A trial has five roles with the Default role among them, five apps and twelve licenses. OneLogin also allows one Smart Hook of each type, so an account that already has a pre-authentication hook gets a reported refusal, and -Skip Hooks leaves the step out.
The provider’s page has the full inventory, every ownership rule and the behaviors above in longer form: Providers/OneLogin/README.md.
That makes seven providers seeding the same people into seven directories. What OneLogin added to the module is a rule I would now go back and apply to two of the earlier six: an object with no description can still be claimed by what it holds, provided it is never allowed to hold nothing.
