diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e28f62..c1f5030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/CLAUDE.md b/CLAUDE.md index eeb50dc..936599f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/Providers/OneLogin/Data/OneLoginApiAuthorizations.csv b/Providers/OneLogin/Data/OneLoginApiAuthorizations.csv new file mode 100644 index 0000000..d04c10c --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginApiAuthorizations.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginAppRules.csv b/Providers/OneLogin/Data/OneLoginAppRules.csv new file mode 100644 index 0000000..48e2345 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginAppRules.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginApps.csv b/Providers/OneLogin/Data/OneLoginApps.csv new file mode 100644 index 0000000..f3f245b --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginApps.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginCustomAttributes.csv b/Providers/OneLogin/Data/OneLoginCustomAttributes.csv new file mode 100644 index 0000000..ea38b52 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginCustomAttributes.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginGroups.csv b/Providers/OneLogin/Data/OneLoginGroups.csv new file mode 100644 index 0000000..61510f3 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginGroups.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginHooks.csv b/Providers/OneLogin/Data/OneLoginHooks.csv new file mode 100644 index 0000000..7b8949b --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginHooks.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginMappings.csv b/Providers/OneLogin/Data/OneLoginMappings.csv new file mode 100644 index 0000000..bed64fc --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginMappings.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginPolicies.csv b/Providers/OneLogin/Data/OneLoginPolicies.csv new file mode 100644 index 0000000..d9690d4 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginPolicies.csv @@ -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" diff --git a/Providers/OneLogin/Data/OneLoginRoles.csv b/Providers/OneLogin/Data/OneLoginRoles.csv new file mode 100644 index 0000000..f6e1d46 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginRoles.csv @@ -0,0 +1,5 @@ +"Key","Name","Tier","Purpose" +"all-staff","All Staff","Core","The broad role nearly every licensed employee holds, and the one most apps are granted through; it holds a suspended manager and people whose passwords have lapsed" +"engineering","Engineering","Core","A department role, and the one the disabled mapping would hand to every contractor if anybody enabled it" +"finance","Finance","Core","One member in the data and a second from the enabled mapping, so who can reach payroll depends on whether mappings have run" +"contractors","Contractors","Core","Held by one contractor, while another contractor is licensed, still waiting for a password, and holds nothing" diff --git a/Providers/OneLogin/Data/OneLoginSelfRegistrations.csv b/Providers/OneLogin/Data/OneLoginSelfRegistrations.csv new file mode 100644 index 0000000..3c018b7 --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginSelfRegistrations.csv @@ -0,0 +1,2 @@ +"Key","Name","Tier","Purpose" +"partner-signup","Partner Sign-up","Core","A dormant public sign-up page, which an access review has to find because enabling it is one click" diff --git a/Providers/OneLogin/Data/OneLoginUsers.csv b/Providers/OneLogin/Data/OneLoginUsers.csv new file mode 100644 index 0000000..1322c0e --- /dev/null +++ b/Providers/OneLogin/Data/OneLoginUsers.csv @@ -0,0 +1,322 @@ +"Key","GivenName","Surname","Title","Department","Company","Manager","Status","State","Group","Roles","BadgeId","Contractor","CostCenter","Tier","Purpose","DisplayName","EmployeeId","Phone","Locale","Mfa" +"awhitfield","Ada","Whitfield","Chief Executive","Executive","","","Active","Approved","seattle-hq","all-staff","B-1001","FALSE","CC-100","Core","Top of every manager chain","Ada Whitfield","EMP-C001","(206) 555-0101","","Email" +"jnino","José","Niño","Principal Engineer","Engineering","","awhitfield","Active","Approved","seattle-hq","all-staff;engineering","B-1002","FALSE","CC-200","Core","Accented name with an ASCII username, managing most of the writing-system cohort","José Niño","EMP-C002","(206) 555-0102","","" +"zmueller","Zoë","Müller","Staff Engineer","Engineering","","jnino","PasswordExpired","Approved","seattle-hq","all-staff;engineering","B-1003","FALSE","CC-200","Core","Accented name, and a password that has expired while every role stays in place","Zoë Müller","EMP-C003","(206) 555-0103","","" +"mbell","Marcus","Bell","Sales Director","Sales","","awhitfield","Suspended","Approved","us-regional","all-staff","B-1004","FALSE","CC-300","Core","Suspended but still holding All Staff and still the manager of two people; the half-finished offboarding","Marcus Bell","EMP-C004","(206) 555-0104","","" +"praghunathan","Priya","Raghunathan","Financial Controller","Finance","","awhitfield","Active","Approved","seattle-hq","all-staff;finance","B-1005","FALSE","CC-400","Core","The only member of Finance the data names, and the only person the payroll app was meant for","Priya Raghunathan","EMP-C005","(206) 555-0105","","Email" +"talvarez","Tomás","Álvarez","Systems Architect","IT","","jnino","Active","Approved","new-york","all-staff;engineering","B-1006","FALSE","CC-200","Core","In Engineering by role and IT by department, and the manager of the contractors","Tomás Álvarez","EMP-C006","(206) 555-0106","","Email" +"hkobayashi","花","小林","Security Consultant","Contractors","Northwind Staffing","talvarez","Active","Approved","remote","contractors","C-2001","TRUE","CC-900","Core","Kanji name, a contractor managed by an employee, and no part of All Staff","小林 花","EMP-C007","(206) 555-0107","ja","" +"ofitzgerald","Owen","Fitzgerald","UX Contractor","Contractors","Northwind Staffing","talvarez","Locked","Approved","remote","contractors","","FALSE","","Core","Locked out for a year and still holding the Contractors role; no badge, no cost center, and a contractor field that says false","Owen Fitzgerald","EMP-C008","(206) 555-0108","","" +"nsorensen","Nadia","Sørensen","People Partner","Human Resources","","awhitfield","AwaitingPasswordReset","Approved","london","all-staff","B-1009","FALSE","CC-500","Core","A stroked o, and a password reset she has been asked for and not done","Nadia Sørensen","EMP-C009","(206) 555-0109","","" +"svcreporting","Reporting","Service","Service Account","IT","","","Active","Unlicensed","","","S-9001","FALSE","CC-200","Core","A non-human account, unlicensed, in no group and no role, which must never be mistaken for the API credential","Reporting Service","EMP-C010","(206) 555-0110","","" +"pmorel","Pascale","Morel","Partner Analyst","Partners","Fabrikam Partners","","PasswordPending","Rejected","","","P-3001","TRUE","CC-900","Core","Rejected by an administrator and never given a password; present, visible, and unable to do anything. In no group, because OneLogin drops a rejected person's group as it drops her roles","Pascale Morel","EMP-C011","(206) 555-0111","","" +"lpetit","Léa","Petit","Partner Consultant","Partners","Fabrikam Partners","","Active","Unlicensed","london","","P-3002","TRUE","CC-900","Core","Active and unlicensed, which reads as able to sign in to anything that checks status alone","Léa Petit","EMP-C012","(206) 555-0112","","" +"jjiang","俊誉","姜","Site Reliability Engineer","Engineering","","jnino","Active","Unlicensed","seattle-hq","","B-1021","FALSE","CC-200","Core","Han name, family name first, joined by an ideographic space that is not U+0020","姜 俊誉","EMP-C013","(206) 555-0113","","" +"tyoshida","太郎","𠮷田","Build Engineer","Engineering","","jnino","Active","Unlicensed","seattle-hq","","B-1022","FALSE","CC-200","Core","A surname above the basic plane, so one character is two UTF-16 units and truncation splits it","𠮷田 太郎","EMP-C014","(206) 555-0114","","" +"dvolkov","Дмитрий","Волков","Infrastructure Engineer","IT","","talvarez","Active","Unlicensed","new-york","","B-1023","FALSE","CC-200","Core","Cyrillic homoglyphs, which a duplicate check made by eye cannot tell from Latin","Дмитрий Волков","EMP-C015","(206) 555-0115","","" +"gpapadopoulos","Γιώργος","Παπαδόπουλος","Financial Analyst","Finance","","praghunathan","Active","Approved","seattle-hq","all-staff","B-1024","FALSE","CC-400","Core","Greek final sigma, and in Finance by department but not by role, until the enabled mapping puts him there","Γιώργος Παπαδόπουλος","EMP-C016","(206) 555-0116","","" +"malahmad","محمد","الأحمد","Network Engineer","IT","","talvarez","Active","Unlicensed","seattle-hq","","B-1025","FALSE","CC-200","Core","Right-to-left, stored in one order and displayed in another wherever it is joined to a Latin username","محمد الأحمد","EMP-C017","(206) 555-0117","","" +"jmarchetti","José","Marchetti","Campaign Manager","Marketing","","mbell","Active","Unlicensed","seattle-hq","","B-1026","FALSE","CC-600","Core","The same José the eye reads on jnino and a different string to every comparison, because this one is stored decomposed; and reports to a suspended manager","José Marchetti","EMP-C018","(206) 555-0118","","" +"iisik","Irmak","Işık","HR Advisor","Human Resources","","nsorensen","Active","Unlicensed","london","","B-1027","FALSE","CC-500","Core","Turkish dotted and dotless i, which lower-case differently under a Turkish culture","Irmak Işık","EMP-C019","(206) 555-0119","tr-TR","" +"jweiss","Jürgen","Weiß","Account Executive","Sales","","mbell","Active","Unlicensed","us-regional","","B-1028","FALSE","CC-300","Core","An eszett, which upper-cases into two characters, and a second report of the suspended manager","Jürgen Weiß","EMP-C020","(206) 555-0120","de","" +"schaudhary","सुनीता","चौधरी","Data Engineer","Engineering","","jnino","Active","Unlicensed","seattle-hq","","B-1029","FALSE","CC-200","Core","Devanagari combining vowel signs, so character count and visible marks are different numbers","सुनीता चौधरी","EMP-C021","(206) 555-0121","","" +"danj","Dan","Jump","CEO","Executive","","awhitfield","Active","Unlicensed","seattle-hq","","B-3001","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Dan Jump","EMP001","(206) 555-0163","","" +"adamb","Adam","Barr","General Manager of Professional Services","Operations","","danj","Active","Unlicensed","seattle-hq","","B-3002","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Adam Barr","EMP002","(206) 555-0132","","" +"frankm1","Frank","Martinez","COO","Executive","","danj","Active","Unlicensed","seattle-hq","","B-3003","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Frank Martinez","EMP007","(206) 555-0103","","" +"jimd","Jim","Daly","CFO","Executive","","danj","Active","Unlicensed","seattle-hq","","B-3004","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Jim Daly","EMP190","(206) 555-0173","","" +"sanjays","Sanjay","Shah","Chief of Technical Strategy","Executive","","danj","Active","Unlicensed","seattle-hq","","B-3005","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Sanjay Shah","EMP188","(206) 555-0136","","" +"tonip","Toni","Poe","Chief of Partnerships and Strategy","Executive","","danj","Active","Unlicensed","seattle-hq","","B-3006","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Toni Poe","EMP189","(206) 555-0130","","" +"alans","Alan","Steiner","Director of Project Management Team","Project Management","","adamb","Active","Unlicensed","seattle-hq","","B-3007","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Alan Steiner","EMP003","(206) 555-0150","","" +"bens","Ben","Spain","Manager","Operations","","adamb","Active","Unlicensed","seattle-hq","","B-3008","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Ben Spain","EMP044","(206) 555-0100","","" +"cassieh","Cassie","Hicks","Director of Client Services","Senior Management","","adamb","Active","Unlicensed","seattle-hq","","B-3009","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Cassie Hicks","EMP183","(206) 555-0137","","" +"christk","Christine","Koch","Managing Director","Engineering","","adamb","PasswordPending","Unlicensed","seattle-hq","","B-3010","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Christine Koch","EMP006","(206) 555-0186","","" +"danp","Dan","Park","Vice President NA Sales","Sales","","adamb","Active","Unlicensed","us-regional","","B-3011","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Dan Park","EMP009","(206) 555-0112","","" +"davidz","David","Zazzo","Managing Director","Engineering Operations","","adamb","Active","Unlicensed","seattle-hq","","B-3012","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","David Zazzo","EMP045","(206) 555-0117","","" +"dianet","Diane","Tibbot","CFO of Professional Services","Operations","","adamb","Active","Unlicensed","seattle-hq","","B-3013","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Diane Tibbot","EMP005","(206) 555-0152","","" +"jeffh","Jeff","Hay","CVP of Operations","Executive","","frankm1","Active","Unlicensed","seattle-hq","","B-3014","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Jeff Hay","EMP010","(206) 555-0116","","" +"keithd","Keith","Dishmo","Procurement Manager","Operations","","adamb","Active","Unlicensed","seattle-hq","","B-3015","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Keith Dishmo","EMP039","(206) 555-0113","","" +"kellyk","Kelly","Krout","Marketing Manager","Marketing","","adamb","Active","Unlicensed","seattle-hq","","B-3016","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Kelly Krout","EMP035","(206) 555-0114","","" +"kellyw","Kelly","Weadock","CTO of Professional Services","Operations","","adamb","Active","Unlicensed","seattle-hq","","B-3017","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Kelly Weadock","EMP021","(206) 555-0124","","" +"aaronp","Aaron","Painter","Strategy Consulting Manager","Strategy Consulting","","christk","PasswordPending","Unlicensed","new-york","","B-3018","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Aaron Painter","EMP026","(206) 555-0180","","" +"amiliag","Ämilia","Groß","Quality Engineer","Engineering","","christk","Active","Unlicensed","seattle-hq","","B-3019","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Ämilia Groß","EMP308","(206) 555-0105","","" +"andrewm","Andrew","Ma","Senior Project Manager","Project Management","","alans","Active","Unlicensed","seattle-hq","","B-3020","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Andrew Ma","EMP027","(206) 555-0108","","" +"arturol","Arturo","Lopez","IT Director","Operations","","kellyw","Active","Unlicensed","seattle-hq","","B-3021","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Arturo Lopez","EMP024","(206) 555-0143","","" +"barryj","Barry","Johnson","Project Manager","Project Management","","alans","Active","Unlicensed","seattle-hq","","B-3022","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Barry Johnson","EMP042","(206) 555-0116","","" +"brads","Brad","Sutton","Strategy Consulting Manager","Strategy Consulting","","christk","Active","Unlicensed","new-york","","B-3023","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Brad Sutton","EMP030","(206) 555-0140","","" +"brianc","Brian","Cox","Procurement Manager","Operations","","keithd","Active","Unlicensed","seattle-hq","","B-3024","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Brian Cox","EMP260","(206) 555-0179","","" +"carolt","Carol","Troup","Strategy Consulting Manager","Engineering Operations","","davidz","Active","Unlicensed","seattle-hq","","B-3025","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Carol Troup","EMP046","(206) 555-0125","","" +"cesarg","Cesar","Garcia","Strategy Consulting Manager","Strategy Consulting","","christk","Active","Unlicensed","new-york","","B-3026","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Cesar Garcia","EMP012","(206) 555-0125","","" +"chrisj","Chris","Johnson","Marketing Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3027","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Chris Johnson","EMP268","(206) 555-0120","","" +"chrisn","Chris","Norred","Project Manager","Project Management","","alans","Active","Unlicensed","seattle-hq","","B-3028","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Chris Norred","EMP004","(206) 555-0179","","" +"christg","Christa","Geller","CVP of Online","Executive","","jeffh","Active","Unlicensed","seattle-hq","","B-3029","FALSE","CC-803","Bulk","Bulk directory volume (Executive)","Christa Geller","EMP013","(206) 555-0125","","" +"christj","Christian","Jene","Marketing Specialist","Marketing","","kellyk","PasswordExpired","Unlicensed","seattle-hq","","B-3030","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Christian Jene","EMP255","(206) 555-0193","","" +"davidb1","David","Bradley","Manager","Operations","","bens","PasswordPending","Unlicensed","seattle-hq","","B-3031","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","David Bradley","EMP247","(206) 555-0184","","" +"davidm","David","Maman","Strategy Consulting Manager","Strategy Consulting","","christk","Active","Unlicensed","new-york","","B-3032","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","David Maman","EMP015","(206) 555-0119","","" +"ellena","Ellen","Adams","Strategy Consulting Manager","Strategy Consulting","","christk","Active","Unlicensed","new-york","","B-3033","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Ellen Adams","EMP043","(206) 555-0109","","" +"erikac","Erika","Cheley","Manager","Operations","","bens","Active","Unlicensed","seattle-hq","","B-3034","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Erika Cheley","EMP047","(206) 555-0103","","" +"felipem","Felipe","Martins","Marketing Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3035","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Felipe Martins","EMP256","(206) 555-0128","","" +"gregw","Greg","Winston","President of Management","Senior Management","","jeffh","Active","Unlicensed","seattle-hq","","B-3036","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Greg Winston","EMP187","(206) 555-0178","","" +"hansg","Hans","Gufler","Manager","Operations","","bens","Active","Unlicensed","seattle-hq","","B-3037","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Hans Gufler","EMP246","(206) 555-0173","","" +"jackc","Jack","Creasey","Managing Director","Sales Engagement Management","","danp","PasswordExpired","Unlicensed","seattle-hq","","B-3038","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Jack Creasey","EMP014","(206) 555-0192","","" +"jamesw2","James","Wilson","Marketing Intern","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3039","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","James Wilson","INT002","(206) 555-0156","","" +"jellev","Jelle","Visser","Procurement Manager","Operations","","keithd","AwaitingPasswordReset","Unlicensed","seattle-hq","","B-3040","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Jelle Visser","EMP212","(206) 555-0198","","" +"jenniferw","Jennifer","Williams","Former Marketing Coordinator","Marketing","","kellyk","Suspended","Unlicensed","seattle-hq","","B-3041","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Jennifer Williams","EMP275","(206) 555-0186","","" +"job","Jo","Berry","Manager","Operations","","bens","Active","Unlicensed","seattle-hq","","B-3042","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Jo Berry","EMP245","(206) 555-0174","","" +"johnev","John","Evans","Marketing Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3043","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","John Evans","EMP257","(206) 555-0157","","" +"jordaom","Jordao","Moreno","Marketing Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3044","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Jordao Moreno","EMP266","(206) 555-0156","","" +"karimm","Karim","Manar","Controller","Accounting","","dianet","Active","Unlicensed","seattle-hq","","B-3045","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Karim Manar","EMP008","(206) 555-0174","","" +"kevina","Kevin","Anderson","Sales Representative","Sales","","danp","Active","Unlicensed","us-regional","","B-3046","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Kevin Anderson","EMP278","(206) 555-0136","","" +"mariar","Maria","Rodriguez","Marketing Analyst","Marketing","Northwind Staffing","kellyk","Active","Unlicensed","seattle-hq","","B-3047","TRUE","CC-897","Bulk","Bulk directory volume (Marketing)","Maria Rodriguez","EMP285","(206) 555-0109","","" +"markj","Mark","Johnson","Project Coordinator","Project Management","","alans","Active","Unlicensed","seattle-hq","","B-3048","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Mark Johnson","EMP280","(206) 555-0102","","" +"mchielw","Mchiel","Wories","Procurement Manager","Operations","","keithd","PasswordExpired","Unlicensed","seattle-hq","","B-3049","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Mchiel Wories","EMP180","(206) 555-0193","","" +"michaeld","Michael","Davis","Former Software Engineer","Engineering","","christk","Suspended","Unlicensed","seattle-hq","","B-3050","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Michael Davis","EMP274","(206) 555-0152","","" +"michellt","Michelle","Thompson","Account Executive","Sales","","danp","Active","Unlicensed","us-regional","","B-3051","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Michelle Thompson","EMP287","(206) 555-0142","","" +"miken","Mike","Nash","Senior Project Manager","Project Management","","alans","AwaitingPasswordReset","Unlicensed","seattle-hq","","B-3052","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Mike Nash","EMP037","(206) 555-0198","","" +"patriciag","Patricia","Garcia","DevOps Engineer","Engineering","","christk","Active","Unlicensed","seattle-hq","","B-3053","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Patricia Garcia","EMP283","(206) 555-0118","","" +"phyllih","Phyllis","Harris","Marketing Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3054","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Phyllis Harris","EMP264","(206) 555-0139","","" +"pieterw","Pieter","Wycoff","Marketing Specialist","Marketing","","kellyk","PasswordPending","Unlicensed","seattle-hq","","B-3055","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Pieter Wycoff","EMP239","(206) 555-0184","","" +"robertt","Robert","Thompson","Former Sales Manager","Sales","","danp","Suspended","Unlicensed","us-regional","","B-3056","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Robert Thompson","EMP273","(206) 555-0146","","" +"sarahc","Sarah","Chen","Software Engineering Intern","Engineering","","christk","Active","Unlicensed","seattle-hq","","B-3057","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Sarah Chen","INT001","(206) 555-0166","","" +"scottb","Scott","Bishop","Senior Project Manager","Project Management","","alans","Active","Unlicensed","seattle-hq","","B-3058","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Scott Bishop","EMP034","(206) 555-0176","","" +"stano","Stan","Orme","Content Management Consulting Manager","Content Management Consulting","","alans","PasswordExpired","Unlicensed","seattle-hq","","B-3059","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Stan Orme","EMP036","(206) 555-0195","","" +"sunilu","Sunil","Uppal","Public Relations Specialist","Marketing","","kellyk","Active","Unlicensed","seattle-hq","","B-3060","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Sunil Uppal","EMP252","(206) 555-0135","","" +"susant","Susan","Taylor","QA Engineer","Engineering","","christk","Active","Unlicensed","seattle-hq","","B-3061","FALSE","CC-830","Bulk","Bulk directory volume (Engineering)","Susan Taylor","EMP279","(206) 555-0171","","" +"tail","Tai","Lee","President of Services","CVP of IT","","jeffh","Active","Unlicensed","seattle-hq","","B-3062","FALSE","CC-738","Bulk","Bulk directory volume (CVP of IT)","Tai Lee","EMP186","(206) 555-0116","","" +"thomasw","Thomas","Wilson","Former Operations Manager","Operations","","jeffh","Suspended","Unlicensed","seattle-hq","","B-3063","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Thomas Wilson","EMP282","(206) 555-0112","","" +"alanb","Alan","Brewer","Regional Sales Manager","Sales","","jackc","AwaitingPasswordReset","Unlicensed","us-regional","","B-3064","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Alan Brewer","EMP016","(206) 555-0199","","" +"alfonsp","Alfons","Parovszky","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","seattle-hq","","B-3065","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Alfons Parovszky","EMP125","(206) 555-0122","","" +"alisal","Alisa","Lawyer","Project Manager","Project Management","","scottb","Active","Unlicensed","seattle-hq","","B-3066","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Alisa Lawyer","EMP163","(206) 555-0115","","" +"allang","Allan","Guinot","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3067","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Allan Guinot","EMP069","(206) 555-0143","","" +"allisob","Allison","Brown","Strategy Consultant","Strategy Consulting","","aaronp","PasswordExpired","Unlicensed","seattle-hq","","B-3068","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Allison Brown","EMP087","(206) 555-0195","","" +"andyj","Andy","Jacobs","Senior Project Manager","Project Management","","scottb","Active","Unlicensed","seattle-hq","","B-3069","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Andy Jacobs","EMP179","(206) 555-0143","","" +"annal","Anna","Lidman","SVP of Online Services","Senior Management","","christg","Active","Unlicensed","seattle-hq","","B-3070","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Anna Lidman","EMP018","(206) 555-0160","","" +"annew","Anne","Weiler","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","seattle-hq","","B-3071","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Anne Weiler","EMP086","(206) 555-0153","","" +"anud","Anu","Deshpande","Engineer","Creative","","carolt","Active","Unlicensed","seattle-hq","","B-3072","FALSE","CC-848","Bulk","Bulk directory volume (Creative)","Anu Deshpande","EMP068","(206) 555-0169","","" +"aris","Ari","Suominen","Engineer","Engineering Operations","","carolt","PasswordExpired","Unlicensed","seattle-hq","","B-3073","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Ari Suominen","EMP067","(206) 555-0196","","" +"aylak","Ayla","Kol","Project Manager","Project Management","","scottb","Active","Unlicensed","seattle-hq","","B-3074","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Ayla Kol","EMP162","(206) 555-0111","","" +"bernart","Bernard","Tham","Engineer","Engineering Operations","","carolt","PasswordPending","Unlicensed","seattle-hq","","B-3075","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Bernard Tham","EMP066","(206) 555-0185","","" +"bjorna","Björn","Åberg","IT Systems Administrator","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3076","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Björn Åberg","EMP289","(206) 555-0100","","" +"blained","Blaine","Dockter","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","seattle-hq","","B-3077","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Blaine Dockter","EMP085","(206) 555-0119","","" +"briang","Brian","Groth","Project Manager","Project Management","","barryj","Active","Unlicensed","seattle-hq","","B-3078","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Brian Groth","EMP048","(206) 555-0121","","" +"carid","Cari","Drummelle","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","new-york","","B-3079","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Cari Drummelle","EMP124","(206) 555-0178","","" +"chasec","Chase","Carpenter","Accountant","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3080","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Chase Carpenter","EMP106","(206) 555-0169","","" +"cheny","Chen","Yang","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3081","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Chen Yang","EMP065","(206) 555-0161","","" +"chrisb","Chris","Barry","Project Manager","Project Management","","miken","Active","Unlicensed","seattle-hq","","B-3082","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Chris Barry","EMP161","(206) 555-0147","","" +"chrisg","Chris","Gray","Senior Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3083","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Chris Gray","EMP234","(206) 555-0109","","" +"chrisjoh","Chris","Johnson","Accountant","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3084","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Chris M. Johnson","EMP270","(206) 555-0158","","" +"chrisjohn","Chris","Johnson","Senior Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3085","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Chris F. Johnson","EMP271","(206) 555-0145","","" +"christopherm","Christopher","Miller","Former Network Administrator","IT Support","","arturol","Suspended","Unlicensed","seattle-hq","","B-3086","FALSE","CC-759","Bulk","Bulk directory volume (IT Support)","Christopher Miller","EMP286","(206) 555-0146","","" +"christr","Christian","Rytt","Project Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3087","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Christian Rytt","EMP171","(206) 555-0155","","" +"cliffd","Cliff","Diddier","Engineer","Engineering Operations","","carolt","PasswordExpired","Unlicensed","seattle-hq","","B-3088","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Cliff Diddier","EMP064","(206) 555-0196","","" +"corinnb","Corinna","Bolender","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3089","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Corinna Bolender","EMP131","(206) 555-0156","","" +"dannio","Danni","Ortman","Senior Project Manager","Project Management","","andrewm","Active","Unlicensed","seattle-hq","","B-3090","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Danni Ortman","EMP178","(206) 555-0133","","" +"daven","Dave","Natsuhara","Project Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3091","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Dave Natsuhara","EMP170","(206) 555-0179","","" +"davidb2","David","Brown","Former Junior Accountant","Accounting","Northwind Staffing","karimm","Suspended","Unlicensed","seattle-hq","","B-3092","TRUE","CC-727","Bulk","Bulk directory volume (Accounting)","David Brown","EMP276","(206) 555-0132","","" +"davidh","David","Hamilton","CRM Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3093","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","David Hamilton","EMP056","(206) 555-0149","","" +"denisd","Denis","Dehenne","Project Manager","Project Management","","barryj","Active","Unlicensed","seattle-hq","","B-3094","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Denis Dehenne","EMP159","(206) 555-0155","","" +"dianeg","Diane","Glimp","Content Management Consultant","Content Management Consulting","","stano","PasswordExpired","Unlicensed","new-york","","B-3095","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Diane Glimp","EMP152","(206) 555-0195","","" +"donf","Don","Funk","Project Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3096","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Don Funk","EMP169","(206) 555-0164","","" +"donh","Don","Hall","Strategy Consultant","Strategy Consulting","","ellena","PasswordPending","Unlicensed","seattle-hq","","B-3097","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Don Hall","EMP084","(206) 555-0183","","" +"eduardd","Eduard","Dell","Regional Sales Manager","Sales","","jackc","PasswordPending","Unlicensed","seattle-hq","","B-3098","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Eduard Dell","EMP019","(206) 555-0184","","" +"erica","Erik","Andersen","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","seattle-hq","","B-3099","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Erik Andersen","EMP130","(206) 555-0126","","" +"erwinz","Erwin","Zischka","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3100","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Erwin Zischka","EMP240","(206) 555-0115","","" +"estherv","Esther","Valle","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3101","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Esther Valle","EMP062","(206) 555-0144","","" +"francoisl","François","Lefèvre","Financial Analyst","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3102","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","François Lefèvre","EMP290","(206) 555-0143","","" +"frankm","Frank","Miller","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","new-york","","B-3103","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Frank Miller","EMP073","(206) 555-0107","","" +"garethc","Gareth","Chan","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3104","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Gareth Chan","EMP054","(206) 555-0152","","" +"garthf","Garth","Fort","HR Manager","Human Resources","","karimm","PasswordPending","Unlicensed","seattle-hq","","B-3105","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Garth Fort","EMP011","(206) 555-0185","","" +"heinrif","Heinrich","Fischer","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3106","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Heinrich Fischer","EMP258","(206) 555-0139","","" +"ibent","Iben","Thorell","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3107","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Iben Thorell","EMP128","(206) 555-0174","","" +"idanr","Idan","Rubin","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3108","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Idan Rubin","EMP083","(206) 555-0160","","" +"ivos","Ivo","Salmre","Project Manager","Project Management","","andrewm","Active","Unlicensed","seattle-hq","","B-3109","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Ivo Salmre","EMP156","(206) 555-0177","","" +"jamesl","James","Lee","Former Senior Accountant","Accounting","","karimm","Suspended","Unlicensed","seattle-hq","","B-3110","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","James Lee","EMP284","(206) 555-0168","","" +"jamesw","James","Wittrell","Accountant","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3111","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","James Wittrell","EMP121","(206) 555-0151","","" +"jank","Jan","Kotas","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3112","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jan Kotas","EMP136","(206) 555-0133","","" +"jennim","Jenni","Merrifield","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","seattle-hq","","B-3113","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jenni Merrifield","EMP038","(206) 555-0149","","" +"jespera","Jesper","Aaberg","Project Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3114","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Jesper Aaberg","EMP177","(206) 555-0146","","" +"jesuse","Jesus","Escolar","Strategy Consultant","Strategy Consulting","","aaronp","PasswordExpired","Unlicensed","seattle-hq","","B-3115","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jesus Escolar","EMP082","(206) 555-0195","","" +"jillf","Jill","Frank","Accountant","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3116","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Jill Frank","EMP140","(206) 555-0172","","" +"jimc","Jim","Corbin","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","seattle-hq","","B-3117","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jim Corbin","EMP081","(206) 555-0164","","" +"jiml","Jim","Lucey","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3118","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Jim Lucey","EMP053","(206) 555-0150","","" +"joell","Joel","Lachance","Accountant","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3119","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Joel Lachance","EMP141","(206) 555-0163","","" +"johne","John","John","Strategy Consultant","Strategy Consulting","","aaronp","PasswordExpired","Unlicensed","seattle-hq","","B-3120","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","John John","EMP142","(206) 555-0195","","" +"johny","John","Yokum","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","seattle-hq","","B-3121","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","John Yokum","EMP093","(206) 555-0137","","" +"jong","Jon","Grande","Manager","Operations","","erikac","Active","Unlicensed","seattle-hq","","B-3122","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Jon Grande","EMP244","(206) 555-0102","","" +"jonj","Jon","Jaffe","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3123","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jon Jaffe","EMP076","(206) 555-0169","","" +"junc","Jun","Cao","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","seattle-hq","","B-3124","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Jun Cao","EMP143","(206) 555-0172","","" +"justint","Justin","Thorp","Manager","Operations","","erikac","Active","Unlicensed","seattle-hq","","B-3125","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Justin Thorp","EMP243","(206) 555-0174","","" +"kaia","Kai","Axford","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3126","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Kai Axford","EMP250","(206) 555-0151","","" +"katet","Kate","Taneyhill","Senior Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3127","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Kate Taneyhill","EMP233","(206) 555-0109","","" +"kene","Ken","Ewert","Manager","Operations","","erikac","Active","Unlicensed","seattle-hq","","B-3128","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Ken Ewert","EMP242","(206) 555-0125","","" +"kenm","Ken","Malcolmson","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","seattle-hq","","B-3129","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Ken Malcolmson","EMP153","(206) 555-0173","","" +"kennetc","Kenneth","Cools","Strategy Consultant","Strategy Consulting","","brads","PasswordPending","Unlicensed","seattle-hq","","B-3130","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Kenneth Cools","EMP145","(206) 555-0190","","" +"kevink","Kevin","Kennedy","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3131","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Kevin Kennedy","EMP249","(206) 555-0144","","" +"kimr","Kim","Ralls","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","seattle-hq","","B-3132","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Kim Ralls","EMP135","(206) 555-0116","","" +"krisj","Kris","Johnsen","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3133","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Kris Johnsen","EMP269","(206) 555-0138","","" +"larryz","Larry","Zhang","Regional Sales Manager","Sales","","jackc","Active","Unlicensed","new-york","","B-3134","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Larry Zhang","EMP023","(206) 555-0116","","" +"larsh","Lars","Hansson","Accounting Manager","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3135","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","Lars Hansson","EMP139","(206) 555-0179","","" +"lisaa","Lisa","Andrews","Content Management Consultant","Content Management Consulting","","stano","PasswordPending","Unlicensed","london","","B-3136","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Lisa Andrews","EMP052","(206) 555-0191","","" +"lisam","Lisa","Miller","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","seattle-hq","","B-3137","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Lisa Miller","EMP079","(206) 555-0104","","" +"lisamart","Lisa","Martinez","Former Help Desk Technician","IT Support","","arturol","Suspended","Unlicensed","seattle-hq","","B-3138","FALSE","CC-759","Bulk","Bulk directory volume (IT Support)","Lisa Martinez","EMP277","(206) 555-0183","","" +"lolans","Lolan","Song","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","seattle-hq","","B-3139","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Lolan Song","EMP138","(206) 555-0170","","" +"lorik","Lori","Kane","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3140","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Lori Kane","EMP063","(206) 555-0168","","" +"lubork","Lubor","Kollar","Strategy Consultant","Strategy Consulting","","aaronp","Active","Unlicensed","seattle-hq","","B-3141","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Lubor Kollar","EMP147","(206) 555-0119","","" +"lukask","Lukas","Keller","Manager","Operations","","erikac","Active","Unlicensed","seattle-hq","","B-3142","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Lukas Keller","EMP241","(206) 555-0149","","" +"magnush","Magnus","Hedlund","Business Process Manager","Content Management Consulting","","stano","Active","Unlicensed","seattle-hq","","B-3143","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Magnus Hedlund","EMP129","(206) 555-0162","","" +"manueam","Manueal","Machado","Senior Project Manager","Project Management","","barryj","Active","Unlicensed","seattle-hq","","B-3144","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Manueal Machado","EMP154","(206) 555-0142","","" +"margara","Margaret","Au","Business Process Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3145","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Margaret Au","EMP176","(206) 555-0130","","" +"maried","Marie","Dubois","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3146","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Marie Dubois","EMP248","(206) 555-0104","","" +"maryc","Mary","Cha","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3147","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Mary Cha","EMP051","(206) 555-0159","","" +"matth","Matt","Hink","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","seattle-hq","","B-3148","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Matt Hink","EMP126","(206) 555-0111","","" +"michael","Michael","Ludwig","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","seattle-hq","","B-3149","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Michael Ludwig","EMP148","(206) 555-0100","","" +"michaem","Michael","Mainer","Manager","Operations","","erikac","Active","Unlicensed","seattle-hq","","B-3150","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Michael Mainer","EMP209","(206) 555-0164","","" +"michaes","Michael","Sullivan","Strategy Consultant","Strategy Consulting","","brads","Active","Unlicensed","seattle-hq","","B-3151","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Michael Sullivan","EMP150","(206) 555-0176","","" +"michal1","Michael","Lund","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","seattle-hq","","B-3152","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Michael Lund","EMP149","(206) 555-0112","","" +"michelf","Michelle","Fredette","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3153","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Michelle Fredette","EMP237","(206) 555-0140","","" +"miker","Mike","Ray","Strategy Consultant","Strategy Consulting","","davidm","PasswordPending","Unlicensed","new-york","","B-3154","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Mike Ray","EMP077","(206) 555-0186","","" +"miket","Mike","Tiano","Business Process Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3155","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Mike Tiano","EMP175","(206) 555-0142","","" +"nedf","Ned","Friend","Project Manager","Project Management","","chrisn","AwaitingPasswordReset","Unlicensed","seattle-hq","","B-3156","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Ned Friend","EMP167","(206) 555-0198","","" +"nikosd","Νίκος","Δημητρίου","Facilities Coordinator","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3157","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Νίκος Δημητρίου","EMP306","(206) 555-0169","","" +"nuriag","Nuria","Gonzalez","Senior Project Manager","Project Management","","miken","PasswordPending","Unlicensed","seattle-hq","","B-3158","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Nuria Gonzalez","EMP181","(206) 555-0186","","" +"olivief","Olivier","Fontana","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3159","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Olivier Fontana","EMP236","(206) 555-0174","","" +"parnak","Parna","Khot","Strategy Consultant","Strategy Consulting","","aaronp","Active","Unlicensed","seattle-hq","","B-3160","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Parna Khot","EMP134","(206) 555-0158","","" +"pauld","Paul","Duffy","Senior Engineer","Engineering Operations","","carolt","PasswordPending","Unlicensed","seattle-hq","","B-3161","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Paul Duffy","EMP232","(206) 555-0187","","" +"pedert","Peder","Thode","Content Management Consultant","Content Management Consulting","","stano","PasswordPending","Unlicensed","london","","B-3162","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Peder Thode","EMP050","(206) 555-0185","","" +"peters","Peter","Saddow","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","new-york","","B-3163","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Peter Saddow","EMP060","(206) 555-0179","","" +"rayc","Ray","Chow","Strategy Consultant","Strategy Consulting","","davidm","Active","Unlicensed","seattle-hq","","B-3164","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Ray Chow","EMP151","(206) 555-0105","","" +"raym","Ray","Mohrman","Senior Project Manager","Project Management","","barryj","Active","Unlicensed","seattle-hq","","B-3165","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Ray Mohrman","EMP174","(206) 555-0132","","" +"reinac","Reina","Cabatana","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","london","","B-3166","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Reina Cabatana","EMP058","(206) 555-0118","","" +"reneel","Renee","Lo","Strategy Consultant","Strategy Consulting","","aaronp","Active","Unlicensed","seattle-hq","","B-3167","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Renee Lo","EMP123","(206) 555-0160","","" +"richart","Richard","Tupy","Project Manager","Project Management","","andrewm","PasswordPending","Unlicensed","seattle-hq","","B-3168","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Richard Tupy","EMP168","(206) 555-0186","","" +"robb","Rob","Barker","Project Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3169","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Rob Barker","EMP165","(206) 555-0139","","" +"roberto","Robert","O'Hara","Content Management Consultant","Content Management Consulting","","stano","PasswordPending","Unlicensed","seattle-hq","","B-3170","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Robert O'Hara","EMP078","(206) 555-0182","","" +"robiny","Robin","Young","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","new-york","","B-3171","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Robin Young","EMP059","(206) 555-0113","","" +"roby","Rob","Young","IT Manager","Operations","","arturol","Active","Unlicensed","seattle-hq","","B-3172","FALSE","CC-793","Bulk","Bulk directory volume (Operations)","Rob Young","EMP207","(206) 555-0121","","" +"rolandw","Roland","Wacker","Strategy Consultant","Strategy Consulting","","aaronp","Active","Unlicensed","new-york","","B-3173","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Roland Wacker","EMP091","(206) 555-0169","","" +"sameert","Sameer","Tejani","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","new-york","","B-3174","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Sameer Tejani","EMP075","(206) 555-0171","","" +"sanjayp","Sanjay","Patel","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","seattle-hq","","B-3175","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Sanjay Patel","EMP090","(206) 555-0106","","" +"seanp","Sean","Purcell","Business Process Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3176","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Sean Purcell","EMP173","(206) 555-0118","","" +"simonp","Simon","Pearson","Senior Engineer","Engineering Operations","","carolt","PasswordPending","Unlicensed","seattle-hq","","B-3177","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Simon Pearson","EMP231","(206) 555-0183","","" +"simonr","Simon","Rapier","Senior Project Manager","Project Management","","scottb","PasswordPending","Unlicensed","seattle-hq","","B-3178","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Simon Rapier","EMP164","(206) 555-0183","","" +"spencel","Spencer","Low","Regional Sales Manager","Sales","","jackc","Active","Unlicensed","seattle-hq","","B-3179","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Spencer Low","EMP020","(206) 555-0136","","" +"stephed","Stephen","Deming","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3180","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Stephen Deming","EMP235","(206) 555-0174","","" +"stevel","Steve","Luper","Regional Sales Manager","Sales","","jackc","Active","Unlicensed","seattle-hq","","B-3181","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Steve Luper","EMP032","(206) 555-0133","","" +"stevem","Steve","Masters","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","seattle-hq","","B-3182","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Steve Masters","EMP146","(206) 555-0165","","" +"sunilk","Sunil","Koduri","Senior Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3183","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Sunil Koduri","EMP230","(206) 555-0150","","" +"syeda","Syed","Abbas","CRM Consulting Manager","CRM Strategy","","aaronp","Active","Unlicensed","london","","B-3184","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Syed Abbas","EMP049","(206) 555-0142","","" +"tedb","Ted","Bremer","Business Process Manager","Project Management","","chrisn","Active","Unlicensed","seattle-hq","","B-3185","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Ted Bremer","EMP172","(206) 555-0160","","" +"terrye","Terry","Earls","Project Manager","Project Management","","miken","Active","Unlicensed","seattle-hq","","B-3186","FALSE","CC-780","Bulk","Bulk directory volume (Project Management)","Terry Earls","EMP208","(206) 555-0118","","" +"tonym","Tony","Madigan","Senior Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3187","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Tony Madigan","EMP229","(206) 555-0137","","" +"tonyw","Tony","Wang","Strategy Consultant","Strategy Consulting","","ellena","Active","Unlicensed","seattle-hq","","B-3188","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Tony Wang","EMP144","(206) 555-0157","","" +"treyp","Trey","Pratt","Strategy Consultant","Strategy Consulting","","cesarg","AwaitingPasswordReset","Unlicensed","seattle-hq","","B-3189","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Trey Pratt","EMP132","(206) 555-0197","","" +"valeryu","Valery","Ushakov","Engineer","Engineering Operations","","carolt","Active","Unlicensed","seattle-hq","","B-3190","FALSE","CC-745","Bulk","Bulk directory volume (Engineering Operations)","Valery Ushakov","EMP088","(206) 555-0179","","" +"vladime","Vladimir","Egorov","Content Management Consultant","Content Management Consulting","","stano","Active","Unlicensed","new-york","","B-3191","FALSE","CC-748","Bulk","Bulk directory volume (Content Management Consulting)","Vladimir Egorov","EMP061","(206) 555-0110","","" +"williav","William","Vong","Strategy Consultant","Strategy Consulting","","cesarg","Active","Unlicensed","new-york","","B-3192","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","William Vong","EMP074","(206) 555-0138","","" +"yukon","優子","中村","Accounts Payable Clerk","Accounting","","karimm","Active","Unlicensed","seattle-hq","","B-3193","FALSE","CC-727","Bulk","Bulk directory volume (Accounting)","中村 優子","EMP304","(206) 555-0126","","" +"alexans","Alexandre","Silva","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3194","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Alexandre Silva","EMP098","(206) 555-0106","","" +"amya","Amy","Alberts","HR Manager","Human Resources","","garthf","PasswordPending","Unlicensed","seattle-hq","","B-3195","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Amy Alberts","EMP031","(206) 555-0186","","" +"andersm","Anders","Madsen","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3196","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Anders Madsen","EMP097","(206) 555-0153","","" +"andread","Andrea","Dunker","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3197","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Andrea Dunker","EMP116","(206) 555-0156","","" +"aprils","April","Stewart","Sales Manager","Sales","","eduardd","Active","Unlicensed","us-regional","","B-3198","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","April Stewart","EMP025","(206) 555-0155","","" +"arleneh","Arlene","Huff","Strategy Consultant","Strategy Consulting","","jennim","Active","Unlicensed","seattle-hq","","B-3199","FALSE","CC-761","Bulk","Bulk directory volume (Strategy Consulting)","Arlene Huff","EMP238","(206) 555-0103","","" +"arthury","Arthur","Yasinski","Sales Manager","Sales Engagement Management","","spencel","Active","Unlicensed","us-regional","","B-3200","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Arthur Yasinski","EMP029","(206) 555-0106","","" +"carlosg","Carlos","Grilo","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordPending","Unlicensed","seattle-hq","","B-3201","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Carlos Grilo","EMP117","(206) 555-0189","","" +"chrism","Chris","McGurk","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3202","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Chris McGurk","EMP118","(206) 555-0152","","" +"danb","Dan","Bacon","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordExpired","Unlicensed","seattle-hq","","B-3203","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Dan Bacon","EMP119","(206) 555-0194","","" +"danield","Daniel","Durrer","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3204","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Daniel Durrer","EMP120","(206) 555-0115","","" +"davidd","David","Derwin","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3205","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","David Derwin","EMP092","(206) 555-0162","","" +"davids","David","Simpson","Sales Manager","Sales Engagement Management","","alanb","PasswordPending","Unlicensed","seattle-hq","","B-3206","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","David Simpson","EMP017","(206) 555-0182","","" +"davids1","David","So","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3207","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","David So","EMP096","(206) 555-0176","","" +"doughm","Dough","Mahugh","Project Manager","1099 Contractor","Northwind Staffing","briang","AwaitingPasswordReset","Unlicensed","seattle-hq","","B-3208","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Dough Mahugh","EMP115","(206) 555-0197","","" +"guidop","Guido","Pica","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordPending","Unlicensed","seattle-hq","","B-3209","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Guido Pica","EMP104","(206) 555-0185","","" +"hugog","Hugo","Garcia","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3210","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Hugo Garcia","EMP108","(206) 555-0149","","" +"humbera","Humberto","Acevedo","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3211","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Humberto Acevedo","EMP095","(206) 555-0175","","" +"jakas","Jaka","Stele","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3212","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Jaka Stele","EMP113","(206) 555-0130","","" +"jasonc","Jason","Carlson","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3213","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Jason Carlson","EMP080","(206) 555-0117","","" +"jeffw","Jeff","Williams","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3214","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Jeff Williams","EMP072","(206) 555-0109","","" +"jeremyl","Jeremy","Los","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3215","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Jeremy Los","EMP109","(206) 555-0102","","" +"jessica","Jessica","Arnold","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3216","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Jessica Arnold","EMP105","(206) 555-0173","","" +"johng","John","Ganio","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3217","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","John Ganio","EMP110","(206) 555-0117","","" +"joses","Jose","Saraiva","Sales Manager","Sales","","larryz","Active","Unlicensed","seattle-hq","","B-3218","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Jose Saraiva","EMP040","(206) 555-0144","","" +"karinl","Karin","Lamb","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3219","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Karin Lamb","EMP055","(206) 555-0138","","" +"lenea","Lene","Aalling","Sales Manager","Sales Engagement Management","","stevel","Active","Unlicensed","remote","","B-3220","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Lene Aalling","EMP041","(206) 555-0173","","" +"linaa","Lina","Abola","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3221","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Lina Abola","EMP111","(206) 555-0147","","" +"lorrain","Lorraine","Nay","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3222","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Lorraine Nay","EMP127","(206) 555-0122","","" +"markh","Mark","Hanson","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3223","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Mark Hanson","EMP112","(206) 555-0138","","" +"maurict","Maurice","Taylor","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3224","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Maurice Taylor","EMP103","(206) 555-0162","","" +"meravs","Merav","Sror","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3225","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Merav Sror","EMP057","(206) 555-0170","","" +"michaez","Michael","Zeman","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3226","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Michael Zeman","EMP102","(206) 555-0144","","" +"michiko","Michiko","Osada","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3227","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Michiko Osada","EMP101","(206) 555-0109","","" +"mollyc","Molly","Clark","GM of Online Ad Sales and Affiliate Marketing","Senior Management","","annal","Active","Unlicensed","seattle-hq","","B-3228","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Molly Clark","EMP022","(206) 555-0108","","" +"olgas","Ольга","Соколова","Recruiter","Human Resources","","garthf","Active","Unlicensed","seattle-hq","","B-3229","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Ольга Соколова","EMP305","(206) 555-0131","","" +"qiongw","Qiong","Wu","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordPending","Unlicensed","seattle-hq","","B-3230","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Qiong Wu","EMP100","(206) 555-0191","","" +"scottm","Scott","MacDonald","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3231","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Scott MacDonald","EMP122","(206) 555-0147","","" +"scottr","Scott","Rockfield","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordPending","Unlicensed","seattle-hq","","B-3232","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Scott Rockfield","EMP094","(206) 555-0183","","" +"stevenw","Steven","Wright","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3233","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Steven Wright","EMP114","(206) 555-0178","","" +"stuartm","Stuart","Munson","Project Manager","1099 Contractor","Northwind Staffing","briang","PasswordPending","Unlicensed","seattle-hq","","B-3234","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Stuart Munson","EMP099","(206) 555-0189","","" +"tado","Tad","Orman","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3235","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Tad Orman","EMP071","(206) 555-0107","","" +"terrenp","Terrence","Phillip","Strategy Consultant","1099 Contractor","Northwind Staffing","jennim","Active","Unlicensed","seattle-hq","","B-3236","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Terrence Phillip","EMP107","(206) 555-0104","","" +"thomash","Thomas","Hamborg","Project Manager","1099 Contractor","Northwind Staffing","briang","Active","Unlicensed","seattle-hq","","B-3237","TRUE","CC-818","Bulk","Bulk directory volume (1099 Contractor)","Thomas Hamborg","EMP137","(206) 555-0135","","" +"tobyn","Toby","Nixon","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3238","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Toby Nixon","EMP070","(206) 555-0128","","" +"toshm","Tosh","Meston","CRM Consultant","CRM Strategy","","syeda","Active","Unlicensed","london","","B-3239","FALSE","CC-787","Bulk","Bulk directory volume (CRM Strategy)","Tosh Meston","EMP089","(206) 555-0148","","" +"aliciat","Alicia","Thomber","Salesperson","Sales","","davids","Active","Unlicensed","seattle-hq","","B-3240","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Alicia Thomber","EMP227","(206) 555-0135","","" +"anneliz","Annelie","Zubar","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3241","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Annelie Zubar","EMP225","(206) 555-0117","","" +"chrisjohns","Chris","Johnson","Salesperson","Sales Engagement Management","","aprils","Active","Unlicensed","seattle-hq","","B-3242","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Chris A. Johnson","EMP272","(206) 555-0137","","" +"danh","Dan","Hough","Salesperson","Sales","","arthury","Active","Unlicensed","seattle-hq","","B-3243","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Dan Hough","EMP215","(206) 555-0167","","" +"davidb","David","Bossard","Salesperson","Sales","","aprils","Active","Unlicensed","seattle-hq","","B-3244","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","David Bossard","EMP226","(206) 555-0164","","" +"davidw","David","Wright","Salesperson","Sales","","davids","Active","Unlicensed","us-regional","","B-3245","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","David Wright","EMP160","(206) 555-0171","","" +"dianep","Diane","Prescott","Director of Ad Sales","Marketing","","mollyc","Active","Unlicensed","seattle-hq","","B-3246","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Diane Prescott","EMP028","(206) 555-0160","","" +"dominip","Dominik","Paiha","Salesperson","Sales","","joses","Active","Unlicensed","new-york","","B-3247","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Dominik Paiha","EMP214","(206) 555-0167","","" +"elizaba","Elizabeth","Andersen","Salesperson","Sales","","arthury","Active","Unlicensed","us-regional","","B-3248","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Elizabeth Andersen","EMP158","(206) 555-0119","","" +"emilyr","Emily","Rodriguez","HR Intern","Human Resources","","amya","Active","Unlicensed","seattle-hq","","B-3249","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Emily Rodriguez","INT003","(206) 555-0121","","" +"eranh","Eran","Harel","Salesperson","Sales","","joses","Active","Unlicensed","us-regional","","B-3250","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Eran Harel","EMP204","(206) 555-0117","","" +"ericg","Eric","Gruber","Salesperson","Sales","","lenea","Active","Unlicensed","seattle-hq","","B-3251","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Eric Gruber","EMP211","(206) 555-0160","","" +"erikg","Erik","Gubbels","Salesperson","Sales","","aprils","Active","Unlicensed","seattle-hq","","B-3252","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Erik Gubbels","EMP213","(206) 555-0147","","" +"euang","Euan","Garden","Salesperson","Sales Engagement Management","","aprils","Active","Unlicensed","us-regional","","B-3253","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Euan Garden","EMP203","(206) 555-0110","","" +"fabricc","Fabrice","Canel","Salesperson","Sales Engagement Management","","arthury","Active","Unlicensed","us-regional","","B-3254","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Fabrice Canel","EMP196","(206) 555-0120","","" +"fatimah","فاطمة","حداد","Sales Engineer","Sales","","mollyc","Active","Unlicensed","us-regional","","B-3255","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","فاطمة حداد","EMP307","(206) 555-0129","","" +"ginab","Gina","Boyer","Salesperson","Sales","","arthury","Active","Unlicensed","seattle-hq","","B-3256","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Gina Boyer","EMP224","(206) 555-0147","","" +"hazema","Hazem","Abolrous","Salesperson","Sales","","lenea","PasswordExpired","Unlicensed","seattle-hq","","B-3257","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Hazem Abolrous","EMP166","(206) 555-0196","","" +"howardg","Howard","Gonzalez","Salesperson","Sales","","lenea","Active","Unlicensed","london","","B-3258","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Howard Gonzalez","EMP157","(206) 555-0125","","" +"iant","Ian","Tien","HR Specialist","Human Resources","","amya","Active","Unlicensed","seattle-hq","","B-3259","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Ian Tien","EMP222","(206) 555-0103","","" +"jamier","Jamie","Reding","Salesperson","Sales Engagement Management","","davids","Active","Unlicensed","seattle-hq","","B-3260","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Jamie Reding","EMP193","(206) 555-0174","","" +"jennyl","Jenny","Lysaker","Salesperson","Sales","","aprils","Active","Unlicensed","seattle-hq","","B-3261","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Jenny Lysaker","EMP265","(206) 555-0169","","" +"johnk","John","Kane","Salesperson","Sales","","joses","Active","Unlicensed","us-regional","","B-3262","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","John Kane","EMP133","(206) 555-0150","","" +"joshe","Josh","Edwards","Salesperson","Sales","","lenea","Active","Unlicensed","seattle-hq","","B-3263","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Josh Edwards","EMP267","(206) 555-0101","","" +"juliani","Julian","Isla","Sr. Business Development Manager","Senior Management","","mollyc","Active","Unlicensed","seattle-hq","","B-3264","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Julian Isla","EMP033","(206) 555-0107","","" +"karif","Kari","Furse","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3265","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Kari Furse","EMP220","(206) 555-0113","","" +"kerimh","Kerim","Hanif","Salesperson","Sales Engagement Management","","davids","Active","Unlicensed","seattle-hq","","B-3266","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Kerim Hanif","EMP192","(206) 555-0108","","" +"kevinv","Kevin","Verboort","Salesperson","Sales","","arthury","Active","Unlicensed","seattle-hq","","B-3267","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Kevin Verboort","EMP254","(206) 555-0116","","" +"kima","Kim","Abercrombie","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3268","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Kim Abercrombie","EMP206","(206) 555-0169","","" +"kirkj","Kirk","Nason","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3269","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Kirk Nason","EMP259","(206) 555-0131","","" +"larss","Lars","Sørensen","Account Executive","Sales","","mollyc","PasswordPending","Unlicensed","us-regional","","B-3270","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Lars Sørensen","EMP293","(206) 555-0187","","" +"lucad","Luca","Dellamore","Salesperson","Sales","","aprils","Active","Unlicensed","seattle-hq","","B-3271","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Luca Dellamore","EMP155","(206) 555-0154","","" +"luisb","Luis","Bonifaz","Salesperson","Sales","","arthury","Active","Unlicensed","seattle-hq","","B-3272","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Luis Bonifaz","EMP253","(206) 555-0108","","" +"lukaa","Luka","Abrus","Salesperson","Sales Engagement Management","","aprils","Active","Unlicensed","us-regional","","B-3273","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Luka Abrus","EMP202","(206) 555-0125","","" +"manishc","Manish","Chopra","Salesperson","Sales","","lenea","Active","Unlicensed","seattle-hq","","B-3274","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Manish Chopra","EMP251","(206) 555-0113","","" +"marcelt","Marcelo","Truffat","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3275","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Marcelo Truffat","EMP228","(206) 555-0108","","" +"marcf","Marc","Faerber","Salesperson","Sales","","davids","Active","Unlicensed","us-regional","","B-3276","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Marc Faerber","EMP219","(206) 555-0150","","" +"markoz","Marko","Zajc","Salesperson","Sales","","aprils","PasswordPending","Unlicensed","seattle-hq","","B-3277","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Marko Zajc","EMP195","(206) 555-0180","","" +"marus","Maru","Subrmani","Salesperson","Sales","","aprils","Active","Unlicensed","seattle-hq","","B-3278","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Maru Subrmani","EMP218","(206) 555-0139","","" +"michaeo","Michael","Ovesen","Salesperson","Sales","","joses","Active","Unlicensed","seattle-hq","","B-3279","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Michael Ovesen","EMP205","(206) 555-0174","","" +"nancyw","Nancy","White","HR Specialist","Human Resources","","amya","Active","Unlicensed","seattle-hq","","B-3280","FALSE","CC-843","Bulk","Bulk directory volume (Human Resources)","Nancy White","EMP281","(206) 555-0167","","" +"nates","Nate","Sun","Salesperson","Sales Engagement Management","","lenea","Active","Unlicensed","remote","","B-3281","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Nate Sun","EMP199","(206) 555-0115","","" +"neilo","Neil","Orint","Salesperson","Sales Engagement Management","","aprils","PasswordPending","Unlicensed","us-regional","","B-3282","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Neil Orint","EMP201","(206) 555-0188","","" +"nunof","Nuno","Farinha","Salesperson","Sales","","davids","Active","Unlicensed","us-regional","","B-3283","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Nuno Farinha","EMP217","(206) 555-0163","","" +"patricd","Patricia","Doyle","Salesperson","Sales","","lenea","Active","Unlicensed","us-regional","","B-3284","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Patricia Doyle","EMP261","(206) 555-0175","","" +"pavels","Pavel","Simsa","Salesperson","Sales","","davids","Active","Unlicensed","seattle-hq","","B-3285","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Pavel Simsa","EMP197","(206) 555-0102","","" +"rong","Ron","Gabel","Salesperson","Sales","","aprils","Active","Unlicensed","us-regional","","B-3286","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Ron Gabel","EMP262","(206) 555-0163","","" +"scottg","Scott","Gode","Salesperson","Sales","","davids","Active","Unlicensed","seattle-hq","","B-3287","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Scott Gode","EMP223","(206) 555-0158","","" +"seanc","Sean","Chai","Director of Affiliate Marketing and Partnerships","Senior Management","","mollyc","PasswordExpired","Unlicensed","seattle-hq","","B-3288","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Sean Chai","EMP184","(206) 555-0196","","" +"shmuely","Shmuel","Yair","Salesperson","Sales","","davids","Active","Unlicensed","us-regional","","B-3289","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Shmuel Yair","EMP263","(206) 555-0113","","" +"svenb","Sven","Buck","Salesperson","Sales Engagement Management","","arthury","Active","Unlicensed","us-regional","","B-3290","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Sven Buck","EMP210","(206) 555-0167","","" +"tinal","Tina","Lassila","Salesperson","Sales","","arthury","Active","Unlicensed","seattle-hq","","B-3291","FALSE","CC-817","Bulk","Bulk directory volume (Sales)","Tina Lassila","EMP216","(206) 555-0122","","" +"tinam","Tina","Makovec","Salesperson","Sales Engagement Management","","aprils","Active","Unlicensed","us-regional","","B-3292","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Tina Makovec","EMP200","(206) 555-0176","","" +"tomy","Tom","Youtsey","Salesperson","Sales Engagement Management","","davids","Active","Unlicensed","seattle-hq","","B-3293","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Tom Youtsey","EMP191","(206) 555-0147","","" +"tyc","Ty","Carlson","Salesperson","Sales Engagement Management","","lenea","Active","Unlicensed","remote","","B-3294","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Ty Carlson","EMP182","(206) 555-0176","","" +"vernettp","Vernette","Price","Salesperson","Sales Engagement Management","","lenea","Active","Unlicensed","remote","","B-3295","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Vernette Price","EMP198","(206) 555-0146","","" +"yex","Ye","Xu","Salesperson","Sales Engagement Management","","arthury","Active","Unlicensed","us-regional","","B-3296","FALSE","CC-876","Bulk","Bulk directory volume (Sales Engagement Management)","Ye Xu","EMP194","(206) 555-0121","","" +"karenb","Karen","Berg","Sr. Account Manager","Senior Management","","juliani","Active","Unlicensed","seattle-hq","","B-3297","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Karen Berg","EMP221","(206) 555-0127","","" +"liw","丽","王","Marketing Specialist","Marketing","","dianep","Active","Unlicensed","seattle-hq","","B-3298","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","王 丽","EMP303","(206) 555-0122","","" +"reneed","Renée","Dubois","Marketing Manager","Marketing","","dianep","Active","Unlicensed","seattle-hq","","B-3299","FALSE","CC-897","Bulk","Bulk directory volume (Marketing)","Renée Dubois","EMP291","(206) 555-0116","","" +"toddr","Todd","Rowe","Sr. Media Planner","Senior Management","","dianep","Active","Unlicensed","seattle-hq","","B-3300","FALSE","CC-899","Bulk","Bulk directory volume (Senior Management)","Todd Rowe","EMP185","(206) 555-0151","","" diff --git a/Providers/OneLogin/Data/README.md b/Providers/OneLogin/Data/README.md new file mode 100644 index 0000000..e69de29 diff --git a/Providers/OneLogin/Initialize.ps1 b/Providers/OneLogin/Initialize.ps1 new file mode 100644 index 0000000..db4dde8 --- /dev/null +++ b/Providers/OneLogin/Initialize.ps1 @@ -0,0 +1,138 @@ +# OneLogin provider initialisation. +# +# Module-scope constants the provider's functions read. Dot-sourced by the root module after the +# provider's Private and Public folders, which is what makes these available to them. + +# The domain seeded usernames and emails are written against, substituted for the connection's +# EmailDomain so one CSV serves any account. Under example.com, which RFC 2606 reserves so that test +# data cannot deliver mail to a real recipient. +$script:OneLoginDefaultSeedDomain = 'onelogin-lab.example.com' + +# The custom user field that carries the seed tag on every seeded user. +# +# A OneLogin user has no description and nothing else that is free text nobody writes: comment, +# company, department and title are all things an administrator fills in. So the seed creates a +# field of its own, writes the tag into it on every user it makes, and teardown removes it last. +# Verified live: a shortname may hold letters, digits and underscores and must be unique, a dash is +# refused as "Shortname is invalid", and the users endpoint filters on it server-side as +# custom_attributes.=. +$script:OneLoginSeedAttribute = 'zztest_seed_tag' + +# Every custom field this provider declares starts with this. Teardown removes a field only when the +# data declares it AND its shortname carries this prefix, because a field has no description to hold +# a tag and the shortname is the only thing about it the seed controls. +$script:OneLoginAttributePrefix = 'zztest_' + +# The connectors seeded apps are built from. OneLogin makes every app an instance of a connector, and +# these two are the generic ones: OpenId Connect (OIDC) and SAML Custom Connector (Advanced). The ids +# are global across OneLogin accounts, verified by name against /api/2/connectors. +$script:OneLoginConnector = @{ + OIDC = 108419 + SAML = 110016 +} + +# OneLogin's numeric user status and state, by name. Every value is here so a report can name what it +# reads; the seed data uses only the ones that stay put, and SeedData.Tests.ps1 holds it to that. +# Three do not, all verified against a live account: Locked, because OneLogin sets locked_until +# fifteen minutes out and unlocks the account itself; Unactivated, which read back as +# PasswordPending moments after creation; and the Unapproved state, which read back as Approved. +$script:OneLoginUserStatus = [ordered]@{ + Unactivated = 0 + Active = 1 + Suspended = 2 + Locked = 3 + PasswordExpired = 4 + AwaitingPasswordReset = 5 + PasswordPending = 7 +} +$script:OneLoginUserState = [ordered]@{ + Unapproved = 0 + Approved = 1 + Rejected = 2 + Unlicensed = 3 +} + +# OIDC token endpoint authentication, by the names the seed data uses. None is the public client, and +# on OneLogin choosing it is choosing PKCE: the connector offers no public client without it. +$script:OneLoginTokenAuth = @{ + Basic = 0 + Post = 1 + None = 2 +} + +# What a person may be for a role to hold them. Verified live: OneLogin accepts a role grant for anyone +# and answers 200, then keeps it only for an approved person whose status is one of these. A grant to +# someone Rejected, Unlicensed or still PasswordPending is dropped without a word, so the data gives +# roles only to people who can hold them and the seed says so when OneLogin disagrees. Locked is one: +# a locked person keeps the roles they hold. +$script:OneLoginRoleHolderStatus = @(1, 2, 3, 4, 5) + +# How long a seeded Locked person stays locked, in minutes. Locked is not a status the seed can set - +# a status of 3 unlocks itself fifteen minutes later - but the version 1 lock call takes a duration, +# verified live for a year. Only a licensed person can be locked; an unlicensed one is refused. A +# repair locks again, so a seed older than a year is put back rather than left unlocked. +$script:OneLoginLockMinutes = 525600 + +# The fields a user listing has to ask for. OneLogin's list leaves out custom_attributes, role_ids and +# the manager unless they are named, and without custom_attributes no seeded user can be proved ours. +$script:OneLoginUserFields = 'id,username,email,firstname,lastname,title,department,company,status,state,group_id,manager_user_id,role_ids,custom_attributes,' + + 'samaccountname,userprincipalname,distinguished_name,member_of,external_id,phone,comment,preferred_locale_code,locked_until' + +# The marker a seeded Smart Hook carries in its own code. A hook has no name and no description, so the +# comment on its first line is the only place a tag can go; teardown decodes the function and looks for +# it. The seed writes this line; nothing else does. +$script:OneLoginHookMarker = '// {0}' + +# What each object type's ownership proof reads, for teardown's -Keep. Keeping a type means keeping +# everything its proof depends on, or the next teardown could never claim it: a role is proved by the +# people and apps in it, a group by its people, a policy by the groups using it, a mapping and a hook by +# the roles they name, an app rule by its app and the roles it names, a person by the tag field. +$script:OneLoginProofDependency = @{ + Users = @('Attributes') + Roles = @('Users', 'Apps') + Groups = @('Users') + Policies = @('Groups') + Mappings = @('Roles') + Hooks = @('Roles') + AppRules = @('Apps', 'Roles') + Apps = @() + ApiAuthorizations = @() + SelfRegistration = @() + Attributes = @() +} + +# The page size every list request asks for. OneLogin's v2 endpoints take limit and page and answer the +# page count in a Total-Pages header. +$script:OneLoginPageSize = 100 + +# Which seed step owns each check Test-OneLoginEnvironment judges, for Repair-TestEnvironment. The +# users step converges a reused user's names, lifecycle, group, manager and roles, so everything about +# a person is put back by it. +$script:OneLoginRepairStep = @{ + Step = @{ + 'Attributes' = 'Attributes' + 'Roles' = 'Roles' + 'Groups' = 'Groups' + 'Apps' = 'Apps' + 'App assignments' = 'Apps' + 'Mappings' = 'Mappings' + 'Users' = 'Users' + 'User names' = 'Users' + 'User lifecycle' = 'Users' + 'Managers' = 'Users' + 'Group membership' = 'Users' + 'Role memberships' = 'Users' + 'Directory fields' = 'Users' + 'Policies' = 'Policies' + 'Group policies' = 'Policies' + 'Policy settings' = 'Policies' + 'API authorizations' = 'ApiAuthorizations' + 'API scopes' = 'ApiAuthorizations' + 'API claims' = 'ApiAuthorizations' + 'API clients' = 'ApiAuthorizations' + 'App rules' = 'AppRules' + 'Smart hooks' = 'Hooks' + 'Self-registration' = 'SelfRegistration' + } + Always = @() +} diff --git a/Providers/OneLogin/Private/Export-OneLoginAppSecret.ps1 b/Providers/OneLogin/Private/Export-OneLoginAppSecret.ps1 new file mode 100644 index 0000000..f5c5feb --- /dev/null +++ b/Providers/OneLogin/Private/Export-OneLoginAppSecret.ps1 @@ -0,0 +1,122 @@ +function Export-OneLoginAppSecret { + <# + .SYNOPSIS + Saves a seeded app's client id and secret, with the secret protected + + .DESCRIPTION + OneLogin shows an app's client secret once, in the answer to its creation; a later read of + the app returns the client id and no secret. New-OneLoginApp -SaveAppSecret hands that + answer here, and nowhere else, so the secret can be used later to test a sign-in against + the seeded app. + + Writing the record - the protected secret or the vault pointer, the UTF-8 bytes without a + byte order mark, the folder and file restricted to the current user - is + Export-TestCredentialRecord's job, the same writer the account's own API credential goes + through. This names the fields an app record carries: the account, the app's id, key and + name, and its client id. + + Remove-OneLoginEnvironment deletes the record, and the vault secret it points to, when it + deletes the app, so saved secrets do not outlive the apps they open. + + .PARAMETER Subdomain + The account the app lives in. + + .PARAMETER AppId + The app's id in OneLogin. + + .PARAMETER AppKey + The app's key in the seed data. + + .PARAMETER AppName + The app's name, with the prefix. + + .PARAMETER ClientId + The app's client id. + + .PARAMETER ClientSecret + The app's client secret. + + .PARAMETER UseSecretStore + Keep the secret in a SecretStore vault rather than in the record. + + .PARAMETER VaultName + The vault to use with -UseSecretStore. + + .PARAMETER VaultPassword + The vault's password, when it is not the module default. + + .OUTPUTS + PSCustomObject with Path, Protection, VaultName and SecretName. + + .EXAMPLE + PS> Export-OneLoginAppSecret -Subdomain contoso -AppId 42 -AppKey expenses -AppName 'ZZ-TEST-Expenses Web' -ClientId $id -ClientSecret $secret -Confirm:$false + + DESCRIPTION: Saves the app's secret DPAPI-protected + OUTPUT: Path and Protection 'DPAPI' + USE CASE: New-OneLoginApp -SaveAppSecret, as each app is created + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'ClientSecret', + Justification = 'The secret arrives as text in the API response and is protected before it touches disk.')] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^\d+$')] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$AppKey, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$AppName, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientSecret, + + [Parameter()] + [switch]$UseSecretStore, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$VaultName = 'OneLoginEnvironment', + + [Parameter()] + [System.Security.SecureString]$VaultPassword + ) + + $path = Get-OneLoginAppSecretPath -Subdomain $Subdomain -AppId $AppId + if (-not $PSCmdlet.ShouldProcess($path, "Save the client secret of OneLogin app $AppName")) { + return $null + } + + $record = [ordered]@{ + schemaVersion = 1 + subdomain = $Subdomain.ToLowerInvariant() + appId = $AppId + appKey = $AppKey + appName = $AppName + clientId = $ClientId + createdUtc = [DateTime]::UtcNow.ToString('o') + } + + Export-TestCredentialRecord -Path $path -Record $record -Secret $ClientSecret -SecretField 'clientSecretProtected' ` + -SecretName ('OneLoginEnvironment-{0}-app-{1}' -f $Subdomain.ToLowerInvariant(), $AppId) ` + -UseSecretStore:$UseSecretStore -VaultName $VaultName -VaultPassword $VaultPassword -Confirm:$false +} diff --git a/Providers/OneLogin/Private/Export-OneLoginCredential.ps1 b/Providers/OneLogin/Private/Export-OneLoginCredential.ps1 new file mode 100644 index 0000000..c1c62e2 --- /dev/null +++ b/Providers/OneLogin/Private/Export-OneLoginCredential.ps1 @@ -0,0 +1,99 @@ +function Export-OneLoginCredential { + <# + .SYNOPSIS + Writes an account's API credential record, with the client secret protected + + .DESCRIPTION + The record names the account and the credential's client id, and holds the secret. + Writing it - the protected secret or the vault pointer, the UTF-8 bytes without a byte + order mark, the folder and file restricted to the current user - is + Export-TestCredentialRecord's job. This names the fields OneLogin's record carries. + + The client id is kept in the record rather than asked for again, because -UseStoredSecret + should need nothing but the subdomain. It is not secret: it identifies the credential and + grants nothing without the secret beside it. + + .PARAMETER Path + Where to write the record. + + .PARAMETER Subdomain + The account the credential belongs to. + + .PARAMETER ClientId + The API credential's client id. + + .PARAMETER ClientSecret + The API credential's client secret. + + .PARAMETER UseSecretStore + Keep the secret in a SecretStore vault rather than in the record. + + .PARAMETER VaultName + The vault to use with -UseSecretStore. + + .PARAMETER VaultPassword + The vault's password, when it is not the module default. + + .OUTPUTS + PSCustomObject with Path, Protection, VaultName and SecretName. + + .EXAMPLE + PS> Export-OneLoginCredential -Path $path -Subdomain contoso -ClientId $id -ClientSecret $secret -Confirm:$false + + DESCRIPTION: Writes the record with the secret DPAPI-protected + OUTPUT: Path and Protection 'DPAPI' + USE CASE: Connect-OneLoginEnvironment -SaveSecret, once the connection is proved + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'ClientSecret', + Justification = 'The secret is materialised for the length of this call and protected before it touches disk.')] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientId, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$ClientSecret, + + [Parameter()] + [switch]$UseSecretStore, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$VaultName = 'OneLoginEnvironment', + + [Parameter()] + [System.Security.SecureString]$VaultPassword + ) + + if (-not $PSCmdlet.ShouldProcess($Path, 'Write the OneLogin credential record')) { + return $null + } + + $record = [ordered]@{ + schemaVersion = 1 + subdomain = $Subdomain + clientId = $ClientId + createdUtc = [DateTime]::UtcNow.ToString('o') + } + + Export-TestCredentialRecord -Path $Path -Record $record -Secret $ClientSecret -SecretField 'clientSecretProtected' ` + -SecretName ('OneLoginEnvironment-{0}' -f $Subdomain) ` + -UseSecretStore:$UseSecretStore -VaultName $VaultName -VaultPassword $VaultPassword -Confirm:$false +} diff --git a/Providers/OneLogin/Private/Get-OneLoginAppSecretPath.ps1 b/Providers/OneLogin/Private/Get-OneLoginAppSecretPath.ps1 new file mode 100644 index 0000000..04ef6de --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginAppSecretPath.ps1 @@ -0,0 +1,51 @@ +function Get-OneLoginAppSecretPath { + <# + .SYNOPSIS + Returns the file a seeded app's saved client secret is kept in + + .DESCRIPTION + One record per app, under the module's per-user credential folder beside the account's own + API credential record, named by subdomain and app id: .onelogin-app..json. + The id, not the name, because a re-created app is a different app with a different secret, + and teardown matches a record to the app it belongs to by the id alone. + + With -AppId omitted, returns the wildcard pattern that matches every app record for the + account, which is how teardown finds the records whose app no longer exists. + + .PARAMETER Subdomain + The account's subdomain. + + .PARAMETER AppId + The app's id in OneLogin. + + .OUTPUTS + System.String, the full path to the record, or the pattern for every record of the account. + + .EXAMPLE + PS> Get-OneLoginAppSecretPath -Subdomain contoso -AppId 4920377 + + DESCRIPTION: Resolves one app's record path + OUTPUT: C:\Users\you\.testenvironment\contoso.onelogin-app.4920377.json + USE CASE: New-OneLoginApp -SaveAppSecret, and teardown when it deletes that app + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain, + + [Parameter()] + [ValidatePattern('^\d+$')] + [string]$AppId + ) + + $leaf = if ($AppId) { '{0}.onelogin-app.{1}.json' -f $Subdomain.ToLowerInvariant(), $AppId } else { '{0}.onelogin-app.*.json' -f $Subdomain.ToLowerInvariant() } + return (Join-Path (Get-TestCredentialRoot) $leaf) +} diff --git a/Providers/OneLogin/Private/Get-OneLoginAppSecretRecord.ps1 b/Providers/OneLogin/Private/Get-OneLoginAppSecretRecord.ps1 new file mode 100644 index 0000000..d45509c --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginAppSecretRecord.ps1 @@ -0,0 +1,79 @@ +function Get-OneLoginAppSecretRecord { + <# + .SYNOPSIS + Lists the saved app secret records for an account, without reading any secret + + .DESCRIPTION + Reads each .onelogin-app..json record under the credential folder and returns + what it says about itself: the app's id, key and name, the client id, and where the secret + is kept. The secret is not decrypted here; Get-OneLoginAppCredential does that, for the + records somebody asks for. + + A record is returned only when its content agrees with its file name - the same account and + the same app id - so a file that was renamed or written by something else is never taken for + an app's record and never deleted as one. + + .PARAMETER Subdomain + The account whose records to list. + + .OUTPUTS + PSCustomObject per record with Path, Subdomain, AppId, AppKey, AppName, ClientId, + Protection, VaultName and SecretName. + + .EXAMPLE + PS> Get-OneLoginAppSecretRecord -Subdomain contoso + + DESCRIPTION: Lists every saved app secret for the contoso account + OUTPUT: One object per record, with no secret in it + USE CASE: Teardown, the report, and Get-OneLoginAppCredential + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain + ) + + $pattern = Get-OneLoginAppSecretPath -Subdomain $Subdomain + $folder = Split-Path -Path $pattern -Parent + if (-not (Test-Path -LiteralPath $folder)) { return } + + $account = $Subdomain.ToLowerInvariant() + foreach ($file in @(Get-ChildItem -LiteralPath $folder -Filter (Split-Path -Path $pattern -Leaf) -File -ErrorAction SilentlyContinue)) { + $match = [regex]::Match($file.Name, '^(?.+)\.onelogin-app\.(?\d+)\.json$') + if (-not $match.Success -or $match.Groups['account'].Value -ne $account) { continue } + + $record = $null + try { + $text = [System.Text.Encoding]::UTF8.GetString([System.IO.File]::ReadAllBytes($file.FullName)).TrimStart([char]0xFEFF) + $record = $text | ConvertFrom-Json + } + catch { + Write-Warning "Skipping $($file.FullName): it is not valid JSON." + continue + } + if ([string]$record.subdomain -ne $account -or [string]$record.appId -ne $match.Groups['id'].Value) { + Write-Warning "Skipping $($file.FullName): its content names a different account or app than its file name." + continue + } + + [PSCustomObject]@{ + Path = $file.FullName + Subdomain = $account + AppId = [string]$record.appId + AppKey = [string]$record.appKey + AppName = [string]$record.appName + ClientId = [string]$record.clientId + Protection = [string]$record.protection + VaultName = $(if ($record.PSObject.Properties['vaultName']) { [string]$record.vaultName } else { $null }) + SecretName = $(if ($record.PSObject.Properties['secretName']) { [string]$record.secretName } else { $null }) + } + } +} diff --git a/Providers/OneLogin/Private/Get-OneLoginConnection.ps1 b/Providers/OneLogin/Private/Get-OneLoginConnection.ps1 new file mode 100644 index 0000000..f008a65 --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginConnection.ps1 @@ -0,0 +1,42 @@ +function Get-OneLoginConnection { + <# + .SYNOPSIS + Returns the session's OneLogin connection, or explains how to make one + + .DESCRIPTION + Every function in this provider reaches the account through here rather than reading + module scope directly, so there is one message when nobody has connected and one place + that knows what a connection has to carry. + + The connection holds the account's subdomain, the API credential's client id and secret, + the token and its expiry, and the Prefix and EmailDomain everything the seed creates is + named with. Teardown keys off the prefix, so setting it once at connect time removes the + failure mode where an account is seeded under one prefix and torn down under another. + + .OUTPUTS + System.Collections.Hashtable, the live connection. + + .EXAMPLE + PS> $connection = Get-OneLoginConnection + + DESCRIPTION: Fetches the connection every other function works through + OUTPUT: The connection hashtable + USE CASE: Called at the top of every function that reaches OneLogin + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([hashtable])] + param() + + if (-not $script:OneLoginConnection) { + throw ('Not connected to OneLogin. Run Connect-TestEnvironment -Provider OneLogin -Subdomain ' + + '-ClientId -ClientSecret first, or -UseStoredCredential once a secret has been saved.') + } + + return $script:OneLoginConnection +} diff --git a/Providers/OneLogin/Private/Get-OneLoginCredentialPath.ps1 b/Providers/OneLogin/Private/Get-OneLoginCredentialPath.ps1 new file mode 100644 index 0000000..af03b03 --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginCredentialPath.ps1 @@ -0,0 +1,42 @@ +function Get-OneLoginCredentialPath { + <# + .SYNOPSIS + Returns the file an account's API credential record is kept in + + .DESCRIPTION + The record lives under the module's per-user credential folder, ~/.testenvironment, which + Get-TestCredentialRoot creates and restricts to the current user. A path inside the module + folder would sit in a working tree, one .gitignore mistake away from being pushed. + + One record per account, named by subdomain, so several accounts can be used from one + machine without overwriting each other. + + .PARAMETER Subdomain + The account's subdomain, the name in https://.onelogin.com. + + .OUTPUTS + System.String, the full path to the record. + + .EXAMPLE + PS> Get-OneLoginCredentialPath -Subdomain contoso + + DESCRIPTION: Resolves the record path, creating the folder if needed + OUTPUT: C:\Users\you\.testenvironment\contoso.onelogin.json + USE CASE: Called by Connect-OneLoginEnvironment for -SaveSecret and -UseStoredSecret + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain + ) + + return (Join-Path (Get-TestCredentialRoot) ('{0}.onelogin.json' -f $Subdomain.ToLowerInvariant())) +} diff --git a/Providers/OneLogin/Private/Get-OneLoginDataPath.ps1 b/Providers/OneLogin/Private/Get-OneLoginDataPath.ps1 new file mode 100644 index 0000000..e9f8a2e --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginDataPath.ps1 @@ -0,0 +1,41 @@ +function Get-OneLoginDataPath { + <# + .SYNOPSIS + Returns the folder holding this provider's seed data + + .DESCRIPTION + The data sits beside the provider's code rather than at the module root, which is the + whole point of the provider folders: a OneLogin CSV is shared with nothing. + + The path comes from the provider table the root module builds at import rather than + from $PSScriptRoot, so it still resolves when the module is loaded from a staged copy + under out/ or from an installed location in the Gallery's folder layout. + + .OUTPUTS + System.String, the full path to the Data folder. + + .EXAMPLE + PS> Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginUsers.csv') -Encoding UTF8 + + DESCRIPTION: Reads the seeded people + OUTPUT: The rows + USE CASE: Called by every step that reads its definitions from disk + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([string])] + param() + + $dataPath = $script:TestEnvironmentProvider['OneLogin'].DataPath + + if (-not (Test-Path -LiteralPath $dataPath)) { + throw "The OneLogin provider's Data folder is missing from $dataPath. The module is incomplete." + } + + return $dataPath +} diff --git a/Providers/OneLogin/Private/Get-OneLoginErrorDetail.ps1 b/Providers/OneLogin/Private/Get-OneLoginErrorDetail.ps1 new file mode 100644 index 0000000..174872e --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginErrorDetail.ps1 @@ -0,0 +1,118 @@ +function Get-OneLoginErrorDetail { + <# + .SYNOPSIS + Pulls the status, error name and message out of a failed OneLogin response + + .DESCRIPTION + OneLogin answers a failure in one of three shapes, depending on which generation of its + API served the request, and all three were seen against a live account: + + {"name":"UnprocessableEntityError","message":"Validation failed: ...","statusCode":422} + {"status":404,"error":"NotFoundError","description":"Resource not found"} + {"status":{"error":true,"code":400,"type":"bad request","message":"..."}} + + The first is what the users and custom attribute endpoints send, the second what roles + send, and the third the version 1 API. A malformed request can also be answered with an + HTML error page rather than JSON, which is reduced to its status. This turns every shape + into one line. + + The body is in ErrorDetails.Message on both editions: Invoke-TestWebRequest reads a failed + response once, whichever way the running PowerShell hands it over, and puts it there. + + .PARAMETER ErrorRecord + The error record from the failed call. + + .OUTPUTS + PSCustomObject with Status, Code, Message, RetryAfterSeconds and Summary. Summary is the + one-line form worth putting in an exception message. + + .EXAMPLE + PS> try { Invoke-TestWebRequest ... } catch { Get-OneLoginErrorDetail -ErrorRecord $_ } + + DESCRIPTION: Turns a failure into something worth printing + OUTPUT: Status 422, Code UnprocessableEntityError, and a summary naming both + USE CASE: Called by Invoke-OneLoginRequest on every failure + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [System.Management.Automation.ErrorRecord]$ErrorRecord + ) + + $status = 0 + $headers = @{} + if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { + try { $status = [int]$ErrorRecord.Exception.Response.StatusCode } catch { $status = 0 } + if ($ErrorRecord.Exception.Response.PSObject.Properties['Headers'] -and + $ErrorRecord.Exception.Response.Headers -is [System.Collections.IDictionary]) { + $headers = $ErrorRecord.Exception.Response.Headers + } + } + + $raw = $null + if ($ErrorRecord.ErrorDetails) { $raw = $ErrorRecord.ErrorDetails.Message } + + $code = $null + $message = $null + + if (-not [string]::IsNullOrWhiteSpace($raw)) { + if ($raw.TrimStart().StartsWith('<')) { + # An HTML error page. Nothing in it is worth quoting; the status says as much. + $message = 'OneLogin answered with an HTML error page rather than JSON' + } + else { + try { + $parsed = $raw | ConvertFrom-Json + if ($parsed.PSObject.Properties['status'] -and $parsed.status -is [PSCustomObject]) { + $code = [string]$parsed.status.type + $message = [string]$parsed.status.message + if (-not $status -and $parsed.status.code) { $status = [int]$parsed.status.code } + } + else { + if ($parsed.PSObject.Properties['name']) { $code = [string]$parsed.name } + elseif ($parsed.PSObject.Properties['error']) { $code = [string]$parsed.error } + if ($parsed.PSObject.Properties['message']) { $message = [string]$parsed.message } + elseif ($parsed.PSObject.Properties['description']) { $message = [string]$parsed.description } + if (-not $status) { + if ($parsed.PSObject.Properties['statusCode']) { $status = [int]$parsed.statusCode } + elseif ($parsed.PSObject.Properties['status'] -and $parsed.status -is [ValueType]) { $status = [int]$parsed.status } + } + } + } + catch { + Write-Verbose 'The error body was not JSON; using it as the message.' + $message = ($raw -replace '\s+', ' ') + } + } + } + + if (-not $message) { $message = $ErrorRecord.Exception.Message } + + # How long a 429 asks to be left alone. Retry-After is the standard header; OneLogin also + # reports the seconds until its hourly window resets. + $retryAfter = $null + foreach ($name in 'Retry-After', 'X-RateLimit-Reset') { + $value = 0 + if ($headers.ContainsKey($name) -and [int]::TryParse([string]$headers[$name], [ref]$value)) { + $retryAfter = $value + break + } + } + + $summary = if ($code) { '{0}: {1}' -f $code, $message } else { $message } + + return [PSCustomObject]@{ + Status = $status + Code = $code + Message = $message + RetryAfterSeconds = $retryAfter + Summary = $summary + } +} diff --git a/Providers/OneLogin/Private/Get-OneLoginIdentitySnapshot.ps1 b/Providers/OneLogin/Private/Get-OneLoginIdentitySnapshot.ps1 new file mode 100644 index 0000000..7a3f8ca --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginIdentitySnapshot.ps1 @@ -0,0 +1,35 @@ +function Get-OneLoginIdentitySnapshot { + <# + .SYNOPSIS + Reads the seeded users of the account as identities Compare-TestEnvironment can match + .DESCRIPTION + The users teardown would find. The key is the username with the seed prefix stripped, so + zz-test-jnino is jnino, the shared login. OneLogin keeps a first and a last name and no + display name, so the display name is left empty and the comparison falls back to the parts + rather than composing a name the account never stored: a composed one would put the family + name last for a person whose name puts it first. + + Enabled means the account can be used: approved, and neither suspended nor never + activated. OneLogin keeps those as two numbers, a state and a status, and a person who is + rejected or suspended is as disabled as one switched off anywhere else. + .OUTPUTS + PSCustomObject with Provider, Target and Identities + .EXAMPLE + PS> (Get-OneLoginIdentitySnapshot).Identities.Count + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param() + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + $unusable = @($script:OneLoginUserStatus['Suspended'], $script:OneLoginUserStatus['Unactivated']) + $identities = foreach ($user in @(Get-OneLoginSeededObject -Type Users -Connection $connection)) { + $login = [string]$user.username + $key = $login + if ($key.StartsWith($marker.Prefix, [StringComparison]::OrdinalIgnoreCase)) { $key = $key.Substring($marker.Prefix.Length) } + $enabled = ([int]$user.state -eq $script:OneLoginUserState['Approved']) -and ($unusable -notcontains [int]$user.status) + New-TestIdentity -Provider 'OneLogin' -Login $login -Key $key -GivenName ([string]$user.firstname) -Surname ([string]$user.lastname) -Enabled $enabled + } + [PSCustomObject]@{ Provider = 'OneLogin'; Target = $connection.Subdomain; Identities = @($identities) } +} diff --git a/Providers/OneLogin/Private/Get-OneLoginSeedMarker.ps1 b/Providers/OneLogin/Private/Get-OneLoginSeedMarker.ps1 new file mode 100644 index 0000000..15aedfd --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginSeedMarker.ps1 @@ -0,0 +1,60 @@ +function Get-OneLoginSeedMarker { + <# + .SYNOPSIS + Returns the prefix and tag this provider stamps on everything it creates + + .DESCRIPTION + A thin wrapper over Core's marker, so that every function in this provider asks one + question rather than reading the connection and reaching into Core itself. + + Where the tag is stored differs by object type, because OneLogin gives them different + fields to store it in: + + - An app has a description, which is free text nobody else writes. The tag goes there, + inside the sentence Core provides, so an administrator who finds a seeded app in a + production portal is told what made it and that it is safe to delete. + - A user has none, so a seeded user carries the tag in a custom field the seed creates. + - A role, a group and a mapping have nothing but a name. They are proved by what they + hold instead - see Get-OneLoginSeededObject. + + The value never differs between object types. It is the module-wide tag from Core, so + every seeded object in the account can be found by one string. + + .PARAMETER Prefix + The prefix, bare or with a trailing separator. Defaults to the connected prefix, and + then to the module default. + + .OUTPUTS + PSCustomObject with Prefix, Tag, Description and DescriptionPattern. + + .EXAMPLE + PS> Get-OneLoginSeedMarker + + DESCRIPTION: Returns the marker for the connected account + OUTPUT: Prefix ZZ-TEST- and Tag ZZ-TEST-seed + USE CASE: Called by every function that names or claims an object + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string]$Prefix + ) + + if (-not $Prefix) { + if ($script:OneLoginConnection) { $Prefix = $script:OneLoginConnection.Prefix } + else { $Prefix = $script:TestEnvironmentDefaultPrefix } + } + + # Core validates the separator form. The connection stores the prefix exactly as it was + # given, which may be bare, so the separator is put back before asking. + $separatorPrefix = if ($Prefix -match '[-_]$') { $Prefix } else { '{0}-' -f $Prefix } + + return Get-TestSeedMarker -Prefix $separatorPrefix +} diff --git a/Providers/OneLogin/Private/Get-OneLoginSeedScope.ps1 b/Providers/OneLogin/Private/Get-OneLoginSeedScope.ps1 new file mode 100644 index 0000000..aa3d3b4 --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginSeedScope.ps1 @@ -0,0 +1,62 @@ +function Get-OneLoginSeedScope { + <# + .SYNOPSIS + Returns the roles and groups the seeded people of the chosen tiers will hold + + .DESCRIPTION + A seeded role or group has nothing but its name to say who made it, so teardown proves it + by what it holds: every member a seeded person, and at least one of them. An empty role + cannot be proved, and teardown leaves it alone - which is right for somebody else's empty + role and wrong for one this module made and then never filled. + + So the seed never makes a role or group that none of the people it is about to seed will + hold. With every tier that is all of them, because the data gives every role and group a + Core member and a test holds it to that. With -Tier Bulk alone it is fewer: Finance, + Leadership and Partners have only Core members, and seeding them empty would leave three + objects behind that no teardown could claim. + + The roles, groups, apps and mappings steps all ask this, so the four agree about which + roles exist without passing anything between them. + + .PARAMETER Tier + The tiers of people being seeded. Both when omitted. + + .OUTPUTS + PSCustomObject with Roles and Groups, each a set of row keys. + + .EXAMPLE + PS> (Get-OneLoginSeedScope -Tier Bulk).Roles.Contains('finance') + + DESCRIPTION: Asks whether a Bulk-only seed would give Finance a member + OUTPUT: False + USE CASE: New-OneLoginRole skipping a role nobody in the tier holds + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier + ) + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginUsers.csv') -Encoding UTF8) + if ($Tier) { $rows = @($rows | Where-Object { $Tier -contains $_.Tier }) } + + $roles = New-Object 'System.Collections.Generic.HashSet[string]' + $groups = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($row in $rows) { + foreach ($role in @(([string]$row.Roles -split ';') | Where-Object { $_ })) { $null = $roles.Add($role) } + if ($row.Group) { $null = $groups.Add([string]$row.Group) } + } + + return [PSCustomObject]@{ + Roles = $roles + Groups = $groups + } +} diff --git a/Providers/OneLogin/Private/Get-OneLoginSeededObject.ps1 b/Providers/OneLogin/Private/Get-OneLoginSeededObject.ps1 new file mode 100644 index 0000000..fd7deef --- /dev/null +++ b/Providers/OneLogin/Private/Get-OneLoginSeededObject.ps1 @@ -0,0 +1,463 @@ +function Get-OneLoginSeededObject { + <# + .SYNOPSIS + Returns the objects of one type that this module can prove it created + + .DESCRIPTION + Teardown's only source of truth, and the verifier's and the report's. Nothing is deleted + for merely matching a name, and this is where that promise is kept - which matters more + here than anywhere, because a OneLogin account is as likely to be somebody's production + directory as a lab. + + Proof differs by type, because OneLogin gives each type a different place to hold it: + + - A user needs the seed tag in the custom field the seed created AND the prefix on the + username. The account is asked server-side for users whose field holds the tag, and + each is then checked here, ordinally, so a server that matched loosely still cannot + hand teardown somebody else. The field has to exist first: before the first seed and + after teardown there is nothing to ask. + - An app and an API authorization server need the tag in their description AND the + prefix on their name; a self-registration profile the tag in its help text and the + prefix on its name. Either alone is not proof: the tag can be pasted into a real + object's description, and the prefix alone is the name matching this module refuses to do. + - A role has nothing but a name, so it is proved by what it holds. It needs the prefix, no + administrators, at least one user or app, and every user it holds must be a proved + seeded user and every app a proved seeded app. A prefixed role holding one real person + is refused. So is an empty one, because nothing about it says who made it. + - A group is proved the same way by its members: the prefix, no administrators, at least + one member, every member a proved seeded user, and either no security policy or a + prefixed one that is not the account's default. + - A user security policy has no description either, so it is proved by the groups that use + it: the prefix, not the default, and used by at least one group and by proved seeded + groups alone. + - A mapping needs the prefix, match 'all', the seed-tag condition the seed always writes - + so it can only ever have acted on seeded people - and only add_role actions, each naming + proved roles. + - An app rule needs to be on a proved seeded app, carry the prefix, and name only proved + seeded roles in its conditions. + - A Smart Hook has no name at all. It is proved by the marker line the seed writes at the top + of its code, and by conditions that name only proved seeded roles - at least one of them, + so even an enabled hook could never have run for anybody else. + - A custom field needs to be declared by this provider's data AND carry the attribute + prefix in its shortname. + + Roles, groups, policies, mappings, hooks and app rules depend on what they hold or name, so + teardown proves them while those still exist. A mapping, hook or app rule naming a role that + no longer exists at all is still accepted: a deleted role grants nothing to anybody, and + refusing it would strand whatever a teardown that stopped halfway left behind. A role that + exists and is somebody else's still disqualifies it. Role membership on OneLogin also + settles a few seconds after it is written; a role proved in the same breath as the seed that + filled it may briefly read as empty, and is then refused rather than guessed at. + + -Unproven returns the other side: objects carrying the prefix (or, for a hook, the marker) + that failed their proof, with the reason, so teardown can say what it left alone instead of + staying silent. + + -AllowEmpty is the seed's view rather than teardown's. A role, group or policy the seed made + and has not filled or attached yet is empty, and the seed has to be able to reuse it; one + that holds somebody else's people or has administrators it must not touch. So under + -AllowEmpty an empty one counts, and everything else about the proof still applies. + Teardown never passes it. + + .PARAMETER Type + Which kind of object to return. + + .PARAMETER Unproven + Return the prefixed objects of the type that could not be proved, with the reason, rather + than the proved ones. Not for users and custom fields, which without their proof are simply + not ours. + + .PARAMETER AllowEmpty + Count an empty role, group or policy as the module's, for the seed steps that fill them. + Never used by teardown. + + .PARAMETER OwnedUserId + The ids of the proved seeded users, when the caller already has them. Proved here when + omitted. + + .PARAMETER OwnedAppId + The ids of the proved seeded apps, likewise. + + .PARAMETER OwnedRoleId + The ids of the proved seeded roles, likewise. + + .PARAMETER OwnedGroupId + The ids of the proved seeded groups, likewise. + + .PARAMETER Connection + The connection to use. Defaults to the session's. + + .OUTPUTS + The OneLogin objects this module owns, of the requested type. Under -Unproven, + PSCustomObjects with Type, Id, Name and Reason. + + .EXAMPLE + PS> Get-OneLoginSeededObject -Type Roles + + DESCRIPTION: Lists the seeded roles, proved by what they hold + OUTPUT: Role objects + USE CASE: Called by Remove-OneLoginEnvironment and Get-OneLoginEnvironmentReport + + .EXAMPLE + PS> Get-OneLoginSeededObject -Type Hooks -Unproven + + DESCRIPTION: Lists hooks carrying the seed marker that this module will not touch, and why + OUTPUT: Type, Id, Name and Reason for each + USE CASE: Teardown's summary of what it left alone + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Attributes', 'Users', 'Apps', 'Roles', 'Groups', 'Policies', 'Mappings', 'AppRules', 'Hooks', 'ApiAuthorizations', 'SelfRegistration')] + [string]$Type, + + [Parameter()] + [switch]$Unproven, + + [Parameter()] + [switch]$AllowEmpty, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$OwnedUserId, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$OwnedAppId, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$OwnedRoleId, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$OwnedGroupId, + + [Parameter()] + [hashtable]$Connection + ) + + if (-not $Connection) { $Connection = Get-OneLoginConnection } + + $marker = Get-OneLoginSeedMarker -Prefix $Connection.Prefix + $tag = $marker.Tag + $prefix = $marker.Prefix + $attribute = $script:OneLoginSeedAttribute + + $hasPrefix = { param($name) ([string]$name).StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } + $idSet = { + param([string[]]$Ids) + $set = New-Object 'System.Collections.Generic.HashSet[string]' + foreach ($id in @($Ids)) { if ($id) { $null = $set.Add([string]$id) } } + return , $set + } + $refuse = { + param($object, [string]$reason, [string]$name) + if (-not $name) { $name = [string]$object.name } + [PSCustomObject]@{ Type = $Type; Id = [string]$object.id; Name = $name; Reason = $reason } + } + # Every list endpoint that does not page answers a bare array, which Windows PowerShell's + # ConvertFrom-Json hands back as one object; ForEach-Object unrolls it on both editions. + $list = { + param([string]$Path, [hashtable]$Query) + $arguments = @{ Method = 'GET'; Path = $Path; Connection = $Connection } + if ($Query) { $arguments['Query'] = $Query } + @(Invoke-OneLoginRequest @arguments | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + } + + if ($Unproven -and $Type -in 'Attributes', 'Users') { + throw "-Unproven applies to every type but Users and Attributes. A user or field without its proof is simply not ours." + } + + # The owned sets the relational types are proved against, computed here when not supplied. + $needUsers = $Type -in 'Roles', 'Groups', 'Policies' + $needApps = $Type -in 'Roles', 'AppRules' + $needRoles = $Type -in 'Mappings', 'AppRules', 'Hooks' + $needGroups = $Type -eq 'Policies' + if ($needUsers -and -not $PSBoundParameters.ContainsKey('OwnedUserId')) { + $OwnedUserId = @(Get-OneLoginSeededObject -Type Users -Connection $Connection | ForEach-Object { [string]$_.id }) + } + if ($needApps -and -not $PSBoundParameters.ContainsKey('OwnedAppId')) { + $OwnedAppId = @(Get-OneLoginSeededObject -Type Apps -Connection $Connection | ForEach-Object { [string]$_.id }) + } + if ($needRoles -and -not $PSBoundParameters.ContainsKey('OwnedRoleId')) { + $OwnedRoleId = @(Get-OneLoginSeededObject -Type Roles -Connection $Connection | ForEach-Object { [string]$_.id }) + } + if ($needGroups -and -not $PSBoundParameters.ContainsKey('OwnedGroupId')) { + $OwnedGroupId = @(Get-OneLoginSeededObject -Type Groups -OwnedUserId $OwnedUserId -Connection $Connection | ForEach-Object { [string]$_.id }) + } + $ownedUsers = & $idSet $OwnedUserId + $ownedApps = & $idSet $OwnedAppId + $ownedRoles = & $idSet $OwnedRoleId + $ownedGroups = & $idSet $OwnedGroupId + + # A role a mapping, hook or rule names is acceptable when it is seeded, or when it no longer exists + # in the account at all. Only a role that exists and is not seeded disqualifies. + $acceptableRole = { $true } + if ($needRoles) { + $existingRoles = & $idSet @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $Connection | + Where-Object { $null -ne $_ } | ForEach-Object { [string]$_.id }) + $acceptableRole = { param([string]$Id) $ownedRoles.Contains($Id) -or -not $existingRoles.Contains($Id) }.GetNewClosure() + } + + switch ($Type) { + 'Attributes' { + $declared = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginCustomAttributes.csv') -Encoding UTF8 | + ForEach-Object { [string]$_.Shortname }) + return @(& $list 'users/custom_attributes' | Where-Object { + $_.id -and $declared -ccontains [string]$_.shortname -and + ([string]$_.shortname).StartsWith($script:OneLoginAttributePrefix, [StringComparison]::Ordinal) + }) + } + + 'Users' { + # The field first. Without it no user can carry the tag, and asking the users endpoint + # to filter on a field that does not exist is a question with no good answer. + if (-not @(& $list 'users/custom_attributes' | Where-Object { [string]$_.shortname -ceq $attribute })) { return @() } + + # The fields are named: the listing leaves custom_attributes out unless asked, and the + # check below would then prove nobody. + $query = @{ ('custom_attributes.{0}' -f $attribute) = $tag; fields = $script:OneLoginUserFields } + return @(Invoke-OneLoginRequest -Method GET -Path 'users' -Query $query -Paginate -Connection $Connection | + Where-Object { + $null -ne $_ -and $_.id -and + (& $hasPrefix $_.username) -and + $_.custom_attributes -and + [string]::Equals([string]$_.custom_attributes.$attribute, $tag, [StringComparison]::Ordinal) + }) + } + + { $_ -in 'Apps', 'ApiAuthorizations' } { + $path = if ($Type -eq 'Apps') { 'apps' } else { 'api_authorizations' } + $candidates = @(Invoke-OneLoginRequest -Method GET -Path $path -Paginate -Connection $Connection | + Where-Object { $null -ne $_ -and $_.id -and (& $hasPrefix $_.name) }) + $proved = { param($object) ([string]$object.description).Contains($tag) } + if ($Unproven) { + return @($candidates | Where-Object { -not (& $proved $_) } | + ForEach-Object { & $refuse $_ 'The name carries the prefix but the description does not carry the seed tag' }) + } + return @($candidates | Where-Object { & $proved $_ }) + } + + 'SelfRegistration' { + $result = New-Object System.Collections.Generic.List[object] + $listed = Invoke-OneLoginRequest -Method GET -Path 'self_registration_profiles' -Connection $Connection + foreach ($summary in @($listed.self_registration_profiles | Where-Object { $null -ne $_ })) { + if (-not $summary.id -or -not (& $hasPrefix $summary.name)) { continue } + # The listing may leave the help text out; the profile itself carries it. + $detail = Invoke-OneLoginRequest -Method GET -Path "self_registration_profiles/$($summary.id)" -IgnoreStatus 404 -Connection $Connection + $registration = if ($detail -and $detail.self_registration_profile) { $detail.self_registration_profile } else { $summary } + $reason = $null + if (-not ([string]$registration.helptext).Contains($tag)) { $reason = 'The name carries the prefix but the help text does not carry the seed tag' } + if ($Unproven) { if ($reason) { $result.Add((& $refuse $registration $reason)) } } + elseif (-not $reason) { $result.Add($registration) } + } + return $result.ToArray() + } + + 'Roles' { + $result = New-Object System.Collections.Generic.List[object] + foreach ($role in @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $Connection)) { + if ($null -eq $role -or -not $role.id -or -not (& $hasPrefix $role.name)) { continue } + + $users = @($role.users | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + $apps = @($role.apps | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + $admins = @($role.admins | Where-Object { $null -ne $_ }) + + $reason = $null + if ($admins.Count -gt 0) { $reason = 'It has administrators, and the seed never gives a role one' } + elseif ($users.Count + $apps.Count -eq 0) { + if (-not $AllowEmpty) { + $reason = 'It holds no users and no apps, so nothing about it says who made it. If it was seeded moments ago, ' + + 'OneLogin may not be showing its members yet; run the teardown again in a minute' + } + } + elseif (@($users | Where-Object { -not $ownedUsers.Contains($_) }).Count -gt 0) { + $reason = 'It holds {0} user(s) that are not seeded' -f @($users | Where-Object { -not $ownedUsers.Contains($_) }).Count + } + elseif (@($apps | Where-Object { -not $ownedApps.Contains($_) }).Count -gt 0) { + $reason = 'It holds {0} app(s) that are not seeded' -f @($apps | Where-Object { -not $ownedApps.Contains($_) }).Count + } + + if ($Unproven) { if ($reason) { $result.Add((& $refuse $role $reason)) } } + elseif (-not $reason) { $result.Add($role) } + } + return $result.ToArray() + } + + 'Groups' { + # The account's policies, so a group's policy can be judged by name and default flag. + $policyById = @{} + foreach ($policy in (& $list 'policies')) { $policyById[[string]$policy.id] = $policy } + + $result = New-Object System.Collections.Generic.List[object] + foreach ($group in @(Invoke-OneLoginRequest -Method GET -Path 'groups' -Paginate -Connection $Connection)) { + if ($null -eq $group -or -not $group.id -or -not (& $hasPrefix $group.name)) { continue } + + # The group itself, because the listing leaves its administrators out. + $detail = Invoke-OneLoginRequest -Method GET -Path "groups/$($group.id)" -IgnoreStatus 404 -Connection $Connection + if ($null -eq $detail) { continue } + # Every member, asked of the users endpoint rather than read from the group, which + # is the one listing that is guaranteed to include people this module did not make. + $members = @(Invoke-OneLoginRequest -Method GET -Path 'users' -Query @{ group_id = [string]$group.id; fields = 'id' } ` + -Paginate -Connection $Connection | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_.id }) + $admins = @($detail.admins | Where-Object { $null -ne $_ }) + $policy = if ($detail.policy_id) { $policyById[[string]$detail.policy_id] } else { $null } + + $reason = $null + if ($admins.Count -gt 0) { $reason = 'It has administrators, and the seed never gives a group one' } + elseif ($detail.policy_id -and (-not $policy -or $policy.is_default -or -not (& $hasPrefix $policy.name))) { + $reason = 'It carries a security policy the seed did not make' + } + elseif ($members.Count -eq 0) { + if (-not $AllowEmpty) { $reason = 'It has no members, so nothing about it says who made it' } + } + elseif (@($members | Where-Object { -not $ownedUsers.Contains($_) }).Count -gt 0) { + $reason = 'It holds {0} user(s) that are not seeded' -f @($members | Where-Object { -not $ownedUsers.Contains($_) }).Count + } + + if ($Unproven) { if ($reason) { $result.Add((& $refuse $group $reason)) } } + elseif (-not $reason) { + $detail | Add-Member -NotePropertyName MemberIds -NotePropertyValue $members -Force + $result.Add($detail) + } + } + return $result.ToArray() + } + + 'Policies' { + # Which groups use each policy. A policy is proved by them, so every group in the + # account is read, not only the seeded ones. + $usedBy = @{} + foreach ($group in @(Invoke-OneLoginRequest -Method GET -Path 'groups' -Paginate -Connection $Connection)) { + if ($null -eq $group -or -not $group.policy_id) { continue } + $key = [string]$group.policy_id + if (-not $usedBy.ContainsKey($key)) { $usedBy[$key] = New-Object System.Collections.Generic.List[string] } + $usedBy[$key].Add([string]$group.id) + } + + $result = New-Object System.Collections.Generic.List[object] + foreach ($policy in (& $list 'policies')) { + if (-not $policy.id -or -not (& $hasPrefix $policy.name)) { continue } + $groups = @() + if ($usedBy.ContainsKey([string]$policy.id)) { $groups = @($usedBy[[string]$policy.id]) } + + $reason = $null + if ($policy.is_default) { $reason = 'It is the account''s default policy' } + elseif ([string]$policy.kind -and [string]$policy.kind -ne 'user') { $reason = "It is a $($policy.kind) policy, and the seed makes user policies only" } + elseif ($groups.Count -eq 0) { + if (-not $AllowEmpty) { $reason = 'No group uses it, so nothing about it says who made it' } + } + elseif (@($groups | Where-Object { -not $ownedGroups.Contains($_) }).Count -gt 0) { + $reason = 'It is used by {0} group(s) that are not seeded' -f @($groups | Where-Object { -not $ownedGroups.Contains($_) }).Count + } + + if ($Unproven) { if ($reason) { $result.Add((& $refuse $policy $reason)) } } + elseif (-not $reason) { + $policy | Add-Member -NotePropertyName GroupIds -NotePropertyValue $groups -Force + $result.Add($policy) + } + } + return $result.ToArray() + } + + 'Mappings' { + # Enabled and disabled mappings are two listings; the endpoint returns only the enabled + # ones unless asked, verified live. + # Each wrapped before they are joined: a listing of one comes back as the object itself, + # and one object plus another is not an array. + $all = @(@(& $list 'mappings') + @(& $list 'mappings' @{ enabled = 'false' })) + $seen = @{} + $result = New-Object System.Collections.Generic.List[object] + $source = 'custom_attribute_{0}' -f $attribute + foreach ($mapping in $all) { + if (-not $mapping.id -or $seen.ContainsKey([string]$mapping.id)) { continue } + $seen[[string]$mapping.id] = $true + if (-not (& $hasPrefix $mapping.name)) { continue } + + $conditions = @($mapping.conditions | Where-Object { $null -ne $_ }) + $actions = @($mapping.actions | Where-Object { $null -ne $_ }) + $gated = @($conditions | Where-Object { + [string]$_.source -ceq $source -and [string]$_.operator -eq '=' -and + [string]::Equals([string]$_.value, $tag, [StringComparison]::Ordinal) + }).Count -gt 0 + $roleIds = @($actions | ForEach-Object { @($_.value) } | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + + $reason = $null + if ([string]$mapping.match -ne 'all') { $reason = "It matches '$($mapping.match)' rather than all of its conditions" } + elseif (-not $gated) { $reason = 'It has no condition requiring the seed tag, so it could act on anybody' } + elseif ($actions.Count -eq 0) { $reason = 'It has no actions' } + elseif (@($actions | Where-Object { [string]$_.action -ne 'add_role' }).Count -gt 0) { $reason = 'It does something other than add a role' } + elseif (@($roleIds | Where-Object { -not (& $acceptableRole $_) }).Count -gt 0) { $reason = 'It adds a role that is not seeded' } + + if ($Unproven) { if ($reason) { $result.Add((& $refuse $mapping $reason)) } } + elseif (-not $reason) { $result.Add($mapping) } + } + return $result.ToArray() + } + + 'AppRules' { + # Only on apps this module proves: a rule on somebody else's app is somebody else's, + # whatever it is called. Enabled and disabled rules are two listings, as with mappings. + $result = New-Object System.Collections.Generic.List[object] + foreach ($appId in @($ownedApps)) { + $seen = @{} + foreach ($rule in @(@(& $list "apps/$appId/rules") + @(& $list "apps/$appId/rules" @{ enabled = 'false' }))) { + if (-not $rule.id -or $seen.ContainsKey([string]$rule.id)) { continue } + $seen[[string]$rule.id] = $true + if (-not (& $hasPrefix $rule.name)) { continue } + + $conditions = @($rule.conditions | Where-Object { $null -ne $_ }) + $foreign = @($conditions | Where-Object { [string]$_.source -ne 'has_role' -or -not (& $acceptableRole ([string]$_.value)) }) + + $reason = $null + if ($conditions.Count -eq 0) { $reason = 'It has no conditions, so it applies to everybody the app serves' } + elseif ($foreign.Count -gt 0) { $reason = 'It has a condition that is not a seeded role' } + + $rule | Add-Member -NotePropertyName AppId -NotePropertyValue $appId -Force + if ($Unproven) { if ($reason) { $result.Add((& $refuse $rule $reason)) } } + elseif (-not $reason) { $result.Add($rule) } + } + } + return $result.ToArray() + } + + 'Hooks' { + $line = $script:OneLoginHookMarker -f $marker.Description + $result = New-Object System.Collections.Generic.List[object] + foreach ($summary in (& $list 'hooks')) { + if (-not $summary.id) { continue } + # The hook itself, because the listing leaves its code out, and the code is where the + # marker is. + $hook = Invoke-OneLoginRequest -Method GET -Path "hooks/$($summary.id)" -IgnoreStatus 404 -Connection $Connection + if ($null -eq $hook) { continue } + $code = '' + try { $code = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([string]$hook.function)) } + catch { Write-Verbose "Hook $($hook.id) has code that is not base64; it is not the seed's" } + # The marker is the only thing a hook can carry, so a hook without it is not a + # candidate at all, and is never named as one left alone. + if (-not $code.StartsWith($line, [StringComparison]::Ordinal)) { continue } + + $conditions = @($hook.conditions | Where-Object { $null -ne $_ }) + $name = '{0} hook {1}' -f $hook.type, $hook.id + $reason = $null + if ($conditions.Count -eq 0) { $reason = 'It carries the seed marker but no conditions, so it could run for anybody' } + elseif (@($conditions | Where-Object { [string]$_.source -ne 'roles' -or -not (& $acceptableRole ([string]$_.value)) }).Count -gt 0) { + $reason = 'It carries the seed marker but a condition that is not a seeded role' + } + + $hook | Add-Member -NotePropertyName name -NotePropertyValue $name -Force + if ($Unproven) { if ($reason) { $result.Add((& $refuse $hook $reason $name)) } } + elseif (-not $reason) { $result.Add($hook) } + } + return $result.ToArray() + } + } +} diff --git a/Providers/OneLogin/Private/Import-OneLoginCredential.ps1 b/Providers/OneLogin/Private/Import-OneLoginCredential.ps1 new file mode 100644 index 0000000..65ecbba --- /dev/null +++ b/Providers/OneLogin/Private/Import-OneLoginCredential.ps1 @@ -0,0 +1,58 @@ +function Import-OneLoginCredential { + <# + .SYNOPSIS + Reads an account's API credential record and recovers its client secret + + .DESCRIPTION + The inverse of Export-OneLoginCredential. Import-TestCredentialRecord reads the record, + checks the fields OneLogin's record has to carry, and follows the record to wherever the + secret is; this shapes the result for the connect command. + + The secret comes back as a SecureString, because that is how the connection holds it and + a plaintext copy would outlive the call that needed it. + + .PARAMETER Path + The record to read. + + .PARAMETER VaultPassword + The vault's password, when the secret is in a vault whose password is not a default. + + .OUTPUTS + PSCustomObject with Subdomain, ClientId, ClientSecret, Protection and Path. + + .EXAMPLE + PS> $credential = Import-OneLoginCredential -Path (Get-OneLoginCredentialPath -Subdomain contoso) + + DESCRIPTION: Reads the record and recovers the secret + OUTPUT: The credential with the secret as a SecureString + USE CASE: Connect-OneLoginEnvironment -UseStoredSecret + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter()] + [System.Security.SecureString]$VaultPassword + ) + + $read = Import-TestCredentialRecord -Path $Path -Required subdomain, clientId -SecretField 'clientSecretProtected' ` + -SecretLabel 'client secret' -MissingRecordMessage 'Connect once with -ClientId, -ClientSecret and -SaveSecret first.' ` + -VaultPassword $VaultPassword + + return [PSCustomObject]@{ + Subdomain = [string]$read.Record.subdomain + ClientId = [string]$read.Record.clientId + ClientSecret = ConvertTo-TestSecureString -PlainText $read.Secret + Protection = $read.Protection + Path = $Path + } +} diff --git a/Providers/OneLogin/Private/Invoke-OneLoginRequest.ps1 b/Providers/OneLogin/Private/Invoke-OneLoginRequest.ps1 new file mode 100644 index 0000000..c18d959 --- /dev/null +++ b/Providers/OneLogin/Private/Invoke-OneLoginRequest.ps1 @@ -0,0 +1,223 @@ +function Invoke-OneLoginRequest { + <# + .SYNOPSIS + Calls one OneLogin API method, with pagination, token renewal, rate-limit backoff and a readable error + + .DESCRIPTION + The single path every OneLogin call takes, and the one function the unit suite mocks. + Nothing else in this provider touches the network except the token request and its + revocation, which authenticate with the credential rather than a token. + + What it has to know, all of it verified against a live account: + + - A path is relative to /api/2 unless it starts with a slash, so 'users' is + https://.onelogin.com/api/2/users and '/auth/rate_limit' is itself. + + - A list comes back as a bare JSON array, one page at a time. -Paginate asks for + limit and page and keeps asking until a page comes back short or the Total-Pages + header says there are no more. The Link header OneLogin sends is not followed: it + names an /api/v5 path that is not the API this provider speaks. + + - The access token is renewed here, a minute before expiry, so no caller has to think + about it. A 401 mid-run means the token was revoked or expired early, so it is renewed + once and the call repeated before anything is thrown. + + - The account allows 5,000 calls an hour per credential. A 429 is waited out, for as long + as the response asks up to a minute, and retried three times; a reset further away than + that is reported rather than slept through, because a seed that stops for most of an + hour without saying so looks hung. + + - Callers that expect a particular failure - a lookup that may find nothing, a delete of + something already gone - name the HTTP status in -IgnoreStatus and get $null back. + OneLogin's error names are too broad to key on: a 400 BadRequestError covers an invalid + shortname and an empty body alike. + + Encoding, TLS and the progress bar are Invoke-TestWebRequest's job, following the HTTP + encoding invariant in CLAUDE.md: bodies go out as UTF-8 bytes and responses are decoded from + their raw bytes as UTF-8. + + .PARAMETER Method + The HTTP method. + + .PARAMETER Path + The path below /api/2, such as 'users' or 'roles/123/users', or an absolute path from + the host when it starts with a slash. + + .PARAMETER Body + An object serialised as JSON. Omitted for GET and DELETE. + + .PARAMETER Query + Query string values, added to the path. + + .PARAMETER Paginate + Ask for every page and return the items from all of them. + + .PARAMETER IgnoreStatus + HTTP statuses to treat as an empty result rather than a failure. + + .PARAMETER Connection + The connection to use. Defaults to the session's. + + .OUTPUTS + The response object, or the items from every page under -Paginate, or $null for an + ignored status. + + .EXAMPLE + PS> Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate + + DESCRIPTION: Reads every role, one page after another + OUTPUT: The role objects, one by one + USE CASE: Ownership discovery and the report + + .EXAMPLE + PS> Invoke-OneLoginRequest -Method DELETE -Path "roles/$id" -IgnoreStatus 404 + + DESCRIPTION: Deletes a role that another step may already have removed + OUTPUT: $null when it was already gone + USE CASE: Teardown, where a missing object is success rather than failure + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([object])] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('GET', 'POST', 'PUT', 'PATCH', 'DELETE')] + [string]$Method, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Path, + + [Parameter()] + [object]$Body, + + [Parameter()] + [hashtable]$Query, + + [Parameter()] + [switch]$Paginate, + + [Parameter()] + [int[]]$IgnoreStatus = @(), + + [Parameter()] + [hashtable]$Connection + ) + + if (-not $Connection) { $Connection = Get-OneLoginConnection } + + # Copied to locals for the $send block below, which is the only place they are read and which + # the analyzer does not look inside when it decides whether a parameter is used. + $requestMethod = $Method + $requestBody = $Body + $ignoredStatus = $IgnoreStatus + + $base = if ($Path.StartsWith('/')) { 'https://{0}{1}' -f $Connection.ApiHost, $Path } + else { 'https://{0}/api/2/{1}' -f $Connection.ApiHost, $Path } + + $buildUri = { + param([hashtable]$Values) + if (-not $Values -or $Values.Count -eq 0) { return $base } + # Sorted, so the same query always produces the same URI and a test can match it. + $pairs = foreach ($key in ($Values.Keys | Sort-Object)) { + '{0}={1}' -f [uri]::EscapeDataString([string]$key), [uri]::EscapeDataString([string]$Values[$key]) + } + $separator = if ($base.Contains('?')) { '&' } else { '?' } + return '{0}{1}{2}' -f $base, $separator, ($pairs -join '&') + } + + # One request, with the 401 renewal and the 429 backoff. Returns the response, or $null for a + # status the caller asked to ignore. + $send = { + param([string]$Uri) + $renewed = $false + $throttled = 0 + while ($true) { + $arguments = @{ + Method = $requestMethod + Uri = $Uri + Headers = @{ Authorization = 'Bearer {0}' -f (Get-OneLoginAccessToken -Connection $Connection -AsPlainText) } + } + if ($null -ne $requestBody) { $arguments['Body'] = $requestBody } + + try { + Write-Verbose "OneLogin $Method $Uri" + return (Invoke-TestWebRequest @arguments) + } + catch { + $detail = Get-OneLoginErrorDetail -ErrorRecord $_ + + if ($ignoredStatus -contains $detail.Status) { + Write-Verbose "OneLogin $Method $Path answered HTTP $($detail.Status), which the caller asked to ignore" + return $null + } + + if ($detail.Status -eq 401 -and -not $renewed) { + Write-Verbose 'OneLogin refused the token; renewing it once and retrying' + $renewed = $true + $Connection.AccessToken = $null + continue + } + + if ($detail.Status -eq 429 -and $throttled -lt 3) { + $wait = if ($detail.RetryAfterSeconds) { $detail.RetryAfterSeconds } else { 10 } + if ($wait -gt 60) { + throw ('OneLogin rate limit reached on {0} {1}: the hourly allowance resets in {2} seconds. ' + + 'Run the step again after that; every seed step reuses what already exists.') -f $Method, $Path, $wait + } + $throttled++ + Write-Warning "OneLogin rate limit reached; waiting $wait seconds before retrying $Method $Path" + Start-Sleep -Seconds $wait + continue + } + + $message = 'OneLogin {0} {1} failed with HTTP {2}: {3}' -f $Method, $Path, $detail.Status, $detail.Summary + throw (New-Object System.Exception($message, $_.Exception)) + } + } + } + + if (-not $Paginate) { + $response = & $send (& $buildUri $Query) + if ($null -eq $response -or [string]::IsNullOrWhiteSpace($response.Content)) { return $null } + # A success is not always JSON: deleting a Smart Hook answers 202 with a word of plain text, + # verified live. The request succeeded, so the text is handed back rather than parsed. + $trimmed = $response.Content.TrimStart() + if (-not ($trimmed.StartsWith('{') -or $trimmed.StartsWith('['))) { return $response.Content } + return ($response.Content | ConvertFrom-Json) + } + + $pageSize = $script:OneLoginPageSize + $page = 1 + # A guard on the guard: no account this module seeds needs anywhere near this many pages, and + # a server that ignored the page parameter would otherwise be read forever. + $maxPages = 1000 + + while ($page -le $maxPages) { + $values = @{} + if ($Query) { foreach ($key in $Query.Keys) { $values[$key] = $Query[$key] } } + $values['limit'] = $pageSize + $values['page'] = $page + + $response = & $send (& $buildUri $values) + if ($null -eq $response -or [string]::IsNullOrWhiteSpace($response.Content)) { break } + + # Assigned before it is wrapped. Windows PowerShell's ConvertFrom-Json emits a JSON array + # as one object, so @($content | ConvertFrom-Json) there is an array holding the array, and + # a page of a hundred users would read as one item and end the loop. + $parsed = $response.Content | ConvertFrom-Json + $items = @($parsed) + foreach ($item in $items) { if ($null -ne $item) { $item } } + + if ($items.Count -lt $pageSize) { break } + $totalPages = 0 + if ($response.Headers -and [int]::TryParse([string]$response.Headers['Total-Pages'], [ref]$totalPages) -and + $totalPages -gt 0 -and $page -ge $totalPages) { break } + $page++ + } +} diff --git a/Providers/OneLogin/Private/Remove-OneLoginAppSecret.ps1 b/Providers/OneLogin/Private/Remove-OneLoginAppSecret.ps1 new file mode 100644 index 0000000..ad29140 --- /dev/null +++ b/Providers/OneLogin/Private/Remove-OneLoginAppSecret.ps1 @@ -0,0 +1,54 @@ +function Remove-OneLoginAppSecret { + <# + .SYNOPSIS + Deletes a saved app secret record, and the vault secret it points to + + .DESCRIPTION + Called by Remove-OneLoginEnvironment for each app it deletes, and for each record whose app + is no longer in the account, so a saved secret never outlives the app it opens. A record + kept in the SecretStore points to a vault secret; that secret is removed first, then the + record, so a failure part way leaves the record that still names what is left. + + Goes through ShouldProcess, so a teardown run with -WhatIf lists the record and removes + nothing. + + .PARAMETER Record + A record as Get-OneLoginAppSecretRecord returns it. + + .OUTPUTS + System.Boolean, $true when the record was removed. + + .EXAMPLE + PS> Get-OneLoginAppSecretRecord -Subdomain contoso | Remove-OneLoginAppSecret -Confirm:$false + + DESCRIPTION: Removes every saved app secret for the account + OUTPUT: $true per record removed + USE CASE: Teardown + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [ValidateNotNull()] + [PSCustomObject]$Record + ) + + process { + $label = if ($Record.AppName) { "$($Record.AppName) ($($Record.AppId))" } else { "app $($Record.AppId)" } + if (-not $PSCmdlet.ShouldProcess($Record.Path, "Delete the saved client secret of OneLogin $label")) { + return $false + } + + if ($Record.Protection -eq 'SecretStore' -and $Record.VaultName -and $Record.SecretName) { + $null = Remove-TestVaultSecret -VaultName $Record.VaultName -SecretName $Record.SecretName -Confirm:$false + } + Remove-Item -LiteralPath $Record.Path -Force -Confirm:$false -ErrorAction Stop + return $true + } +} diff --git a/Providers/OneLogin/Private/Resolve-OneLoginDirectoryIdentity.ps1 b/Providers/OneLogin/Private/Resolve-OneLoginDirectoryIdentity.ps1 new file mode 100644 index 0000000..8238f83 --- /dev/null +++ b/Providers/OneLogin/Private/Resolve-OneLoginDirectoryIdentity.ps1 @@ -0,0 +1,118 @@ +function Resolve-OneLoginDirectoryIdentity { + <# + .SYNOPSIS + Builds the directory-style identifiers a seeded person carries, all of them in the seed's own namespace + + .DESCRIPTION + A OneLogin account is usually fed from Active Directory, so its people carry an AD user name, + a UPN, a distinguished name, the groups they are a member of and an employee id. Seeding + those makes the account look like the ones scripts actually run against, and gives an app + rule something real to read. + + None of them may name anything outside the seed. A production account's directory connector + and its provisioning match people by exactly these values - a seeded person carrying the + sAMAccountName or UPN of a real AD account, or the external id of a real employee, is one a + sync could link to that account. So every value is built inside the seed's namespace: + + - samaccountname is the prefix and the key, lower case, cut to AD's twenty characters; + - userprincipalname is the seeded username at the connection's email domain, which is under + example.com unless somebody who owns another domain chose it; + - distinguished_name puts the person in OU=,OU=Users under DC components + made from that same domain, and member_of names groups under OU=Groups there; + - external_id is the prefix and the employee id from the data. + + The common name is the person's display name, which is where the writing systems live: an + ideographic space, a surname above the basic plane, right-to-left script, escaped as RFC 4514 + says and otherwise left exactly as written. + + The comment carries Core's sentence - "Seeded by TestEnvironment. Safe to delete." - for an + administrator who opens one of these people in a production portal. + + .PARAMETER Row + The person's row from OneLoginUsers.csv. + + .PARAMETER RoleNameByKey + The seed data's role names by key, unprefixed. + + .PARAMETER GroupNameByKey + The seed data's group names by key, unprefixed. + + .PARAMETER Connection + The connection to take the prefix and email domain from. Defaults to the session's. + + .OUTPUTS + System.Collections.Specialized.OrderedDictionary of OneLogin user field names and values. + + .EXAMPLE + PS> Resolve-OneLoginDirectoryIdentity -Row $row -RoleNameByKey $roles -GroupNameByKey $groups + + DESCRIPTION: Builds the identifiers for one person + OUTPUT: samaccountname zz-test-jnino, userprincipalname zz-test-jnino@onelogin-lab.example.com, ... + USE CASE: New-OneLoginUser, and the verifier's expected values + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNull()] + [object]$Row, + + [Parameter(Mandatory = $true)] + [hashtable]$RoleNameByKey, + + [Parameter(Mandatory = $true)] + [hashtable]$GroupNameByKey, + + [Parameter()] + [hashtable]$Connection + ) + + if (-not $Connection) { $Connection = Get-OneLoginConnection } + $marker = Get-OneLoginSeedMarker -Prefix $Connection.Prefix + $prefix = $marker.Prefix + + # RFC 4514: a comma, plus, quote, backslash, angle bracket, semicolon or equals is escaped + # everywhere, a leading hash or space and a trailing space at the ends. + $escape = { + param([string]$Value) + $escaped = [regex]::Replace($Value, '([,+"\\<>;=])', '\$1') + if ($escaped.StartsWith('#') -or $escaped.StartsWith(' ')) { $escaped = '\' + $escaped } + if ($escaped.EndsWith(' ') -and -not $escaped.EndsWith('\ ')) { $escaped = $escaped.Substring(0, $escaped.Length - 1) + '\ ' } + return $escaped + } + $domainComponents = (@(([string]$Connection.EmailDomain).Split('.') | Where-Object { $_ } | ForEach-Object { 'DC={0}' -f (& $escape $_) })) -join ',' + + $username = Resolve-OneLoginSeedName -Key $Row.Key -Kind Username -Connection $Connection + $sam = $username + if ($sam.Length -gt 20) { $sam = $sam.Substring(0, 20) } + + $display = [string]$Row.DisplayName + if (-not $display) { $display = ('{0} {1}' -f $Row.GivenName, $Row.Surname).Trim() } + $department = if ($Row.Department) { [string]$Row.Department } else { 'Unassigned' } + $dn = 'CN={0},OU={1},OU={2},{3}' -f (& $escape $display), (& $escape $department), (& $escape ('{0}Users' -f $prefix)), $domainComponents + + $groupDns = New-Object System.Collections.Generic.List[string] + $names = @(([string]$Row.Roles -split ';') | Where-Object { $_ } | ForEach-Object { $RoleNameByKey[$_] }) + if ($Row.Group) { $names += $GroupNameByKey[[string]$Row.Group] } + foreach ($name in @($names | Where-Object { $_ })) { + $groupDns.Add(('CN={0},OU={1},{2}' -f (& $escape ('{0}{1}' -f $prefix, $name)), (& $escape ('{0}Groups' -f $prefix)), $domainComponents)) + } + + $fields = [ordered]@{ + samaccountname = $sam + userprincipalname = Resolve-OneLoginSeedName -Key $Row.Key -Kind Email -Connection $Connection + distinguished_name = $dn + member_of = ($groupDns -join ';') + external_id = $(if ($Row.EmployeeId) { '{0}{1}' -f $prefix, $Row.EmployeeId } else { '' }) + phone = [string]$Row.Phone + preferred_locale_code = [string]$Row.Locale + comment = $marker.Description + } + return $fields +} diff --git a/Providers/OneLogin/Private/Resolve-OneLoginSeedName.ps1 b/Providers/OneLogin/Private/Resolve-OneLoginSeedName.ps1 new file mode 100644 index 0000000..f728d85 --- /dev/null +++ b/Providers/OneLogin/Private/Resolve-OneLoginSeedName.ps1 @@ -0,0 +1,83 @@ +function Resolve-OneLoginSeedName { + <# + .SYNOPSIS + Turns a seed-file key into the name the object carries in the account + + .DESCRIPTION + The seed files hold bare keys and names - 'all-staff', 'Expenses Web', 'awhitfield' - and + the account holds prefixed ones. This is the one place the two are related, so every step + names an object the same way and teardown and verification find what the seed made. + + Three forms: + + - A display name takes the prefix as written: 'All Staff' becomes 'ZZ-TEST-All Staff'. + Roles, groups, apps and mappings all use this. + - A username takes the prefix lower-cased and stays lower case throughout, so a lookup + by username never depends on how the account folds case. + - An email is the username at the connection's email domain. + + A seeded person's first and last name are never prefixed. They carry a real name on + purpose, so anything that displays, sorts or matches people by name is tested against + names rather than against prefixed identifiers. Ownership is proved by the seed tag in + the user's custom field and the prefix on the username, neither of which a person reads. + + .PARAMETER Key + The bare key or name from the seed data. + + .PARAMETER Kind + Which form to produce. + + .PARAMETER Connection + The connection to take the prefix and email domain from. Defaults to the session's. + + .OUTPUTS + System.String + + .EXAMPLE + PS> Resolve-OneLoginSeedName -Key 'All Staff' -Kind DisplayName + + DESCRIPTION: Names a role as the account holds it + OUTPUT: ZZ-TEST-All Staff + USE CASE: Creating a role and finding it again at teardown + + .EXAMPLE + PS> Resolve-OneLoginSeedName -Key 'awhitfield' -Kind Username + + DESCRIPTION: Names a user + OUTPUT: zz-test-awhitfield + USE CASE: Creating a user, and every lookup of one afterwards + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Key, + + [Parameter(Mandatory = $true)] + [ValidateSet('DisplayName', 'Username', 'Email')] + [string]$Kind, + + [Parameter()] + [hashtable]$Connection + ) + + if (-not $Connection) { $Connection = Get-OneLoginConnection } + + $prefix = (Get-OneLoginSeedMarker -Prefix $Connection.Prefix).Prefix + + switch ($Kind) { + 'DisplayName' { return '{0}{1}' -f $prefix, $Key } + 'Username' { return ('{0}{1}' -f $prefix, $Key).ToLowerInvariant() } + 'Email' { + $username = ('{0}{1}' -f $prefix, $Key).ToLowerInvariant() + return '{0}@{1}' -f $username, $Connection.EmailDomain + } + } +} diff --git a/Providers/OneLogin/Public/Connect-OneLoginEnvironment.ps1 b/Providers/OneLogin/Public/Connect-OneLoginEnvironment.ps1 new file mode 100644 index 0000000..6cf1d1d --- /dev/null +++ b/Providers/OneLogin/Public/Connect-OneLoginEnvironment.ps1 @@ -0,0 +1,202 @@ +function Connect-OneLoginEnvironment { + <# + .SYNOPSIS + Establishes the OneLogin connection every other function in this provider uses + + .DESCRIPTION + OneLogin authenticates an API credential - a client id and secret created under + Developers, API Credentials in the admin portal - and that is the only credential this + provider needs. The credential is created by hand, by an account owner or super user, + before the first connect; this provider does not create it. Its scope has to be Manage + All: the seed creates roles, apps, mappings and custom user fields as well as users, and + Manage Users covers only the last. + + Every call goes to the account's own host, https://.onelogin.com, which serves + both the token and the API whichever region the account is in, so there is no region to + name. -Subdomain takes the bare name, the host or the portal URL, and keeps the name. + + The connection is validated before it is stored. A credential accepted here and refused + on the first real call gives somebody an error about users when the problem is the + credential, so a token is fetched and the API read once up front. A credential whose + scope stops at reading is caught later, by the first write, with OneLogin's own message. + + Prefix and EmailDomain are recorded on the connection rather than passed to every + function. They are what teardown keys off, so setting them once removes the failure mode + where an account is seeded under one prefix and torn down under another. + + .PARAMETER Subdomain + The account: 'contoso', 'contoso.onelogin.com' or 'https://contoso.onelogin.com/'. + + .PARAMETER ClientId + The API credential's client id. + + .PARAMETER ClientSecret + The API credential's client secret, as a SecureString. + + .PARAMETER UseStoredSecret + Read the client id and secret from this machine's record for the account instead of + being given them. Written by -SaveSecret on an earlier connect. Also answers to + -UseStoredCredential, the name every provider shares for connecting with what it stored. + + .PARAMETER SaveSecret + Write the client id and secret to this machine's record once the connection has been + proved, so later runs can use -UseStoredSecret. Nothing is written if the connection + fails. + + .PARAMETER UseSecretStore + With -SaveSecret, keep the secret in a SecretStore vault rather than in the record. + + .PARAMETER VaultPassword + The vault's password, when the secret is in a vault whose password is not a default. + + .PARAMETER Prefix + Name prefix and seed tag for everything this module creates. Everything created and + everything removed is scoped by it. + + .PARAMETER EmailDomain + Domain for seeded usernames and emails. The default is under example.com, which RFC 2606 + reserves precisely so test data cannot deliver mail to a real recipient. Change it only if + you own the domain you change it to. + + .PARAMETER PassThru + Return the connection object. + + .OUTPUTS + PSCustomObject describing the connection, when -PassThru is used. + + .EXAMPLE + PS> $secret = Read-Host 'Client secret' -AsSecureString + PS> Connect-TestEnvironment -Provider OneLogin -Subdomain contoso -ClientId 7f12... -ClientSecret $secret -SaveSecret + + DESCRIPTION: First run, saving the credential for later ones + OUTPUT: Nothing, unless -PassThru is given + USE CASE: Connecting to a new account + + .EXAMPLE + PS> Connect-TestEnvironment -Provider OneLogin -Subdomain contoso -UseStoredCredential + + DESCRIPTION: Every run after that + OUTPUT: Nothing + USE CASE: The ordinary case, with nothing to paste + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + .LINK + New-OneLoginEnvironment + Get-OneLoginEnvironmentReport + Disconnect-OneLoginEnvironment + #> + + [CmdletBinding(DefaultParameterSetName = 'Secret')] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Subdomain, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [ValidatePattern('^[A-Za-z0-9]+$')] + [string]$ClientId, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [System.Security.SecureString]$ClientSecret, + + [Parameter(Mandatory = $true, ParameterSetName = 'Stored')] + [Alias('UseStoredCredential')] + [switch]$UseStoredSecret, + + [Parameter(ParameterSetName = 'Secret')] + [switch]$SaveSecret, + + [Parameter(ParameterSetName = 'Secret')] + [switch]$UseSecretStore, + + [Parameter()] + [System.Security.SecureString]$VaultPassword, + + [Parameter()] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_-]{0,30}$|^[A-Za-z0-9][A-Za-z0-9_-]*[-_]$')] + [string]$Prefix = $script:TestEnvironmentDefaultPrefix, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$EmailDomain = $script:OneLoginDefaultSeedDomain, + + [Parameter()] + [switch]$PassThru + ) + + $correlationId = [Guid]::NewGuid() + Write-Verbose "Starting Connect-OneLoginEnvironment - CorrelationId: $correlationId" + + # 'https://contoso.onelogin.com/', 'contoso.onelogin.com' and 'contoso' all name contoso. + $name = $Subdomain.Trim() -replace '^[A-Za-z]+://', '' + $name = ($name -split '[/?#]')[0] + $name = ($name -replace '\.onelogin\.com$', '').ToLowerInvariant() + if ($name -notmatch '^[a-z0-9][a-z0-9-]*$') { + throw "'$Subdomain' does not name a OneLogin account. Give the subdomain, as in https://.onelogin.com." + } + + # Locals rather than the parameters: a parameter keeps its validation, and assigning the stored + # id back to $ClientId would re-run the pattern against a value the caller never typed. + $clientIdentifier = $ClientId + $secret = $ClientSecret + if ($UseStoredSecret) { + $stored = Import-OneLoginCredential -Path (Get-OneLoginCredentialPath -Subdomain $name) -VaultPassword $VaultPassword + $clientIdentifier = $stored.ClientId + $secret = $stored.ClientSecret + } + + # Held in a local until it is proved, so a failed connect cannot leave a half-built + # connection in module scope for the next call to trip over. + $candidate = @{ + Subdomain = $name + ApiHost = '{0}.onelogin.com' -f $name + ClientId = $clientIdentifier + ClientSecret = $secret + Prefix = $Prefix + EmailDomain = $EmailDomain + AccountId = $null + AccessToken = $null + TokenExpiresUtc = $null + } + + # The token proves the credential; one read proves the API answers to it. + try { + $null = Invoke-OneLoginRequest -Method GET -Path 'roles' -Query @{ limit = 1 } -Connection $candidate -ErrorAction Stop + } + catch { + throw ("Could not connect to OneLogin account '$name' at https://$($candidate.ApiHost): $($_.Exception.Message)") + } + + $script:OneLoginConnection = $candidate + + if ($SaveSecret) { + $plain = ConvertFrom-TestSecureString -SecureString $secret + try { + $null = Export-OneLoginCredential -Path (Get-OneLoginCredentialPath -Subdomain $name) -Subdomain $name ` + -ClientId $clientIdentifier -ClientSecret $plain -UseSecretStore:$UseSecretStore -VaultPassword $VaultPassword -Confirm:$false + } + finally { + $plain = $null + } + Write-Verbose "Wrote the OneLogin credential record for $name" + } + + Write-Verbose ("Connected to OneLogin account '{0}' (account id {1})" -f $name, $candidate.AccountId) + + if ($PassThru) { + return [PSCustomObject]@{ + PSTypeName = 'OneLoginConnection' + Subdomain = $name + AccountId = $candidate.AccountId + ApiHost = $candidate.ApiHost + ClientId = $clientIdentifier + Prefix = $Prefix + EmailDomain = $EmailDomain + } + } +} diff --git a/Providers/OneLogin/Public/Disconnect-OneLoginEnvironment.ps1 b/Providers/OneLogin/Public/Disconnect-OneLoginEnvironment.ps1 new file mode 100644 index 0000000..e35f86a --- /dev/null +++ b/Providers/OneLogin/Public/Disconnect-OneLoginEnvironment.ps1 @@ -0,0 +1,76 @@ +function Disconnect-OneLoginEnvironment { + <# + .SYNOPSIS + Revokes the session's OneLogin token and clears the connection + + .DESCRIPTION + Removes the connection from module scope so that a later call fails with "not connected" + rather than reaching an account the caller has stopped thinking about. + + The token is revoked first, at /auth/oauth2/revoke, because a OneLogin token lives ten + hours and would otherwise stay valid long after the session that asked for it. The + revocation is best effort: a network failure or a token that already expired is not a + reason to keep a connection the caller asked to drop, so it is reported verbosely and the + connection is cleared regardless. + + The secret is a SecureString, so it is disposed rather than merely dereferenced: dropping + the reference leaves the value in memory until the garbage collector gets to it, and + disposing zeroes it now. + + Nothing in the account changes. + + .EXAMPLE + PS> Disconnect-TestEnvironment + + DESCRIPTION: Ends the session's connection + OUTPUT: None + USE CASE: Finishing with one account before connecting to another + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + .LINK + Connect-OneLoginEnvironment + #> + + [CmdletBinding()] + [OutputType([void])] + param() + + if (-not $script:OneLoginConnection) { + Write-Verbose 'No OneLogin connection to clear.' + return + } + + $connection = $script:OneLoginConnection + $subdomain = $connection.Subdomain + + if ($connection.AccessToken) { + $bstr = [IntPtr]::Zero + try { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($connection.ClientSecret) + $pair = '{0}:{1}' -f $connection.ClientId, [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + $basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) + $pair = $null + $null = Invoke-TestWebRequest -Method POST -Uri ('https://{0}/auth/oauth2/revoke' -f $connection.ApiHost) ` + -Headers @{ Authorization = "Basic $basic" } -Body @{ access_token = $connection.AccessToken } + Write-Verbose 'Revoked the OneLogin access token' + } + catch { + Write-Verbose "Could not revoke the OneLogin access token: $($_.Exception.Message)" + } + finally { + if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + $basic = $null + } + } + + $connection.AccessToken = $null + if ($connection.ClientSecret -is [System.Security.SecureString]) { $connection.ClientSecret.Dispose() } + + Remove-Variable -Name OneLoginConnection -Scope Script -ErrorAction SilentlyContinue + + Write-Verbose "Disconnected from OneLogin account $subdomain" +} diff --git a/Providers/OneLogin/Public/Get-OneLoginAccessToken.ps1 b/Providers/OneLogin/Public/Get-OneLoginAccessToken.ps1 new file mode 100644 index 0000000..1c7c206 --- /dev/null +++ b/Providers/OneLogin/Public/Get-OneLoginAccessToken.ps1 @@ -0,0 +1,117 @@ +function Get-OneLoginAccessToken { + <# + .SYNOPSIS + Returns a live OneLogin access token, renewing it when it is about to expire + + .DESCRIPTION + OneLogin issues an API credential a bearer token through the OAuth 2 client credentials + grant, at https://.onelogin.com/auth/oauth2/v2/token, authenticated with the + client id and secret as HTTP Basic and a JSON body naming the grant. Verified live, the + token lives ten hours and its scope comes back empty: what it may do is decided by the + credential's scope in the admin portal, not by the token request. + + Ten hours is longer than any seed, but a connection held open across a working day + outlives it, so the token is renewed here, a minute before expiry, and written back onto + the connection. Invoke-OneLoginRequest also renews once on a 401, for a token revoked + from the portal. + + The secret is held on the connection as a SecureString and converted for exactly as long + as the request takes. + + .PARAMETER AsPlainText + Return the bare token string rather than an object. Used by the request function, which + needs it for an Authorization header. + + .PARAMETER Connection + The connection to use. Defaults to the session's. + + .OUTPUTS + PSCustomObject describing the token, or System.String with -AsPlainText. + + .EXAMPLE + PS> Get-TestAccessToken + + DESCRIPTION: Returns the current token's expiry and account + OUTPUT: An object with ExpiresUtc and AccountId + USE CASE: Checking that a connection is still good before a long run + + .EXAMPLE + PS> $bearer = Get-OneLoginAccessToken -AsPlainText + + DESCRIPTION: Fetches the raw token for an Authorization header + OUTPUT: The token string + USE CASE: Called by Invoke-OneLoginRequest on every request + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [switch]$AsPlainText, + + [Parameter()] + [hashtable]$Connection + ) + + if (-not $Connection) { $Connection = Get-OneLoginConnection } + + $needsToken = ( + [string]::IsNullOrWhiteSpace($Connection.AccessToken) -or + -not $Connection.TokenExpiresUtc -or + [DateTime]::UtcNow -ge $Connection.TokenExpiresUtc.AddSeconds(-60) + ) + + if ($needsToken) { + Write-Verbose 'Requesting a OneLogin access token' + $uri = 'https://{0}/auth/oauth2/v2/token' -f $Connection.ApiHost + + $basic = $null + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Connection.ClientSecret) + try { + $pair = '{0}:{1}' -f $Connection.ClientId, [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + $basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($pair)) + $pair = $null + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + + $requestedAt = [DateTime]::UtcNow + try { + $response = Invoke-TestWebRequest -Method POST -Uri $uri -Headers @{ Authorization = "Basic $basic" } ` + -Body @{ grant_type = 'client_credentials' } + $payload = $response.Content | ConvertFrom-Json + } + catch { + $detail = Get-OneLoginErrorDetail -ErrorRecord $_ + # Formatted as one string first: -f binds tighter than +, and a template built by + # concatenation in the same expression goes out with its placeholders unfilled. + $template = 'Could not get a OneLogin access token from {0}: {1}. A 401 here means the client id or secret is ' + + 'wrong, or the API credential was deleted in the admin portal.' + throw ($template -f $uri, $detail.Summary) + } + finally { + $basic = $null + } + + $Connection.AccessToken = $payload.access_token + $Connection.TokenExpiresUtc = $requestedAt.AddSeconds([int]$payload.expires_in) + if ($payload.account_id) { $Connection.AccountId = [string]$payload.account_id } + } + + if ($AsPlainText) { return $Connection.AccessToken } + + return [PSCustomObject]@{ + PSTypeName = 'OneLoginAccessToken' + Subdomain = $Connection.Subdomain + AccountId = $Connection.AccountId + ClientId = $Connection.ClientId + ExpiresUtc = $Connection.TokenExpiresUtc + ExpiresInSeconds = [int]([Math]::Max(0, ($Connection.TokenExpiresUtc - [DateTime]::UtcNow).TotalSeconds)) + } +} diff --git a/Providers/OneLogin/Public/Get-OneLoginAppCredential.ps1 b/Providers/OneLogin/Public/Get-OneLoginAppCredential.ps1 new file mode 100644 index 0000000..b6685d4 --- /dev/null +++ b/Providers/OneLogin/Public/Get-OneLoginAppCredential.ps1 @@ -0,0 +1,41 @@ +function Get-OneLoginAppCredential { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Returns the saved client id and secret of seeded OneLogin apps as credentials + #> + + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$Subdomain, + + [Parameter()] + [System.Security.SecureString]$VaultPassword + ) + + $account = if ($Subdomain) { $Subdomain } else { (Get-OneLoginConnection).Subdomain } + + foreach ($record in @(Get-OneLoginAppSecretRecord -Subdomain $account)) { + if ($Key -and $Key -notcontains $record.AppKey) { continue } + + $stored = Import-TestCredentialRecord -Path $record.Path -Required 'appId', 'clientId' -SecretField 'clientSecretProtected' ` + -SecretLabel 'client secret' -MissingRecordMessage 'Seed the app with New-OneLoginApp -SaveAppSecret.' -VaultPassword $VaultPassword + $secure = ConvertTo-TestSecureString -PlainText $stored.Secret + $stored = $null + + [PSCustomObject]@{ + Key = $record.AppKey + Name = $record.AppName + AppId = $record.AppId + Subdomain = $record.Subdomain + Protection = $record.Protection + Credential = [PSCredential]::new($record.ClientId, $secure) + } + } +} diff --git a/Providers/OneLogin/Public/Get-OneLoginEnvironmentReport.ps1 b/Providers/OneLogin/Public/Get-OneLoginEnvironmentReport.ps1 new file mode 100644 index 0000000..0e13493 --- /dev/null +++ b/Providers/OneLogin/Public/Get-OneLoginEnvironmentReport.ps1 @@ -0,0 +1,248 @@ +function Get-OneLoginEnvironmentReport { + <# + .SYNOPSIS + Reports what this module has seeded in a OneLogin account, and what shape it is in + + .DESCRIPTION + Reached through Get-TestEnvironmentReport once a OneLogin connection is active. + + Only objects this module can prove it created are counted, using the same selection + teardown uses, so the report and the teardown can never disagree about what is ours. + + Beyond counts, it surfaces the states the seed exists to create, because a report that + says "321 users" and nothing else proves nothing about whether a script handles a + suspended manager or a rejected partner who still holds a role: + + - Users by status and by state, the two numbers OneLogin keeps a lifecycle in. + - Roles with their user and app counts. A role an enabled mapping adds people to holds + more than the data lists, and only once OneLogin has run the mapping. + - Groups with their member counts and policy, apps by connector and visibility, and + mappings with whether each is enabled. + - User policies with the groups they govern and their password and lockout settings, API + authorization servers with their scopes and clients, app rules, the Smart Hook with + whether it is disabled (it should always be), and the sign-up profile with whether it is + enabled and moderated. + + Read-only. Nothing in the account changes. + + Console output is for a person; JSON, CSV and HTML are for a file, written by the one + writer every provider shares, as UTF-8. The report object is the shape every provider + returns: Provider, Target, GeneratedOn, the account's own facts, Counts, Sections, and one + property per section. + + .PARAMETER OutputFormat + Console, JSON, HTML or CSV. + + .PARAMETER OutputPath + The file to write, or for CSV the folder. Required for anything but Console. + + .PARAMETER PassThru + Returns the report object as well. + + .OUTPUTS + OneLoginEnvironmentReport, when -PassThru is supplied. + + .EXAMPLE + PS> Get-TestEnvironmentReport + + DESCRIPTION: Summarises the seeded account + OUTPUT: Counts and state breakdowns per object type + USE CASE: Confirming a seed produced what it should + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + .LINK + New-OneLoginEnvironment + Remove-OneLoginEnvironment + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'The summary is written for a person reading it; -PassThru returns the object for scripts.')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [ValidateSet('Console', 'JSON', 'HTML', 'CSV')] + [string]$OutputFormat = 'Console', + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + if ($OutputFormat -ne 'Console' -and -not $OutputPath) { + throw "-OutputPath is required for the $OutputFormat format." + } + + $attributes = @(Get-OneLoginSeededObject -Type Attributes -Connection $connection) + $users = @(Get-OneLoginSeededObject -Type Users -Connection $connection) + $apps = @(Get-OneLoginSeededObject -Type Apps -Connection $connection) + $userIds = @($users | ForEach-Object { [string]$_.id }) + $appIds = @($apps | ForEach-Object { [string]$_.id }) + $roles = @(Get-OneLoginSeededObject -Type Roles -OwnedUserId $userIds -OwnedAppId $appIds -Connection $connection) + $groups = @(Get-OneLoginSeededObject -Type Groups -OwnedUserId $userIds -Connection $connection) + $owned = @{ OwnedUserId = $userIds; OwnedAppId = $appIds; OwnedRoleId = @($roles | ForEach-Object { [string]$_.id }); OwnedGroupId = @($groups | ForEach-Object { [string]$_.id }); Connection = $connection } + $mappings = @(Get-OneLoginSeededObject -Type Mappings @owned) + $policies = @(Get-OneLoginSeededObject -Type Policies @owned) + $apiServers = @(Get-OneLoginSeededObject -Type ApiAuthorizations -Connection $connection) + $appRules = @(Get-OneLoginSeededObject -Type AppRules @owned) + $hooks = @(Get-OneLoginSeededObject -Type Hooks @owned) + $registrations = @(Get-OneLoginSeededObject -Type SelfRegistration -Connection $connection) + + $nameOf = { + param($map, $value) + foreach ($entry in $map.GetEnumerator()) { if ([int]$entry.Value -eq [int]$value) { return $entry.Key } } + return [string]$value + } + + $groupName = @{} + foreach ($group in $groups) { $groupName[[string]$group.id] = $group.name } + $userName = @{} + foreach ($user in $users) { $userName[[string]$user.id] = $user.username } + $connectorName = @{} + foreach ($entry in $script:OneLoginConnector.GetEnumerator()) { $connectorName[[int]$entry.Value] = $entry.Key } + + $byStatus = [ordered]@{} + foreach ($bucket in ($users | Group-Object { & $nameOf $script:OneLoginUserStatus $_.status } | Sort-Object Name)) { $byStatus[$bucket.Name] = $bucket.Count } + $byState = [ordered]@{} + foreach ($bucket in ($users | Group-Object { & $nameOf $script:OneLoginUserState $_.state } | Sort-Object Name)) { $byState[$bucket.Name] = $bucket.Count } + + $userRows = foreach ($user in ($users | Sort-Object username)) { + [PSCustomObject]@{ + Username = $user.username + FirstName = $user.firstname + LastName = $user.lastname + Status = & $nameOf $script:OneLoginUserStatus $user.status + State = & $nameOf $script:OneLoginUserState $user.state + Group = $(if ($user.group_id) { $groupName[[string]$user.group_id] } else { $null }) + Manager = $(if ($user.manager_user_id) { $userName[[string]$user.manager_user_id] } else { $null }) + Roles = @($user.role_ids).Count + Contractor = $(if ($user.custom_attributes) { $user.custom_attributes.zztest_contractor } else { $null }) + AdUserName = $user.samaccountname + Locale = $user.preferred_locale_code + LockedUntil = $user.locked_until + } + } + $roleRows = foreach ($role in ($roles | Sort-Object name)) { + [PSCustomObject]@{ Name = $role.name; Users = @($role.users).Count; Apps = @($role.apps).Count } + } + $policyName = @{} + foreach ($policy in $policies) { $policyName[[string]$policy.id] = $policy.name } + $groupRows = foreach ($group in ($groups | Sort-Object name)) { + [PSCustomObject]@{ Name = $group.name; Members = @($group.MemberIds).Count; Policy = $(if ($group.policy_id) { $policyName[[string]$group.policy_id] } else { $null }) } + } + $policyRows = foreach ($policy in ($policies | Sort-Object name)) { + $detail = Invoke-OneLoginRequest -Method GET -Path "policies/$($policy.id)" -Connection $connection + [PSCustomObject]@{ + Name = $policy.name + Groups = @($policy.GroupIds).Count + MinimumPasswordLength = $detail.minimum_password_length + PasswordExpirationDays = $detail.password_expiration_days + MaxInvalidAttempts = $detail.maximum_invalid_login_attempts + LockMinutes = $detail.lock_effective_minutes + } + } + $apiRows = foreach ($server in ($apiServers | Sort-Object name)) { + $scopes = @(Invoke-OneLoginRequest -Method GET -Path "api_authorizations/$($server.id)/scopes" -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + $clients = @(Invoke-OneLoginRequest -Method GET -Path "api_authorizations/$($server.id)/clients" -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + [PSCustomObject]@{ Name = $server.name; Audience = @($server.configuration.audiences) -join ' '; Scopes = @($scopes | ForEach-Object value); Clients = $clients.Count } + } + $appName = @{} + foreach ($app in $apps) { $appName[[string]$app.id] = $app.name } + $appRuleRows = foreach ($rule in ($appRules | Sort-Object name)) { + [PSCustomObject]@{ App = $appName[[string]$rule.AppId]; Name = $rule.name; Enabled = [bool]$rule.enabled } + } + $hookRows = foreach ($hook in $hooks) { + [PSCustomObject]@{ Type = $hook.type; Id = $hook.id; Disabled = [bool]$hook.disabled; Conditions = @($hook.conditions).Count } + } + $registrationRows = foreach ($registration in ($registrations | Sort-Object name)) { + [PSCustomObject]@{ Name = $registration.name; Enabled = [bool]$registration.enabled; Moderated = [bool]$registration.moderated; Domains = $registration.domain_whitelist } + } + # An app's roles are counted from the roles, because the app listing leaves role_ids out. + $rolesOfApp = @{} + foreach ($role in $roles) { + foreach ($id in @($role.apps | Where-Object { $null -ne $_ })) { $rolesOfApp[[string]$id] = 1 + [int]$rolesOfApp[[string]$id] } + } + # Whether New-OneLoginApp -SaveAppSecret kept the app's secret on this machine. Read from the + # records alone; no secret is decrypted for a report. + $savedSecret = @{} + foreach ($record in @(Get-OneLoginAppSecretRecord -Subdomain $connection.Subdomain)) { $savedSecret[$record.AppId] = $record.Protection } + $appRows = foreach ($app in ($apps | Sort-Object name)) { + $connector = if ($connectorName.ContainsKey([int]$app.connector_id)) { $connectorName[[int]$app.connector_id] } else { [string]$app.connector_id } + $secret = if ($savedSecret.ContainsKey([string]$app.id)) { $savedSecret[[string]$app.id] } else { '' } + [PSCustomObject]@{ Name = $app.name; Connector = $connector; Visible = [bool]$app.visible; Roles = [int]$rolesOfApp[[string]$app.id]; SecretSaved = $secret } + } + $mappingRows = foreach ($mapping in ($mappings | Sort-Object name)) { + [PSCustomObject]@{ Name = $mapping.name; Enabled = [bool]$mapping.enabled; Conditions = @($mapping.conditions).Count; Actions = @($mapping.actions).Count } + } + $attributeRows = foreach ($field in ($attributes | Sort-Object shortname)) { + [PSCustomObject]@{ Shortname = $field.shortname; Name = $field.name } + } + + $report = New-TestEnvironmentReport -Provider 'OneLogin' -Target $connection.Subdomain -TypeName 'OneLoginEnvironmentReport' ` + -Property ([ordered]@{ + Subdomain = $connection.Subdomain + AccountId = $connection.AccountId + Prefix = $connection.Prefix + UsersByStatus = [PSCustomObject]$byStatus + UsersByState = [PSCustomObject]$byState + UsersWithManager = @($users | Where-Object { $_.manager_user_id }).Count + UsersInNoGroup = @($users | Where-Object { -not $_.group_id }).Count + UsersLocked = @($users | Where-Object { [int]$_.status -eq $script:OneLoginUserStatus['Locked'] }).Count + }) ` + -Section ([ordered]@{ + Attributes = @($attributeRows) + Users = @($userRows) + Roles = @($roleRows) + Groups = @($groupRows) + Apps = @($appRows) + Mappings = @($mappingRows) + Policies = @($policyRows) + ApiAuthorizations = @($apiRows) + AppRules = @($appRuleRows) + Hooks = @($hookRows) + SelfRegistration = @($registrationRows) + }) + + if ($OutputFormat -eq 'Console') { + Write-TestMessage -Message ('OneLogin Test Environment Report ({0}.onelogin.com)' -f $connection.Subdomain) -Type Header + Write-Host 'Counts' + foreach ($property in $report.Counts.PSObject.Properties) { Write-Host (' {0,-12} {1}' -f $property.Name, $property.Value) } + Write-Host '' + Write-Host 'Users by status' + foreach ($property in $report.UsersByStatus.PSObject.Properties) { Write-Host (' {0,-24} {1}' -f $property.Name, $property.Value) } + Write-Host 'Users by state' + foreach ($property in $report.UsersByState.PSObject.Properties) { Write-Host (' {0,-24} {1}' -f $property.Name, $property.Value) } + Write-Host (' with a manager {0}, in no group {1}' -f $report.UsersWithManager, $report.UsersInNoGroup) + Write-Host '' + Write-Host 'Roles' + $report.Roles | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'Groups' + $report.Groups | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'Apps' + $report.Apps | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'Mappings' + $report.Mappings | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'Policies' + $report.Policies | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'API authorizations' + $report.ApiAuthorizations | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + Write-Host 'App rules, Smart Hooks and sign-up' + $report.AppRules | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + $report.Hooks | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + $report.SelfRegistration | Format-Table -AutoSize | Out-String -Width 120 | Write-Host + } + else { + Export-TestEnvironmentReport -Report $report -OutputFormat $OutputFormat -OutputPath $OutputPath ` + -FilePrefix 'OneLoginLab' -Title 'OneLogin Test Environment Report' ` + -Note @("Account $($connection.Subdomain).onelogin.com") + } + + if ($PassThru) { return $report } +} diff --git a/Providers/OneLogin/Public/New-OneLoginApiAuthorization.ps1 b/Providers/OneLogin/Public/New-OneLoginApiAuthorization.ps1 new file mode 100644 index 0000000..567754c --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginApiAuthorization.ps1 @@ -0,0 +1,157 @@ +function New-OneLoginApiAuthorization { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded API authorization servers with their scopes and claims, and lets seeded apps ask for them + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginApiAuthorizations.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + # Client links go to proved seeded apps only: linking somebody else's app would let it ask for a + # seeded API's tokens, and let a seeded API issue tokens to it. + $appNameByKey = @{} + foreach ($appRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginApps.csv') -Encoding UTF8)) { + $appNameByKey[$appRow.Key] = Resolve-OneLoginSeedName -Key $appRow.Name -Kind DisplayName -Connection $connection + } + $appByName = @{} + foreach ($app in @(Get-OneLoginSeededObject -Type Apps -Connection $connection)) { $appByName[[string]$app.name] = $app } + + $existing = @{} + foreach ($server in @(Invoke-OneLoginRequest -Method GET -Path 'api_authorizations' -Paginate -Connection $connection)) { + if ($null -ne $server -and $server.name) { $existing[[string]$server.name] = $server } + } + + $split = { param($value) @(([string]$value -split '\|') | Where-Object { $_ }) } + $list = { param([string]$Path) @(Invoke-OneLoginRequest -Method GET -Path $Path -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $scopesAdded = 0 + $claimsAdded = 0 + $clientsLinked = 0 + + foreach ($row in $rows) { + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + $identifier = 'https://api.{0}/{1}' -f $connection.EmailDomain, ([string]$row.Path).TrimStart('/') + $server = $null + + if ($existing.ContainsKey($name)) { + $server = $existing[$name] + if (-not ([string]$server.description).Contains($marker.Tag)) { + $errors.Add("API authorization '$name' already exists without the seed tag in its description. It is somebody else's; it is left alone.") + continue + } + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $server.id }) + } + else { + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin API authorization server')) { continue } + $body = @{ + name = $name + description = '{0}. {1}' -f $row.Description, $marker.Description + configuration = @{ + resource_identifier = $identifier + audiences = @($identifier) + access_token_expiration_minutes = [int]$row.TokenMinutes + } + } + try { + $server = Invoke-OneLoginRequest -Method POST -Path 'api_authorizations' -Body $body -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $server.id }) + Write-Verbose "Created API authorization $name" + } + catch { + $errors.Add("Could not create API authorization ${name}: $($_.Exception.Message)") + Write-Warning "Could not create API authorization ${name}: $($_.Exception.Message)" + continue + } + } + + # Scopes, by value. + $scopeByValue = @{} + foreach ($scope in (& $list "api_authorizations/$($server.id)/scopes")) { $scopeByValue[[string]$scope.value] = $scope } + foreach ($entry in (& $split $row.Scopes)) { + $value, $description = $entry -split '=', 2 + if ($scopeByValue.ContainsKey($value)) { continue } + if (-not $PSCmdlet.ShouldProcess("$name : $value", 'Create OneLogin API scope')) { continue } + try { + $scope = Invoke-OneLoginRequest -Method POST -Path "api_authorizations/$($server.id)/scopes" -Body @{ value = $value; description = $description } -Connection $connection + $scopeByValue[$value] = [PSCustomObject]@{ id = $scope.id; value = $value } + $scopesAdded++ + } + catch { $errors.Add("Could not create scope $value on ${name}: $($_.Exception.Message)") } + } + + # Claims, by name. + $claimNames = @((& $list "api_authorizations/$($server.id)/claims") | ForEach-Object { [string]$_.name }) + foreach ($entry in (& $split $row.Claims)) { + $claim, $source = $entry -split '=', 2 + if ($claimNames -contains $claim) { continue } + if (-not $PSCmdlet.ShouldProcess("$name : $claim", 'Create OneLogin API claim')) { continue } + try { + $null = Invoke-OneLoginRequest -Method POST -Path "api_authorizations/$($server.id)/claims" -Body @{ name = $claim; user_attribute_mappings = $source } -Connection $connection + $claimsAdded++ + } + catch { $errors.Add("Could not create claim $claim on ${name}: $($_.Exception.Message)") } + } + + # Clients: seeded apps, each with the scopes the data grants it. + $linked = @{} + foreach ($client in (& $list "api_authorizations/$($server.id)/clients")) { $linked[[string]$client.app_id] = @($client.scopes | ForEach-Object { [string]$_.value }) } + foreach ($entry in (& $split $row.Clients)) { + $appKey, $scopeList = $entry -split '=', 2 + $app = $appByName[$appNameByKey[$appKey]] + if (-not $app) { + $errors.Add("App '$appKey' for API authorization $name does not exist, or is not seeded; run New-OneLoginApp first") + continue + } + $wanted = @(([string]$scopeList -split ' ') | Where-Object { $_ }) + $scopeIds = @($wanted | ForEach-Object { if ($scopeByValue.ContainsKey($_)) { [long]$scopeByValue[$_].id } }) + if ($scopeIds.Count -ne $wanted.Count) { + $errors.Add("App '$appKey' is granted a scope that API authorization $name does not have") + continue + } + $have = if ($linked.ContainsKey([string]$app.id)) { @($linked[[string]$app.id]) } else { $null } + if ($null -ne $have -and @($wanted | Where-Object { $have -notcontains $_ }).Count -eq 0) { continue } + if (-not $PSCmdlet.ShouldProcess("$name <- $($app.name)", 'Let a OneLogin app ask for an API')) { continue } + try { + if ($null -eq $have) { + $null = Invoke-OneLoginRequest -Method POST -Path "api_authorizations/$($server.id)/clients" -Body @{ app_id = [long]$app.id; scopes = $scopeIds } -Connection $connection + } + else { + $null = Invoke-OneLoginRequest -Method PUT -Path "api_authorizations/$($server.id)/clients/$($app.id)" -Body @{ scopes = $scopeIds } -Connection $connection + } + $clientsLinked++ + } + catch { $errors.Add("Could not let $($app.name) ask for ${name}: $($_.Exception.Message)") } + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalApiAuthorizations = @($rows).Count + CreatedApiAuthorizations = $created.Count + ReusedApiAuthorizations = $reused.Count + ScopesAdded = $scopesAdded + ClaimsAdded = $claimsAdded + ClientsLinked = $clientsLinked + ApiAuthorizations = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginApp.ps1 b/Providers/OneLogin/Public/New-OneLoginApp.ps1 new file mode 100644 index 0000000..c056602 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginApp.ps1 @@ -0,0 +1,208 @@ +function New-OneLoginApp { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded OIDC and SAML apps and grants them to their roles + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$SaveAppSecret, + + [Parameter()] + [switch]$UseSecretStore, + + [Parameter()] + [System.Security.SecureString]$VaultPassword, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + # Passed only when given: -Tier $null fails the ValidateSet, which is how a seed with no -Tier + # once skipped every one of these steps. + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginApps.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $roleNameByKey = @{} + foreach ($roleRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginRoles.csv') -Encoding UTF8)) { + $roleNameByKey[$roleRow.Key] = Resolve-OneLoginSeedName -Key $roleRow.Name -Kind DisplayName -Connection $connection + } + # Only a role the seed may use: its own, or an empty one of its names. An app is never granted + # to a role that holds anybody else, because that would give them the app. + $roleByName = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -Connection $connection)) { $roleByName[[string]$role.name] = $role } + + $existing = @{} + foreach ($app in @(Invoke-OneLoginRequest -Method GET -Path 'apps' -Paginate -Connection $connection)) { + if ($null -ne $app -and $app.name) { $existing[[string]$app.name] = $app } + } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $grantsByRoleId = @{} + $secretsSaved = 0 + $secretsUnavailable = [System.Collections.Generic.List[string]]::new() + # Only a confidential client has a secret worth keeping. A public or native client + # authenticates with none, and a SAML app has no client at all. + $takesSecret = { param($row) $row.Connector -eq 'OIDC' -and @('Basic', 'Post') -contains $row.TokenAuth } + $savedAppIds = @{} + if ($SaveAppSecret) { + foreach ($record in @(Get-OneLoginAppSecretRecord -Subdomain $connection.Subdomain)) { $savedAppIds[$record.AppId] = $true } + } + + $split = { param($value) @(([string]$value -split ';') | Where-Object { $_ }) } + $expand = { param($value) ([string]$value).Replace('{domain}', $connection.EmailDomain) } + + foreach ($row in $rows) { + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + $appId = $null + + if ($existing.ContainsKey($name)) { + $app = $existing[$name] + if (-not ([string]$app.description).Contains($marker.Tag)) { + $errors.Add("App '$name' already exists without the seed tag in its description. It is somebody else's; it is left alone and granted to nothing.") + continue + } + Write-Verbose "App $name already exists; reusing it" + $appId = $app.id + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $appId; Connector = $row.Connector }) + if ($SaveAppSecret -and (& $takesSecret $row) -and -not $savedAppIds.ContainsKey([string]$appId)) { + # OneLogin shows a secret once, in the answer to the create. An app that already + # existed cannot have its secret saved now; saying so beats a record that is missing. + $secretsUnavailable.Add($row.Key) + Write-Warning "The client secret of $name was not saved: OneLogin shows it only when the app is created, and this app already existed. Remove it and seed again, or regenerate its secret in the portal." + } + } + else { + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin app')) { continue } + + # The tag inside Core's sentence, so an administrator who finds this app in a + # production portal is told what made it and that it is safe to delete. + $body = @{ + connector_id = $script:OneLoginConnector[$row.Connector] + name = $name + description = '{0}. {1}' -f $row.Description, $marker.Description + visible = [bool]::Parse($row.Visible) + } + if ($row.Connector -eq 'SAML') { + $consumer = & $expand $row.ConsumerUrl + $body['configuration'] = @{ + audience = & $expand $row.Audience + consumer_url = $consumer + recipient = $consumer + validator = '^{0}$' -f [regex]::Escape($consumer) + login = & $expand $row.LoginUrl + signature_algorithm = 'SHA-256' + } + } + else { + $configuration = @{ + redirect_uri = & $expand $row.RedirectUri + oidc_application_type = $(if ($row.AppType -eq 'Native') { 1 } else { 0 }) + token_endpoint_auth_method = $script:OneLoginTokenAuth[$row.TokenAuth] + } + if ($row.LoginUrl) { $configuration['login_url'] = & $expand $row.LoginUrl } + $body['configuration'] = $configuration + } + + try { + # The response carries the new client secret, and no later read of the app does. + # Unless -SaveAppSecret asks for it to be kept, protected, it is dropped here. + $response = Invoke-OneLoginRequest -Method POST -Path 'apps' -Body $body -Connection $connection + $appId = $response.id + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $appId; Connector = $row.Connector }) + Write-Verbose "Created app $name" + if ($SaveAppSecret -and (& $takesSecret $row)) { + $sso = $response.sso + if ($sso -and $sso.client_id -and $sso.client_secret) { + try { + $null = Export-OneLoginAppSecret -Subdomain $connection.Subdomain -AppId ([string]$appId) -AppKey $row.Key -AppName $name ` + -ClientId ([string]$sso.client_id) -ClientSecret ([string]$sso.client_secret) ` + -UseSecretStore:$UseSecretStore -VaultPassword $VaultPassword -Confirm:$false + $secretsSaved++ + } + catch { $errors.Add("App $name was created, but its client secret could not be saved: $($_.Exception.Message)") } + } + else { + $errors.Add("App $name was created, but OneLogin's answer carried no client secret to save.") + } + $sso = $null + } + $response = $null + } + catch { + $hint = '' + if ($_.Exception.Message -match 'limit') { $hint = ' The account''s plan allows no more apps; a OneLogin trial allows five.' } + $errors.Add("Could not create app ${name}: $($_.Exception.Message)$hint") + Write-Warning "Could not create app ${name}: $($_.Exception.Message)$hint" + continue + } + } + + foreach ($roleKey in (& $split $row.Roles)) { + if (-not $scope.Roles.Contains($roleKey)) { continue } + $role = $roleByName[$roleNameByKey[$roleKey]] + if (-not $role) { + $errors.Add("Role '$roleKey' for app $name does not exist, or is not one the seed may use; run New-OneLoginRole first") + continue + } + if (-not $grantsByRoleId.ContainsKey([string]$role.id)) { + $grantsByRoleId[[string]$role.id] = [PSCustomObject]@{ Role = $role; AppIds = New-Object System.Collections.Generic.List[string] } + } + $grantsByRoleId[[string]$role.id].AppIds.Add([string]$appId) + } + } + + # One request per role. The endpoint sets a role's apps, so what the role already holds is + # read and kept rather than trusted to survive. + $grantsApplied = 0 + foreach ($grant in $grantsByRoleId.Values) { + $current = @(Invoke-OneLoginRequest -Method GET -Path "roles/$($grant.Role.id)/apps" -Paginate -Connection $connection | + Where-Object { $null -ne $_ } | ForEach-Object { [string]$_.id }) + $missing = @($grant.AppIds | Where-Object { $current -notcontains $_ } | Sort-Object -Unique) + if ($missing.Count -eq 0) { continue } + + if (-not $PSCmdlet.ShouldProcess("$($grant.Role.name) <- $($missing.Count) app(s)", 'Grant OneLogin apps to role')) { continue } + + # Written as JSON here rather than handed over as an array: piped to ConvertTo-Json, a + # one-element array becomes the bare number, and the endpoint wants a list. + $all = @(@($current) + @($missing) | Sort-Object -Unique) + $json = '[{0}]' -f ($all -join ',') + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "roles/$($grant.Role.id)/apps" -Body $json -Connection $connection + $grantsApplied += $missing.Count + } + catch { + $errors.Add("Could not grant apps to role $($grant.Role.name): $($_.Exception.Message)") + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalApps = @($rows).Count + CreatedApps = $created.Count + ReusedApps = $reused.Count + GrantsApplied = $grantsApplied + SecretsSaved = $secretsSaved + SecretsUnavailable = $secretsUnavailable.ToArray() + Apps = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginAppRule.ps1 b/Providers/OneLogin/Public/New-OneLoginAppRule.ps1 new file mode 100644 index 0000000..725cbad --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginAppRule.ps1 @@ -0,0 +1,113 @@ +function New-OneLoginAppRule { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded app rules, which hand a seeded app's groups claim out by seeded role + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginAppRules.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $appNameByKey = @{} + foreach ($appRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginApps.csv') -Encoding UTF8)) { + $appNameByKey[$appRow.Key] = Resolve-OneLoginSeedName -Key $appRow.Name -Kind DisplayName -Connection $connection + } + $roleNameByKey = @{} + foreach ($roleRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginRoles.csv') -Encoding UTF8)) { + $roleNameByKey[$roleRow.Key] = Resolve-OneLoginSeedName -Key $roleRow.Name -Kind DisplayName -Connection $connection + } + + # A rule goes on a proved seeded app and names a role the seed may use. A rule naming somebody + # else's role would hand their holders a claim on a seeded app; one on somebody else's app would + # change what that app gives its real users. + $appByName = @{} + foreach ($app in @(Get-OneLoginSeededObject -Type Apps -Connection $connection)) { $appByName[[string]$app.name] = $app } + $roleByName = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -Connection $connection)) { $roleByName[[string]$role.name] = $role } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $rulesByApp = @{} + + foreach ($row in $rows) { + if (-not $scope.Roles.Contains($row.Role)) { + Write-Verbose "App rule $($row.Key) names role $($row.Role), which the tiers being seeded do not create; not creating it" + $skipped.Add($row.Key) + continue + } + + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + $app = $appByName[$appNameByKey[$row.App]] + $role = $roleByName[$roleNameByKey[$row.Role]] + if (-not $app) { $errors.Add("App '$($row.App)' for app rule $name does not exist, or is not seeded; run New-OneLoginApp first"); continue } + if (-not $role) { $errors.Add("Role '$($row.Role)' for app rule $name does not exist, or is not one the seed may use; run New-OneLoginRole first"); continue } + + if (-not $rulesByApp.ContainsKey([string]$app.id)) { + $rules = @{} + foreach ($query in @($null, @{ enabled = 'false' })) { + $arguments = @{ Method = 'GET'; Path = "apps/$($app.id)/rules"; Connection = $connection } + if ($query) { $arguments['Query'] = $query } + foreach ($rule in @(Invoke-OneLoginRequest @arguments | ForEach-Object { $_ } | Where-Object { $null -ne $_ })) { $rules[[string]$rule.name] = $rule } + } + $rulesByApp[[string]$app.id] = $rules + } + + if ($rulesByApp[[string]$app.id].ContainsKey($name)) { + Write-Verbose "App rule $name already exists on $($app.name); reusing it" + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $rulesByApp[[string]$app.id][$name].id; AppId = $app.id }) + continue + } + + if (-not $PSCmdlet.ShouldProcess("$($app.name) : $name", 'Create OneLogin app rule')) { continue } + + # The action is the provider's, not the data's: set the OIDC groups claim from member_of, + # which on a seeded person names only seeded groups in the lab domain. + $body = @{ + name = $name + match = 'all' + enabled = [bool]::Parse($row.Enabled) + conditions = @(@{ source = 'has_role'; operator = $row.Operator; value = [string]$role.id }) + actions = @(@{ action = 'set_groups'; value = @('member_of'); expression = $row.Expression }) + } + try { + $result = Invoke-OneLoginRequest -Method POST -Path "apps/$($app.id)/rules" -Body $body -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $result.id; AppId = $app.id }) + Write-Verbose "Created app rule $name on $($app.name)" + } + catch { + $errors.Add("Could not create app rule $name on $($app.name): $($_.Exception.Message)") + Write-Warning "Could not create app rule $name on $($app.name): $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalAppRules = @($rows).Count + CreatedAppRules = $created.Count + ReusedAppRules = $reused.Count + SkippedAppRules = $skipped.ToArray() + AppRules = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginCustomAttribute.ps1 b/Providers/OneLogin/Public/New-OneLoginCustomAttribute.ps1 new file mode 100644 index 0000000..82a6683 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginCustomAttribute.ps1 @@ -0,0 +1,64 @@ +function New-OneLoginCustomAttribute { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the custom user fields the seed needs, including its ownership marker + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Shortname, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginCustomAttributes.csv') -Encoding UTF8) + if ($Shortname) { $rows = @($rows | Where-Object { $Shortname -contains $_.Shortname }) } + + $existing = @{} + foreach ($field in @(Invoke-OneLoginRequest -Method GET -Path 'users/custom_attributes' -Connection $connection | ForEach-Object { $_ })) { + if ($null -ne $field -and $field.shortname) { $existing[[string]$field.shortname] = $field } + } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + + foreach ($row in $rows) { + if ($existing.ContainsKey($row.Shortname)) { + # Left as it is. The field is a name and a shortname and nothing else, so there is + # nothing to converge, and replacing it would drop the value every seeded user holds. + Write-Verbose "Custom field $($row.Shortname) already exists; reusing it" + $reused.Add([PSCustomObject]@{ Shortname = $row.Shortname; Id = $existing[$row.Shortname].id }) + continue + } + + if (-not $PSCmdlet.ShouldProcess($row.Shortname, 'Create OneLogin custom user field')) { continue } + + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'users/custom_attributes' ` + -Body @{ user_field = @{ name = $row.Name; shortname = $row.Shortname } } -Connection $connection + $created.Add([PSCustomObject]@{ Shortname = $row.Shortname; Id = $result.id }) + Write-Verbose "Created custom field $($row.Shortname)" + } + catch { + $errors.Add("Could not create custom field $($row.Shortname): $($_.Exception.Message)") + Write-Warning "Could not create custom field $($row.Shortname): $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalAttributes = @($rows).Count + CreatedAttributes = $created.Count + ReusedAttributes = $reused.Count + Attributes = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginEnvironment.ps1 b/Providers/OneLogin/Public/New-OneLoginEnvironment.ps1 new file mode 100644 index 0000000..ed9639d --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginEnvironment.ps1 @@ -0,0 +1,244 @@ +function New-OneLoginEnvironment { + <# + .SYNOPSIS + Seeds a OneLogin account with custom fields, roles, groups, apps, mappings and people + + .DESCRIPTION + Reached through New-TestEnvironment once a OneLogin connection is active. Runs every step + in the only order that works, and keeps going when a step fails so the summary can say + what did and did not happen rather than stopping at the first error. + + The order is dictated by what references what: + + 1. Attributes - the custom user fields, including the one every seeded user carries + the seed tag in. Nobody can be tagged before it exists. + 2. Roles - the roles the people being seeded will hold. + 3. Groups - the groups they will be placed in. + 4. Policies - user security policies, attached to seeded groups. + 5. Apps - granted to roles, so the roles come first. + 6. AppRules - entitlements on seeded apps by seeded role. + 7. ApiAuthorizations - API authorization servers, with seeded apps as their clients. + 8. Mappings - each adds a role; before the people, so that an enabled mapping + acts as each person is created. + 9. Hooks - the disabled Smart Hook, gated on a seeded role. + 10. SelfRegistration - the disabled, moderated sign-up profile. + 11. Users - created in manager order with their lifecycle, group, manager, + directory identifiers and custom fields, locked where the data says, + then added to their roles. + 12. Mfa - pre-verified factors, where the account already offers them. + + What is deliberately never done, and has no parameter: + + - No mapping is created without a condition requiring the seed tag, with match all. An + enabled mapping can therefore act on seeded people and nobody else, which is what makes + it safe to seed one into an account real people sign in to. + - Nobody who is not seeded is ever added to a role or group, given a manager, or made a + manager; and no app is granted to a role that holds anybody who is not. Every id the + seed sends comes from a user it created or proved. + - No role or group is created that no one being seeded will hold. OneLogin gives a role + nothing but its name, so teardown proves one by its members; an empty one could never + be claimed and would be left behind. + - No seeded object can reach anything real, or be reached by it. A policy is attached to + seeded groups only and is never the default; an app rule and a Smart Hook name seeded + roles only; the hook is always disabled; the sign-up profile is always disabled, + moderated and open to the lab domain alone; an API authorization serves seeded apps only; + a person's directory identifiers are all under the seed prefix or the lab domain, so no + directory connector or provisioning can link one to a real account. Risk rules are not + seeded at all, because a rule acts on every sign-in in the account. + - Nothing the account shipped with - the Default role, the default policy, the default + brand - is created, edited or deleted, and no account-wide setting is changed: an MFA + factor is enrolled only where the account already offers it. + + The whole run is idempotent: every step reuses what already exists and puts a reused + person back as the data describes, so a run that stopped halfway can simply be run again. + + .PARAMETER Skip + Steps to leave out. + + .PARAMETER Tier + Seed only the Core people (the hand-designed edge cases) or only the Bulk people (the + generated volume). Both by default. Roles, groups, apps and mappings follow the people: + one that nobody in the chosen tiers would hold is not created. + + .PARAMETER ShowProgress + Report progress per user. + + .PARAMETER SaveAppSecret + Keep the client secret of each confidential app the seed creates, protected, so a sign-in + can be tested against it with Get-OneLoginAppCredential. Off by default: nothing in the + module needs the secrets. Teardown deletes each saved secret with its app. + + .PARAMETER UseSecretStore + With -SaveAppSecret, keep the secrets in a SecretStore vault rather than DPAPI-protected in + their records. + + .PARAMETER VaultPassword + With -UseSecretStore, the vault's password when it is not the module default. + + .PARAMETER PassThru + Return the results object. + + .OUTPUTS + PSCustomObject describing every step, when -PassThru is used. + + .EXAMPLE + PS> New-TestEnvironment -WhatIf + + DESCRIPTION: Shows every object the seed would create, creating none + OUTPUT: A What if: line per object + USE CASE: Checking what a seed will do before running it against an account you care about + + .EXAMPLE + PS> New-TestEnvironment -Tier Core -PassThru + + DESCRIPTION: Seeds every object type with only the designed people + OUTPUT: The results object + USE CASE: The fast loop, when the test is behaviour rather than scale + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + .LINK + Connect-OneLoginEnvironment + Get-OneLoginEnvironmentReport + Remove-OneLoginEnvironment + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'The step summary is written for the person watching the seed run; the result object carries the same data for scripts.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSShouldProcess', '', + Justification = 'Delegated to the step functions, which each call ShouldProcess per object.')] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [ValidateSet('Attributes', 'Roles', 'Groups', 'Policies', 'Apps', 'AppRules', 'ApiAuthorizations', 'Mappings', 'Hooks', 'SelfRegistration', 'Users', 'Mfa')] + [string[]]$Skip = @(), + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$ShowProgress, + + [Parameter()] + [switch]$SaveAppSecret, + + [Parameter()] + [switch]$UseSecretStore, + + [Parameter()] + [System.Security.SecureString]$VaultPassword, + + [Parameter()] + [switch]$PassThru + ) + + $correlationId = [Guid]::NewGuid() + Write-Verbose "Starting New-OneLoginEnvironment - CorrelationId: $correlationId" + + $connection = Get-OneLoginConnection + $startTime = Get-Date + + Write-TestMessage -Message ('OneLogin Test Environment Creation ({0}.onelogin.com)' -f $connection.Subdomain) -Type Header + + # Built here, rather than read from inside each step's scriptblock, so the parameters are + # visibly used. Every step but the fields takes the tier, because what exists follows who does. + $tierArguments = @{ PassThru = $true } + if ($Tier) { $tierArguments['Tier'] = $Tier } + $userArguments = @{ PassThru = $true; ShowProgress = $ShowProgress } + if ($Tier) { $userArguments['Tier'] = $Tier } + $appArguments = @{} + $tierArguments + if ($SaveAppSecret) { + $appArguments['SaveAppSecret'] = $true + $appArguments['UseSecretStore'] = $UseSecretStore + if ($VaultPassword) { $appArguments['VaultPassword'] = $VaultPassword } + } + + $plan = @( + @{ Name = 'Attributes'; Label = 'Creating custom user fields'; Run = { New-OneLoginCustomAttribute -PassThru } } + @{ Name = 'Roles'; Label = 'Creating roles'; Run = { New-OneLoginRole @tierArguments } } + @{ Name = 'Groups'; Label = 'Creating groups'; Run = { New-OneLoginGroup @tierArguments } } + @{ Name = 'Policies'; Label = 'Creating user security policies and attaching them to groups'; Run = { New-OneLoginPolicy @tierArguments } } + @{ Name = 'Apps'; Label = 'Creating apps and granting them to roles'; Run = { New-OneLoginApp @appArguments } } + @{ Name = 'AppRules'; Label = 'Creating app rules'; Run = { New-OneLoginAppRule @tierArguments } } + @{ Name = 'ApiAuthorizations'; Label = 'Creating API authorization servers'; Run = { New-OneLoginApiAuthorization -PassThru } } + @{ Name = 'Mappings'; Label = 'Creating mappings'; Run = { New-OneLoginMapping @tierArguments } } + @{ Name = 'Hooks'; Label = 'Creating the disabled Smart Hook'; Run = { New-OneLoginSmartHook @tierArguments } } + @{ Name = 'SelfRegistration'; Label = 'Creating the disabled self-registration profile'; Run = { New-OneLoginSelfRegistration -PassThru } } + @{ Name = 'Users'; Label = 'Creating users, their managers and their roles'; Run = { New-OneLoginUser @userArguments } } + @{ Name = 'Mfa'; Label = 'Enrolling MFA factors where the account offers them'; Run = { New-OneLoginMfaFactor @tierArguments } } + ) + + $steps = [System.Collections.Generic.List[object]]::new() + $number = 0 + + foreach ($step in $plan) { + $number++ + if ($Skip -contains $step.Name) { + Write-Host ('Step {0}: Skipping {1}' -f $number, $step.Name.ToLowerInvariant()) + $steps.Add([PSCustomObject]@{ Name = $step.Name; Attempted = $false; Success = $false; Result = $null; Errors = @() }) + continue + } + + Write-Host ('Step {0}: {1}' -f $number, $step.Label) + + try { + $result = & $step.Run + $stepErrors = @() + if ($result -and $result.Errors) { $stepErrors = @($result.Errors) } + $steps.Add([PSCustomObject]@{ + Name = $step.Name + Attempted = $true + Success = ($stepErrors.Count -eq 0) + Result = $result + Errors = $stepErrors + }) + } + catch { + # A step that throws is recorded rather than allowed to end the run, so the steps after + # it that do not depend on it still happen and the summary says what stopped. + Write-Warning "Step $($step.Name) failed: $($_.Exception.Message)" + $steps.Add([PSCustomObject]@{ + Name = $step.Name + Attempted = $true + Success = $false + Result = $null + Errors = @("Step $($step.Name) failed: $($_.Exception.Message)") + }) + } + } + + $endTime = Get-Date + $attempted = @($steps | Where-Object Attempted) + $failed = @($attempted | Where-Object { -not $_.Success }) + + Write-TestMessage -Message 'Environment Creation Summary' -Type Header + Write-Host ('Operations Completed: {0}/{1}' -f ($attempted.Count - $failed.Count), $attempted.Count) + Write-Host ('Duration: {0:hh\:mm\:ss}' -f ($endTime - $startTime)) + foreach ($step in $failed) { + foreach ($message in $step.Errors) { Write-Host (' ! {0}' -f $message) } + } + + if ($PassThru) { + return [PSCustomObject]@{ + CorrelationId = $correlationId + Subdomain = $connection.Subdomain + Prefix = $connection.Prefix + StartTime = $startTime + EndTime = $endTime + Duration = $endTime - $startTime + Skipped = @($Skip) + Steps = $steps.ToArray() + Summary = [PSCustomObject]@{ + TotalSteps = $steps.Count + AttemptedSteps = $attempted.Count + SuccessfulSteps = $attempted.Count - $failed.Count + FailedSteps = $failed.Count + } + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginGroup.ps1 b/Providers/OneLogin/Public/New-OneLoginGroup.ps1 new file mode 100644 index 0000000..bee4e16 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginGroup.ps1 @@ -0,0 +1,88 @@ +function New-OneLoginGroup { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded groups that the people being seeded will be placed in + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + # Passed only when given: -Tier $null fails the ValidateSet, which is how a seed with no -Tier + # once skipped every one of these steps. + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginGroups.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $byName = @{} + foreach ($group in @(Invoke-OneLoginRequest -Method GET -Path 'groups' -Paginate -Connection $connection)) { + if ($null -ne $group -and $group.name) { $byName[[string]$group.name] = $group } + } + $usable = @{} + foreach ($group in @(Get-OneLoginSeededObject -Type Groups -AllowEmpty -Connection $connection)) { $usable[[string]$group.id] = $true } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + + foreach ($row in $rows) { + if (-not $scope.Groups.Contains($row.Key)) { + Write-Verbose "Group $($row.Key) has no member in the tiers being seeded; not creating it" + $skipped.Add($row.Key) + continue + } + + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + + if ($byName.ContainsKey($name)) { + $group = $byName[$name] + if ($usable.ContainsKey([string]$group.id)) { + Write-Verbose "Group $name already exists; reusing it" + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $group.id }) + } + else { + $errors.Add("Group '$name' already exists and holds users, a policy or administrators this module did not seed. It is left alone, and nobody will be put in it.") + } + continue + } + + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin group')) { continue } + + # A name and nothing else. No policy: the seed creates none, and attaching one that exists + # would change how its real members sign in. + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'groups' -Body @{ name = $name } -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $result.id }) + Write-Verbose "Created group $name" + } + catch { + $errors.Add("Could not create group ${name}: $($_.Exception.Message)") + Write-Warning "Could not create group ${name}: $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalGroups = @($rows).Count + CreatedGroups = $created.Count + ReusedGroups = $reused.Count + SkippedGroups = $skipped.ToArray() + Groups = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginMapping.ps1 b/Providers/OneLogin/Public/New-OneLoginMapping.ps1 new file mode 100644 index 0000000..c86b01d --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginMapping.ps1 @@ -0,0 +1,132 @@ +function New-OneLoginMapping { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded user mappings, each gated so it can only ever act on seeded people + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + # Passed only when given: -Tier $null fails the ValidateSet, which is how a seed with no -Tier + # once skipped every one of these steps. + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginMappings.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $roleNameByKey = @{} + foreach ($roleRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginRoles.csv') -Encoding UTF8)) { + $roleNameByKey[$roleRow.Key] = Resolve-OneLoginSeedName -Key $roleRow.Name -Kind DisplayName -Connection $connection + } + $roleByName = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -Connection $connection)) { $roleByName[[string]$role.name] = $role } + + # Every mapping, enabled or not: the endpoint lists only the enabled ones unless asked. + $existing = @{} + foreach ($mapping in @( + Invoke-OneLoginRequest -Method GET -Path 'mappings' -Connection $connection | ForEach-Object { $_ } + Invoke-OneLoginRequest -Method GET -Path 'mappings' -Query @{ enabled = 'false' } -Connection $connection | ForEach-Object { $_ } + )) { + if ($null -ne $mapping -and $mapping.name) { $existing[[string]$mapping.name] = $mapping } + } + + # The gate. Written on every mapping whatever the data says, with match 'all', so the mapping + # fires only for a user whose seed tag field holds the tag - which nobody but this module + # writes. An enabled mapping in a production account therefore touches seeded people and no + # one else. There is no parameter to leave it out, and a test asserts there is none. + $gate = @{ source = ('custom_attribute_{0}' -f $script:OneLoginSeedAttribute); operator = '='; value = $marker.Tag } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + + foreach ($row in $rows) { + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + + if (-not $scope.Roles.Contains($row.Role)) { + Write-Verbose "Mapping $name adds role $($row.Role), which the tiers being seeded do not create; not creating it" + $skipped.Add($row.Key) + continue + } + + if ($existing.ContainsKey($name)) { + $mapping = $existing[$name] + $gated = @($mapping.conditions | Where-Object { + $null -ne $_ -and [string]$_.source -ceq $gate.source -and [string]$_.operator -eq '=' -and + [string]::Equals([string]$_.value, $gate.value, [StringComparison]::Ordinal) + }).Count -gt 0 + if ($gated -and [string]$mapping.match -eq 'all') { + Write-Verbose "Mapping $name already exists; reusing it" + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $mapping.id; Enabled = [bool]$mapping.enabled }) + } + else { + $errors.Add("Mapping '$name' already exists without the seed-tag condition. It is somebody else's and is left alone.") + } + continue + } + + $role = $roleByName[$roleNameByKey[$row.Role]] + if (-not $role) { + $errors.Add("Role '$($row.Role)' for mapping $name does not exist, or is not one the seed may use; run New-OneLoginRole first") + continue + } + + $conditions = New-Object System.Collections.Generic.List[object] + $conditions.Add($gate) + foreach ($entry in @(([string]$row.Conditions -split ';') | Where-Object { $_ })) { + $parts = $entry -split '\|', 3 + if ($parts.Count -ne 3) { + $errors.Add("Mapping $name has a condition '$entry' that is not source|operator|value") + continue + } + $conditions.Add(@{ source = $parts[0]; operator = $parts[1]; value = $parts[2] }) + } + + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin mapping')) { continue } + + $body = @{ + name = $name + match = 'all' + enabled = [bool]::Parse($row.Enabled) + conditions = $conditions.ToArray() + actions = @(@{ action = 'add_role'; value = @([string]$role.id) }) + } + + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'mappings' -Body $body -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $result.id; Enabled = $body.enabled }) + Write-Verbose "Created mapping $name" + } + catch { + $errors.Add("Could not create mapping ${name}: $($_.Exception.Message)") + Write-Warning "Could not create mapping ${name}: $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalMappings = @($rows).Count + CreatedMappings = $created.Count + ReusedMappings = $reused.Count + SkippedMappings = $skipped.ToArray() + Mappings = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginMfaFactor.ps1 b/Providers/OneLogin/Public/New-OneLoginMfaFactor.ps1 new file mode 100644 index 0000000..6291572 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginMfaFactor.ps1 @@ -0,0 +1,84 @@ +function New-OneLoginMfaFactor { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Pre-enrols the seeded people's MFA factors, where the account already offers the factor + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Username, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginUsers.csv') -Encoding UTF8 | Where-Object { $_.Mfa }) + if ($Tier) { $rows = @($rows | Where-Object { $Tier -contains $_.Tier }) } + if ($Username) { $rows = @($rows | Where-Object { $Username -contains $_.Key }) } + + # Proved seeded people only. A factor is enrolled on nobody else, and only ever already verified, + # so no code is sent anywhere - and a seeded address is at the lab domain, which cannot receive. + $seededByLogin = @{} + foreach ($user in @(Get-OneLoginSeededObject -Type Users -Connection $connection)) { $seededByLogin[([string]$user.username).ToLowerInvariant()] = $user } + + $enrolled = [System.Collections.Generic.List[object]]::new() + $existing = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + + foreach ($row in $rows) { + $login = Resolve-OneLoginSeedName -Key $row.Key -Kind Username -Connection $connection + $user = $seededByLogin[$login.ToLowerInvariant()] + if (-not $user) { $errors.Add("$login is not seeded; run New-OneLoginUser first"); continue } + + # Which factors the account offers this person. The seed does not turn a factor on: that is an + # account-wide setting, and changing it would change what every real person is offered. + $available = @(Invoke-OneLoginRequest -Method GET -Path "mfa/users/$($user.id)/factors" -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + $factor = @($available | Where-Object { ([string]$_.name -like "*$($row.Mfa)*") -or ([string]$_.auth_factor_name -like "*$($row.Mfa)*") }) | Select-Object -First 1 + if (-not $factor) { + $skipped.Add(("{0}: the account offers no {1} factor. Enable OneLogin {1} under Authentication Factors, and allow it in the user's policy, to seed it." -f $login, $row.Mfa)) + continue + } + + $devices = @(Invoke-OneLoginRequest -Method GET -Path "mfa/users/$($user.id)/devices" -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + if (@($devices | Where-Object { ([string]$_.auth_factor_name -like "*$($row.Mfa)*") -or ([string]$_.type_display_name -like "*$($row.Mfa)*") })) { + $existing.Add([PSCustomObject]@{ Key = $row.Key; Username = $login; Factor = $row.Mfa }) + continue + } + + if (-not $PSCmdlet.ShouldProcess("$login : $($row.Mfa)", 'Enrol a verified OneLogin MFA factor')) { continue } + try { + $null = Invoke-OneLoginRequest -Method POST -Path "mfa/users/$($user.id)/registrations" -Connection $connection -Body @{ + factor_id = $factor.factor_id + display_name = Resolve-OneLoginSeedName -Key $row.Mfa -Kind DisplayName -Connection $connection + verified = $true + } + $enrolled.Add([PSCustomObject]@{ Key = $row.Key; Username = $login; Factor = $row.Mfa }) + } + catch { $errors.Add("Could not enrol $($row.Mfa) for ${login}: $($_.Exception.Message)") } + } + + if ($skipped.Count -gt 0) { + Write-Verbose ("MFA factors not seeded: {0}" -f ($skipped -join '; ')) + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalFactors = @($rows).Count + EnrolledFactors = $enrolled.Count + ExistingFactors = $existing.Count + Skipped = $skipped.ToArray() + Factors = (@($enrolled) + @($existing)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginPolicy.ps1 b/Providers/OneLogin/Public/New-OneLoginPolicy.ps1 new file mode 100644 index 0000000..39dae6d --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginPolicy.ps1 @@ -0,0 +1,148 @@ +function New-OneLoginPolicy { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded user security policies and attaches each to seeded groups only + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginPolicies.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $groupNameByKey = @{} + foreach ($groupRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginGroups.csv') -Encoding UTF8)) { + $groupNameByKey[$groupRow.Key] = Resolve-OneLoginSeedName -Key $groupRow.Name -Kind DisplayName -Connection $connection + } + + # Groups the seed may attach a policy to: its own, or an empty one of its names. A policy is never + # attached to a group holding anybody else, because it would then govern how they sign in. + $ownedUserId = @(Get-OneLoginSeededObject -Type Users -Connection $connection | ForEach-Object { [string]$_.id }) + $groupByName = @{} + foreach ($group in @(Get-OneLoginSeededObject -Type Groups -AllowEmpty -OwnedUserId $ownedUserId -Connection $connection)) { + $groupByName[[string]$group.name] = $group + } + $usableGroupId = @($groupByName.Values | ForEach-Object { [string]$_.id }) + + $byName = @{} + foreach ($policy in @(Invoke-OneLoginRequest -Method GET -Path 'policies' -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ })) { + $byName[[string]$policy.name] = $policy + } + $usable = @{} + foreach ($policy in @(Get-OneLoginSeededObject -Type Policies -AllowEmpty -OwnedUserId $ownedUserId -OwnedGroupId $usableGroupId -Connection $connection)) { + $usable[[string]$policy.id] = $true + } + + # The settings the data can carry, by column and by OneLogin field. Nothing else about a policy + # is written; in particular is_default is never sent. + $settingField = [ordered]@{ + MinimumPasswordLength = 'minimum_password_length' + PasswordExpirationDays = 'password_expiration_days' + PasswordsRemembered = 'passwords_remembered' + MaximumInvalidLoginAttempts = 'maximum_invalid_login_attempts' + LockEffectiveMinutes = 'lock_effective_minutes' + } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $settingsUpdated = 0 + $attached = 0 + + foreach ($row in $rows) { + $groupKeys = @(([string]$row.Groups -split ';') | Where-Object { $_ -and $scope.Groups.Contains($_) }) + if ($groupKeys.Count -eq 0) { + # Proved only by the seeded groups using it, so a policy no group in the tiers will use + # could never be claimed. Not made rather than made and stranded. + Write-Verbose "Policy $($row.Key) has no group in the tiers being seeded; not creating it" + $skipped.Add($row.Key) + continue + } + + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + $settings = [ordered]@{} + foreach ($column in $settingField.Keys) { if ($row.$column) { $settings[$settingField[$column]] = [int]$row.$column } } + + $policy = $null + if ($byName.ContainsKey($name)) { + $policy = $byName[$name] + if (-not $usable.ContainsKey([string]$policy.id)) { + $errors.Add("Policy '$name' already exists and is the default, or is used by groups this module did not seed. It is left alone and attached to nothing.") + continue + } + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $policy.id }) + + # Put its settings back as the data describes. + $current = Invoke-OneLoginRequest -Method GET -Path "policies/$($policy.id)" -Connection $connection + $change = [ordered]@{} + foreach ($field in $settings.Keys) { if ([string]$current.$field -ne [string]$settings[$field]) { $change[$field] = $settings[$field] } } + if ($change.Count -gt 0 -and $PSCmdlet.ShouldProcess($name, ('Update OneLogin policy ({0})' -f (@($change.Keys) -join ', ')))) { + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "policies/$($policy.id)" -Body $change -Connection $connection + $settingsUpdated++ + } + catch { $errors.Add("Could not update policy ${name}: $($_.Exception.Message)") } + } + } + else { + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin user policy')) { continue } + $body = [ordered]@{ name = $name; kind = 'user' } + foreach ($field in $settings.Keys) { $body[$field] = $settings[$field] } + try { + $policy = Invoke-OneLoginRequest -Method POST -Path 'policies' -Body $body -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $policy.id }) + Write-Verbose "Created policy $name" + } + catch { + $errors.Add("Could not create policy ${name}: $($_.Exception.Message)") + Write-Warning "Could not create policy ${name}: $($_.Exception.Message)" + continue + } + } + + foreach ($groupKey in $groupKeys) { + $group = $groupByName[$groupNameByKey[$groupKey]] + if (-not $group) { + $errors.Add("Group '$groupKey' for policy $name does not exist, or is not one the seed may use; run New-OneLoginGroup first") + continue + } + if ([string]$group.policy_id -eq [string]$policy.id) { continue } + if (-not $PSCmdlet.ShouldProcess("$($group.name) <- $name", 'Attach OneLogin policy to group')) { continue } + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "groups/$($group.id)" -Body @{ policy_id = [long]$policy.id } -Connection $connection + $attached++ + } + catch { $errors.Add("Could not attach policy $name to group $($group.name): $($_.Exception.Message)") } + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalPolicies = @($rows).Count + CreatedPolicies = $created.Count + ReusedPolicies = $reused.Count + SkippedPolicies = $skipped.ToArray() + SettingsUpdated = $settingsUpdated + GroupsAttached = $attached + Policies = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginRole.ps1 b/Providers/OneLogin/Public/New-OneLoginRole.ps1 new file mode 100644 index 0000000..b833909 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginRole.ps1 @@ -0,0 +1,95 @@ +function New-OneLoginRole { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded roles that the people being seeded will hold + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + # Passed only when given: -Tier $null fails the ValidateSet, which is how a seed with no -Tier + # once skipped every one of these steps. + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginRoles.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + # Every role in the account carrying a name the seed would use, and separately the ones the + # seed may reuse: empty, or holding only seeded people and apps. A prefixed role holding + # anybody else is somebody else's, however it is named. + $byName = @{} + foreach ($role in @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $connection)) { + if ($null -ne $role -and $role.name) { $byName[[string]$role.name] = $role } + } + $usable = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -Connection $connection)) { $usable[[string]$role.id] = $true } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + + foreach ($row in $rows) { + if (-not $scope.Roles.Contains($row.Key)) { + # Nobody in the tiers being seeded holds it, and an empty role cannot be proved ours + # at teardown. Not made rather than made and stranded. + Write-Verbose "Role $($row.Key) has no member in the tiers being seeded; not creating it" + $skipped.Add($row.Key) + continue + } + + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + + if ($byName.ContainsKey($name)) { + $role = $byName[$name] + if ($usable.ContainsKey([string]$role.id)) { + Write-Verbose "Role $name already exists; reusing it" + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $role.id }) + } + else { + $errors.Add("Role '$name' already exists and holds users, apps or administrators this module did not seed. It is left alone, and nothing will be added to it.") + } + continue + } + + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin role')) { continue } + + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'roles' -Body @{ name = $name } -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $result.id }) + Write-Verbose "Created role $name" + } + catch { + # OneLogin's own words for a plan limit are "max role limit is exceed", which does not + # say how many; the trial's number is worth saying. + $hint = '' + if ($_.Exception.Message -match 'limit') { $hint = ' The account''s plan allows no more roles; a OneLogin trial allows five, the Default role among them.' } + $errors.Add("Could not create role ${name}: $($_.Exception.Message)$hint") + Write-Warning "Could not create role ${name}: $($_.Exception.Message)$hint" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalRoles = @($rows).Count + CreatedRoles = $created.Count + ReusedRoles = $reused.Count + SkippedRoles = $skipped.ToArray() + Roles = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginSelfRegistration.ps1 b/Providers/OneLogin/Public/New-OneLoginSelfRegistration.ps1 new file mode 100644 index 0000000..09f14a2 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginSelfRegistration.ps1 @@ -0,0 +1,100 @@ +function New-OneLoginSelfRegistration { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded self-registration profile, always disabled, moderated and open to the lab domain only + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + + $rows = @(Import-Csv -LiteralPath (Join-Path (Get-OneLoginDataPath) 'OneLoginSelfRegistrations.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $listed = Invoke-OneLoginRequest -Method GET -Path 'self_registration_profiles' -Connection $connection + $byName = @{} + foreach ($summary in @($listed.self_registration_profiles | Where-Object { $null -ne $_ })) { $byName[[string]$summary.name] = $summary } + $ours = @{} + foreach ($registration in @(Get-OneLoginSeededObject -Type SelfRegistration -Connection $connection)) { $ours[[string]$registration.id] = $registration } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $restored = 0 + + foreach ($row in $rows) { + $name = Resolve-OneLoginSeedName -Key $row.Name -Kind DisplayName -Connection $connection + + # The safe shape, and nothing else, has no parameter: disabled, so there is no public page; + # moderated, so even an enabled one admits nobody without an administrator; open only to the + # lab domain, which RFC 2606 reserves, so no real address can register; and no default role + # or group, so a registrant could never land in anything. + $safe = [ordered]@{ + name = $name + url = (Resolve-OneLoginSeedName -Key $row.Key -Kind Username -Connection $connection) + enabled = $false + moderated = $true + helptext = $marker.Description + email_verification_type = 'Email MagicLink' + domain_list_strategy = 0 + domain_whitelist = [string]$connection.EmailDomain + default_role_id = $null + default_group_id = $null + } + + if ($byName.ContainsKey($name)) { + $summary = $byName[$name] + $registration = $ours[[string]$summary.id] + if (-not $registration) { + $errors.Add("Self-registration profile '$name' already exists without the seed tag in its help text. It is somebody else's; it is left alone.") + continue + } + $reused.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $registration.id }) + + # Put the safe shape back if anybody loosened it. + $loosened = $registration.enabled -or -not $registration.moderated -or $registration.default_role_id -or $registration.default_group_id -or + ([string]$registration.domain_whitelist -ne [string]$connection.EmailDomain) + if ($loosened -and $PSCmdlet.ShouldProcess($name, 'Restore the seeded self-registration profile to disabled and moderated')) { + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "self_registration_profiles/$($registration.id)" -Body @{ self_registration_profile = $safe } -Connection $connection + $restored++ + } + catch { $errors.Add("Could not restore self-registration profile ${name}: $($_.Exception.Message)") } + } + continue + } + + if (-not $PSCmdlet.ShouldProcess($name, 'Create OneLogin self-registration profile')) { continue } + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'self_registration_profiles' -Body @{ self_registration_profile = $safe } -Connection $connection + $id = if ($result.self_registration_profile) { $result.self_registration_profile.id } else { $result.id } + $created.Add([PSCustomObject]@{ Key = $row.Key; Name = $name; Id = $id }) + Write-Verbose "Created self-registration profile $name, disabled" + } + catch { + $errors.Add("Could not create self-registration profile ${name}: $($_.Exception.Message)") + Write-Warning "Could not create self-registration profile ${name}: $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalSelfRegistrations = @($rows).Count + CreatedSelfRegistrations = $created.Count + ReusedSelfRegistrations = $reused.Count + Restored = $restored + SelfRegistrations = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginSmartHook.ps1 b/Providers/OneLogin/Public/New-OneLoginSmartHook.ps1 new file mode 100644 index 0000000..8ef2d3c --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginSmartHook.ps1 @@ -0,0 +1,117 @@ +function New-OneLoginSmartHook { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded Smart Hook, always disabled and gated on a seeded role + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Key, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + $scope = if ($Tier) { Get-OneLoginSeedScope -Tier $Tier } else { Get-OneLoginSeedScope } + $dataPath = Get-OneLoginDataPath + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginHooks.csv') -Encoding UTF8) + if ($Key) { $rows = @($rows | Where-Object { $Key -contains $_.Key }) } + + $roleNameByKey = @{} + foreach ($roleRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginRoles.csv') -Encoding UTF8)) { + $roleNameByKey[$roleRow.Key] = Resolve-OneLoginSeedName -Key $roleRow.Name -Kind DisplayName -Connection $connection + } + $roleByName = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -Connection $connection)) { $roleByName[[string]$role.name] = $role } + $usableRoleId = @($roleByName.Values | ForEach-Object { [string]$_.id }) + + # The seeded hooks already there, found by the marker in their code. + $ours = @(Get-OneLoginSeededObject -Type Hooks -OwnedRoleId $usableRoleId -Connection $connection) + + # The code. The marker line is what proves the hook ours; the handler changes nothing about a + # sign-in - it hands back the policy the person already has. + $code = ($script:OneLoginHookMarker -f $marker.Description) + "`n" + + "exports.handler = async (context) => {`n return { success: true, user: { policy_id: context.user.policy_id } };`n};`n" + $function = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($code)) + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $skipped = [System.Collections.Generic.List[string]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $disabledAgain = 0 + + foreach ($row in $rows) { + if (-not $scope.Roles.Contains($row.Role)) { + Write-Verbose "Hook $($row.Key) is gated on role $($row.Role), which the tiers being seeded do not create; not creating it" + $skipped.Add($row.Key) + continue + } + $role = $roleByName[$roleNameByKey[$row.Role]] + if (-not $role) { $errors.Add("Role '$($row.Role)' for hook $($row.Key) does not exist, or is not one the seed may use; run New-OneLoginRole first"); continue } + + # Always disabled, and always gated on a seeded role, so that even if somebody enabled it, it + # would run for seeded people and nobody else. Neither has a parameter. + $body = [ordered]@{ + type = $row.Type + function = $function + disabled = $true + runtime = 'nodejs22.x' + retries = 0 + timeout = 1 + options = @{ risk_enabled = $false; location_enabled = $false; mfa_device_info_enabled = $false } + env_vars = @() + packages = @{} + conditions = @(@{ source = 'roles'; operator = '~'; value = [string]$role.id }) + } + + $existing = @($ours | Where-Object { [string]$_.type -eq [string]$row.Type }) + if ($existing) { + $hook = $existing[0] + $reused.Add([PSCustomObject]@{ Key = $row.Key; Id = $hook.id; Type = $hook.type }) + if (-not $hook.disabled -and $PSCmdlet.ShouldProcess($hook.name, 'Disable OneLogin Smart Hook')) { + # A seeded hook is never left enabled. Put back whole, because the endpoint replaces. + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "hooks/$($hook.id)" -Body $body -Connection $connection + $disabledAgain++ + } + catch { $errors.Add("Could not disable the seeded $($row.Type) hook: $($_.Exception.Message)") } + } + continue + } + + if (-not $PSCmdlet.ShouldProcess("$($row.Type) hook gated on $($role.name)", 'Create OneLogin Smart Hook')) { continue } + try { + $result = Invoke-OneLoginRequest -Method POST -Path 'hooks' -Body $body -Connection $connection + $created.Add([PSCustomObject]@{ Key = $row.Key; Id = $result.id; Type = $row.Type }) + Write-Verbose "Created the $($row.Type) hook, disabled" + } + catch { + # OneLogin allows one hook of a type per account; an account that already has its own is + # left with it. + $errors.Add("Could not create the $($row.Type) hook: $($_.Exception.Message). An account can hold one hook of a type; if it already has its own, pass -Skip Hooks.") + Write-Warning "Could not create the $($row.Type) hook: $($_.Exception.Message)" + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalHooks = @($rows).Count + CreatedHooks = $created.Count + ReusedHooks = $reused.Count + SkippedHooks = $skipped.ToArray() + DisabledAgain = $disabledAgain + Hooks = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/New-OneLoginUser.ps1 b/Providers/OneLogin/Public/New-OneLoginUser.ps1 new file mode 100644 index 0000000..8b71cd8 --- /dev/null +++ b/Providers/OneLogin/Public/New-OneLoginUser.ps1 @@ -0,0 +1,355 @@ +function New-OneLoginUser { + <# + .EXTERNALHELP TestEnvironment-Help.xml + .SYNOPSIS + Creates the seeded people with their lifecycle, group, manager and roles, and puts a reused one back as the data describes + #> + + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [string[]]$Username, + + [Parameter()] + [ValidateSet('Core', 'Bulk')] + [string[]]$Tier, + + [Parameter()] + [switch]$ShowProgress, + + [Parameter()] + [switch]$PassThru + ) + + $connection = Get-OneLoginConnection + $marker = Get-OneLoginSeedMarker -Prefix $connection.Prefix + $dataPath = Get-OneLoginDataPath + $attribute = $script:OneLoginSeedAttribute + + $rows = @(Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginUsers.csv') -Encoding UTF8) + if ($Tier) { $rows = @($rows | Where-Object { $Tier -contains $_.Tier }) } + if ($Username) { $rows = @($rows | Where-Object { $Username -contains $_.Key }) } + + $groupNameByKey = @{} + foreach ($groupRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginGroups.csv') -Encoding UTF8)) { + $groupNameByKey[$groupRow.Key] = Resolve-OneLoginSeedName -Key $groupRow.Name -Kind DisplayName -Connection $connection + } + $roleNameByKey = @{} + $roleDataName = @{} + foreach ($roleRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginRoles.csv') -Encoding UTF8)) { + $roleNameByKey[$roleRow.Key] = Resolve-OneLoginSeedName -Key $roleRow.Name -Kind DisplayName -Connection $connection + $roleDataName[$roleRow.Key] = $roleRow.Name + } + $groupDataName = @{} + foreach ($groupRow in (Import-Csv -LiteralPath (Join-Path $dataPath 'OneLoginGroups.csv') -Encoding UTF8)) { $groupDataName[$groupRow.Key] = $groupRow.Name } + + # The people already seeded, by username, and the roles and groups the seed may put people in: + # its own, or empty ones of its names. Nobody is ever added to a role or group holding someone + # this module did not make, and only ids from this list and from users created below are ever + # sent anywhere, so no real person can be moved, managed or given a role by the seed. + $seededByLogin = @{} + $idByKey = @{} + foreach ($user in @(Get-OneLoginSeededObject -Type Users -Connection $connection)) { + $login = ([string]$user.username).ToLowerInvariant() + $seededByLogin[$login] = $user + $idByKey[$login.Substring($marker.Prefix.Length)] = [string]$user.id + } + $ownedIds = @($idByKey.Values) + $groupByName = @{} + foreach ($group in @(Get-OneLoginSeededObject -Type Groups -AllowEmpty -OwnedUserId $ownedIds -Connection $connection)) { + $groupByName[[string]$group.name] = $group + } + $roleByName = @{} + foreach ($role in @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -OwnedUserId $ownedIds -Connection $connection)) { + $roleByName[[string]$role.name] = $role + } + + $created = [System.Collections.Generic.List[object]]::new() + $reused = [System.Collections.Generic.List[object]]::new() + $errors = [System.Collections.Generic.List[string]]::new() + $updated = 0 + $deferredManager = [System.Collections.Generic.List[object]]::new() + $toLock = [System.Collections.Generic.List[object]]::new() + $roleMembers = @{} + $index = 0 + + foreach ($row in $rows) { + $index++ + $login = Resolve-OneLoginSeedName -Key $row.Key -Kind Username -Connection $connection + Write-TestProgress -Activity 'Creating OneLogin users' -Status $login ` + -PercentComplete ([int](100 * $index / [Math]::Max(1, $rows.Count))) -ShowProgress:$ShowProgress + + if (-not $script:OneLoginUserStatus.Contains($row.Status) -or -not $script:OneLoginUserState.Contains($row.State)) { + $errors.Add("User $login has status '$($row.Status)' and state '$($row.State)', which the provider does not know") + continue + } + + # What the data says the person is. Locked is not a status the seed sends - a status of 3 + # unlocks itself fifteen minutes later - so a Locked person is created Active and locked + # afterwards through the lock call, and their status is never sent again. + $locked = $row.Status -eq 'Locked' + $desired = [ordered]@{ + firstname = $row.GivenName + lastname = $row.Surname + title = $row.Title + department = $row.Department + company = $row.Company + status = $(if ($locked) { $script:OneLoginUserStatus['Active'] } else { $script:OneLoginUserStatus[$row.Status] }) + state = $script:OneLoginUserState[$row.State] + } + # The directory identifiers, every one of them in the seed's namespace, so none can name or be + # matched to a real AD account, UPN or employee. + $directory = Resolve-OneLoginDirectoryIdentity -Row $row -RoleNameByKey $roleDataName -GroupNameByKey $groupDataName -Connection $connection + $fields = [ordered]@{ $attribute = $marker.Tag } + $fields['zztest_contractor'] = ([string]$row.Contractor).ToLowerInvariant() + if ($row.BadgeId) { $fields['zztest_badge_id'] = $row.BadgeId } + if ($row.CostCenter) { $fields['zztest_cost_center'] = $row.CostCenter } + + $groupId = $null + if ($row.Group) { + $group = $groupByName[$groupNameByKey[$row.Group]] + if ($group) { $groupId = [string]$group.id } + else { $errors.Add("Group '$($row.Group)' for $login does not exist, or is not one the seed may use; run New-OneLoginGroup first") } + } + + $managerId = $null + if ($row.Manager) { + if ($idByKey.ContainsKey($row.Manager)) { $managerId = $idByKey[$row.Manager] } + else { $deferredManager.Add([PSCustomObject]@{ Key = $row.Key; Login = $login; Manager = $row.Manager }) } + } + + $existing = $seededByLogin[$login] + if ($existing) { + # Put back what differs, so a repair restores a person rather than only a missing one. + # Names are compared ordinally: a decomposed name that came back precomposed is wrong. + $change = [ordered]@{} + foreach ($name in 'firstname', 'lastname', 'title', 'department', 'company') { + if (-not [string]::Equals([string]$existing.$name, [string]$desired[$name], [StringComparison]::Ordinal)) { + $change[$name] = $desired[$name] + } + } + if (-not $locked -and [int]$existing.status -ne $desired.status) { $change['status'] = $desired.status } + foreach ($name in $directory.Keys) { + if (-not [string]::Equals([string]$existing.$name, [string]$directory[$name], [StringComparison]::Ordinal)) { $change[$name] = $directory[$name] } + } + if ([int]$existing.state -ne $desired.state) { $change['state'] = $desired.state } + if ([string]$existing.group_id -ne [string]$groupId -and $groupId) { $change['group_id'] = [long]$groupId } + if ($managerId -and [string]$existing.manager_user_id -ne $managerId) { $change['manager_user_id'] = [long]$managerId } + $fieldChange = [ordered]@{} + foreach ($name in $fields.Keys) { + $current = $null + if ($existing.custom_attributes) { $current = [string]$existing.custom_attributes.$name } + if (-not [string]::Equals($current, [string]$fields[$name], [StringComparison]::Ordinal)) { $fieldChange[$name] = $fields[$name] } + } + if ($fieldChange.Count -gt 0) { $change['custom_attributes'] = $fieldChange } + + $reused.Add([PSCustomObject]@{ Key = $row.Key; Username = $login; Id = [string]$existing.id }) + if ($change.Count -gt 0 -and $PSCmdlet.ShouldProcess($login, ('Update OneLogin user ({0})' -f (@($change.Keys) -join ', ')))) { + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "users/$($existing.id)" -Body $change -Connection $connection + $updated++ + } + catch { + $errors.Add("Could not update user ${login}: $($_.Exception.Message)") + } + } + } + else { + if (-not $PSCmdlet.ShouldProcess($login, 'Create OneLogin user')) { continue } + + $body = [ordered]@{ + username = $login + email = Resolve-OneLoginSeedName -Key $row.Key -Kind Email -Connection $connection + } + foreach ($name in $desired.Keys) { if ($null -ne $desired[$name] -and $desired[$name] -ne '') { $body[$name] = $desired[$name] } } + foreach ($name in $directory.Keys) { if ($directory[$name]) { $body[$name] = $directory[$name] } } + if ($groupId) { $body['group_id'] = [long]$groupId } + if ($managerId) { $body['manager_user_id'] = [long]$managerId } + $body['custom_attributes'] = $fields + + try { + $user = Invoke-OneLoginRequest -Method POST -Path 'users' -Body $body -Connection $connection + $idByKey[$row.Key] = [string]$user.id + $created.Add([PSCustomObject]@{ Key = $row.Key; Username = $login; Id = [string]$user.id }) + } + catch { + # A 422 here is usually a username or email that already exists. It is not one this + # module seeded - those were found above - so it is left alone. + $errors.Add("Could not create user ${login}: $($_.Exception.Message)") + Write-Warning "Could not create user ${login}: $($_.Exception.Message)" + continue + } + } + + if ($locked -and $idByKey[$row.Key]) { + # Locked again when the lock is missing or has less than a month left, so a repair or a + # re-run keeps the person locked however old the seed is. + $lockedUntil = $null + if ($existing -and $existing.locked_until) { try { $lockedUntil = [datetime]$existing.locked_until } catch { $lockedUntil = $null } } + if (-not $existing -or [int]$existing.status -ne $script:OneLoginUserStatus['Locked'] -or -not $lockedUntil -or $lockedUntil -lt (Get-Date).AddDays(30)) { + $toLock.Add([PSCustomObject]@{ Id = $idByKey[$row.Key]; Login = $login }) + } + } + + foreach ($roleKey in @(([string]$row.Roles -split ';') | Where-Object { $_ })) { + if (-not $roleMembers.ContainsKey($roleKey)) { $roleMembers[$roleKey] = New-Object System.Collections.Generic.List[string] } + $roleMembers[$roleKey].Add($idByKey[$row.Key]) + } + } + + # The locks, through the version 1 call, which takes a duration where the status field does not. + # Only a licensed person can be locked; OneLogin refuses an unlicensed one, and says so. + $lockedCount = 0 + foreach ($pending in $toLock) { + if (-not $PSCmdlet.ShouldProcess($pending.Login, 'Lock OneLogin user for a year')) { continue } + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "/api/1/users/$($pending.Id)/lock_user" -Body @{ locked_until = $script:OneLoginLockMinutes } -Connection $connection + $lockedCount++ + } + catch { $errors.Add("Could not lock $($pending.Login): $($_.Exception.Message)") } + } + + Write-TestProgress -Activity 'Creating OneLogin users' -Completed -ShowProgress:$ShowProgress + + # Managers the file order could not provide at creation: a manager outside the rows being + # seeded, or one seeded later. A manager who is not seeded at all is left unset rather than + # looked up among real people. + $managersSet = 0 + foreach ($pending in $deferredManager) { + $userId = $idByKey[$pending.Key] + $managerId = $idByKey[$pending.Manager] + if (-not $userId) { continue } + if (-not $managerId) { + Write-Warning "The manager '$($pending.Manager)' of $($pending.Login) is not seeded; the manager is left unset" + continue + } + if (-not $PSCmdlet.ShouldProcess($pending.Login, 'Set OneLogin manager')) { continue } + try { + $null = Invoke-OneLoginRequest -Method PUT -Path "users/$userId" -Body @{ manager_user_id = [long]$managerId } -Connection $connection + $managersSet++ + } + catch { + $errors.Add("Could not set the manager of $($pending.Login): $($_.Exception.Message)") + } + } + + # Roles, one request per role for everyone it is missing. OneLogin settles role membership a + # few seconds after it is written, so what a role holds is read from the listing taken at the + # start of the step rather than re-read after each write. + $membershipsApplied = 0 + $grantedTo = @{} + foreach ($roleKey in ($roleMembers.Keys | Sort-Object)) { + $role = $roleByName[$roleNameByKey[$roleKey]] + if (-not $role) { + $errors.Add("Role '$roleKey' does not exist, or is not one the seed may use; run New-OneLoginRole first") + continue + } + $current = @($role.users | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) + $missing = @($roleMembers[$roleKey] | Where-Object { $_ -and $current -notcontains $_ } | Sort-Object -Unique) + if ($missing.Count -eq 0) { continue } + + if (-not $PSCmdlet.ShouldProcess("$($role.name) <- $($missing.Count) user(s)", 'Add OneLogin role members')) { continue } + + for ($offset = 0; $offset -lt $missing.Count; $offset += 100) { + $batch = @($missing[$offset..([Math]::Min($offset + 99, $missing.Count - 1))]) + # Written as JSON here: piped to ConvertTo-Json a one-element array becomes a bare + # number, and the endpoint wants a list. + try { + $null = Invoke-OneLoginRequest -Method POST -Path "roles/$($role.id)/users" -Body ('[{0}]' -f ($batch -join ',')) -Connection $connection + $membershipsApplied += $batch.Count + if (-not $grantedTo.ContainsKey([string]$role.id)) { $grantedTo[[string]$role.id] = New-Object System.Collections.Generic.List[string] } + foreach ($id in $batch) { $grantedTo[[string]$role.id].Add([string]$id) } + } + catch { + $errors.Add("Could not add $($batch.Count) user(s) to role $($role.name): $($_.Exception.Message)") + } + } + } + + # OneLogin shows a role grant a few seconds after answering it - ten, measured on a trial. On some + # runs, verified live, it answers 200 and applies nothing: twelve of thirteen grants were still + # absent two minutes later, and on another run grants that had sat unapplied for minutes appeared + # within seconds of the next grant being sent. A role is proved at teardown by the seeded people + # it holds, so an unapplied grant leaves an empty role that teardown, rightly, refuses to claim. + # So the step waits until every grant it sent is visible, sending the missing ones again every + # thirty seconds - adding somebody already in a role changes nothing - for up to four minutes. + $pendingGrants = 0 + if ($grantedTo.Count -gt 0 -and -not $WhatIfPreference) { + # Counted in attempts rather than against the clock, so it is the same time live and a + # deterministic loop under a test that mocks the sleep. Four minutes, because a live seed once waited more than two. + $attempt = 0 + while ($true) { + $attempt++ + $visible = @{} + foreach ($role in @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $connection)) { + if ($null -ne $role -and $grantedTo.ContainsKey([string]$role.id)) { $visible[[string]$role.id] = @($role.users | ForEach-Object { [string]$_ }) } + } + $pendingGrants = 0 + $missingByRole = @{} + foreach ($roleId in $grantedTo.Keys) { + $absent = @($grantedTo[$roleId] | Where-Object { @($visible[$roleId]) -notcontains $_ }) + if ($absent.Count -gt 0) { $missingByRole[$roleId] = $absent } + $pendingGrants += $absent.Count + } + if ($pendingGrants -eq 0 -or $attempt -ge 48) { break } + + if ($attempt % 6 -eq 0) { + foreach ($roleId in $missingByRole.Keys) { + Write-Verbose "Sending $($missingByRole[$roleId].Count) role grant(s) to role $roleId again; OneLogin has not applied them" + try { + $null = Invoke-OneLoginRequest -Method POST -Path "roles/$roleId/users" -Body ('[{0}]' -f ($missingByRole[$roleId] -join ',')) -Connection $connection + } + catch { + Write-Verbose "Sending the role grants to role $roleId again failed: $($_.Exception.Message)" + } + } + } + Start-Sleep -Seconds 5 + } + if ($pendingGrants -gt 0) { + # Formatted as one string first: -f binds tighter than +. + $template = 'OneLogin has not yet shown {0} of the role grants it accepted. They usually appear within seconds; until they do, ' + + 'verification reports them missing and teardown cannot prove the roles they are in.' + Write-Warning ($template -f $pendingGrants) + } + } + + # OneLogin approves a person only while the account has a user licence for them, and otherwise + # makes them Unlicensed without an error - the create answers with the approved state it was + # asked for. An unlicensed person cannot hold a role, so their role grants are dropped too. + # Read back once, so the seed says what happened rather than leaving it to verification. + if (($created.Count + $updated) -gt 0 -and -not $WhatIfPreference) { + $approved = $script:OneLoginUserState['Approved'] + $wanted = @{} + foreach ($row in $rows) { if ($script:OneLoginUserState[$row.State] -eq $approved) { $wanted[(Resolve-OneLoginSeedName -Key $row.Key -Kind Username -Connection $connection)] = $true } } + $unlicensed = @(Get-OneLoginSeededObject -Type Users -Connection $connection | Where-Object { + $wanted.ContainsKey(([string]$_.username).ToLowerInvariant()) -and [int]$_.state -eq $script:OneLoginUserState['Unlicensed'] + } | ForEach-Object { [string]$_.username } | Sort-Object) + if ($unlicensed.Count -gt 0) { + $shown = @($unlicensed | Select-Object -First 5) -join ', ' + if ($unlicensed.Count -gt 5) { $shown = '{0}, ...' -f $shown } + # Built before it is added: inside a method call's parentheses the commas after -f + # would be read as more arguments to Add, and the format would be one value short. + $template = '{0} person(s) the data approves were made Unlicensed by OneLogin, which means the account has no user licence left ' + + 'for them. They exist, but hold no roles: {1}' + $message = $template -f $unlicensed.Count, $shown + $errors.Add($message) + } + } + + if ($PassThru) { + return [PSCustomObject]@{ + TotalUsers = @($rows).Count + CreatedUsers = $created.Count + ReusedUsers = $reused.Count + UpdatedUsers = $updated + ManagersSet = $managersSet + UsersLocked = $lockedCount + MembershipsApplied = $membershipsApplied + GrantsNotYetShown = $pendingGrants + Users = (@($created) + @($reused)) + Errors = $errors.ToArray() + } + } +} diff --git a/Providers/OneLogin/Public/Remove-OneLoginEnvironment.ps1 b/Providers/OneLogin/Public/Remove-OneLoginEnvironment.ps1 new file mode 100644 index 0000000..aa680ea --- /dev/null +++ b/Providers/OneLogin/Public/Remove-OneLoginEnvironment.ps1 @@ -0,0 +1,303 @@ +function Remove-OneLoginEnvironment { + <# + .SYNOPSIS + Removes every object this module created in a OneLogin account, proving ownership first + + .DESCRIPTION + Reached through Remove-TestEnvironment once a OneLogin connection is active. + + Nothing is deleted for matching a name. Every object is selected by + Get-OneLoginSeededObject: a person by the seed tag in its custom field and the prefix on its + username; an app, an API authorization server and a self-registration profile by the tag in + their text and the prefix on their name; a role or group by the prefix and by holding seeded + people and nothing else; a policy by the seeded groups that use it; a mapping by the seed-tag + condition and roles that are themselves proved; an app rule by its seeded app and seeded + roles; the Smart Hook by the marker in its code and the seeded role it is gated on; a custom + field by being declared here with the attribute prefix. A candidate that fails its proof is + listed as left alone, with the reason, and never touched. + + Everything is proved before anything is deleted, because the proofs depend on each other. + Then the deletions run in this order: + + 1. SelfRegistration and Hooks + 2. Mappings - so nothing re-adds a role while the rest goes + 3. AppRules - only when the apps they sit on are being kept; otherwise they go + with their apps + 4. ApiAuthorizations - their scopes, claims and client links go with them + 5. Roles - their memberships and app grants go with them + 6. Groups - their members are left in no group + 7. Policies + 8. Apps - with each app, the client secret New-OneLoginApp -SaveAppSecret + saved for it, record and vault secret both + 9. Users - their MFA factors go with them + 10. Attributes - last, because a user without the seed tag field can no longer be + proved ours, and a teardown that stopped halfway has to be able to + finish + + Once the apps are gone, any saved app secret whose app is no longer in the account - one + deleted by hand, or by a teardown that stopped before this existed - is deleted too, so saved + secrets do not build up on the machine. Only this account's records are read, and a record + whose content disagrees with its file name is never touched. With -Keep Apps no saved + secret is touched at all. + + -Keep keeps what its proof needs as well: keeping a type keeps every type that type is proved + by, read from the provider's proof dependency table, so nothing is left behind that the next + teardown could not claim. What that adds is said. + + The confirmation is asked once, for the whole run, in the body of the function, and not in + a begin{} block: a return inside begin{} ends that block and nothing else, so a refusal + there would not stop the deletion. A session that cannot answer the prompt is refused too, + so an automated teardown has to pass -Force. + + -WhatIf always wins over -Force. -Force lowers the confirmation preference and nothing + else; every deletion still goes through ShouldProcess, so "-Force -WhatIf" deletes nothing + and prints a line for every object it would have removed. + + .PARAMETER Keep + Object types to leave in place, together with what their proof needs. + + .PARAMETER Force + Remove without asking for confirmation. Required for any unattended teardown. + + .PARAMETER PassThru + Return the results object. + + .OUTPUTS + PSCustomObject describing what was removed and what was left alone, when -PassThru is used. + + .EXAMPLE + PS> Remove-TestEnvironment -WhatIf + + DESCRIPTION: Lists everything teardown would remove, removing nothing + OUTPUT: A What if: line per object, and the objects it would leave alone + USE CASE: Checking ownership before deleting anything in an account you care about + + .EXAMPLE + PS> Remove-TestEnvironment -Force -Keep Attributes -PassThru + + DESCRIPTION: Removes everything except the custom fields, unattended + OUTPUT: The results object + USE CASE: Tearing down between runs while keeping the fields, so the next seed is faster + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + .LINK + New-OneLoginEnvironment + Get-OneLoginEnvironmentReport + #> + + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'The teardown summary is written for the person watching the run; the result object carries the same data for scripts.')] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [ValidateSet('SelfRegistration', 'Hooks', 'Mappings', 'AppRules', 'ApiAuthorizations', 'Roles', 'Groups', 'Policies', 'Apps', 'Users', 'Attributes')] + [string[]]$Keep = @(), + + [Parameter()] + [switch]$Force, + + [Parameter()] + [switch]$PassThru + ) + + $correlationId = [Guid]::NewGuid() + Write-Verbose "Starting Remove-OneLoginEnvironment - CorrelationId: $correlationId" + + $connection = Get-OneLoginConnection + + $order = @('SelfRegistration', 'Hooks', 'Mappings', 'AppRules', 'ApiAuthorizations', 'Roles', 'Groups', 'Policies', 'Apps', 'Users', 'Attributes') + $removed = [ordered]@{} + foreach ($type in $order) { $removed[$type] = 0 } + $errors = [System.Collections.Generic.List[string]]::new() + $leftAlone = [System.Collections.Generic.List[object]]::new() + $appSecretsRemoved = 0 + + $buildResult = { + param([bool]$Cancelled) + [PSCustomObject]@{ + CorrelationId = $correlationId + Subdomain = $connection.Subdomain + Cancelled = $Cancelled + Removed = [PSCustomObject]$removed + TotalRemoved = ($removed.Values | Measure-Object -Sum).Sum + AppSecretsRemoved = $appSecretsRemoved + LeftAlone = $leftAlone.ToArray() + Errors = $errors.ToArray() + } + } + + # One confirmation for the run, asked here in the body where a refusal actually stops the + # function. Skipped under -WhatIf, because a preview changes nothing and demanding an answer + # before showing what would happen made -WhatIf unusable from anything non-interactive. + if (-not $WhatIfPreference) { + $question = "Remove every object this module created in OneLogin account '$($connection.Subdomain)'?" + $confirmed = $Force + if (-not $confirmed) { + $confirmed = Confirm-TestTeardown -Cmdlet $PSCmdlet -Question $question -Caption 'Remove OneLogin test environment' + } + if (-not $confirmed) { + Write-Host 'Teardown cancelled. Nothing was removed. Pass -Force to remove without being asked.' + if ($PassThru) { return (& $buildResult $true) } + return + } + # Asked once; every deletion below still passes through ShouldProcess, so -WhatIf is never + # defeated by this. + $ConfirmPreference = 'None' + } + + Write-TestMessage -Message ('OneLogin Test Environment Teardown ({0}.onelogin.com)' -f $connection.Subdomain) -Type Header + + # Keeping a type keeps everything its proof reads, transitively. + $kept = New-Object 'System.Collections.Generic.HashSet[string]' + $pending = New-Object System.Collections.Generic.Queue[string] + foreach ($type in $Keep) { $pending.Enqueue($type) } + while ($pending.Count -gt 0) { + $type = $pending.Dequeue() + if (-not $kept.Add($type)) { continue } + foreach ($dependency in @($script:OneLoginProofDependency[$type])) { if ($dependency) { $pending.Enqueue($dependency) } } + } + $implied = @($kept | Where-Object { $Keep -notcontains $_ } | Sort-Object) + if ($implied.Count -gt 0) { + $errors.Add(('Also kept, because what -Keep keeps is proved by them and without them no later teardown could claim it: {0}.' -f ($implied -join ', '))) + } + + # Everything proved first, while every proof still stands. + $users = @(Get-OneLoginSeededObject -Type Users -Connection $connection) + $apps = @(Get-OneLoginSeededObject -Type Apps -Connection $connection) + $userIds = @($users | ForEach-Object { [string]$_.id }) + $appIds = @($apps | ForEach-Object { [string]$_.id }) + $roles = @(Get-OneLoginSeededObject -Type Roles -OwnedUserId $userIds -OwnedAppId $appIds -Connection $connection) + $roleIds = @($roles | ForEach-Object { [string]$_.id }) + $groups = @(Get-OneLoginSeededObject -Type Groups -OwnedUserId $userIds -Connection $connection) + $groupIds = @($groups | ForEach-Object { [string]$_.id }) + $owned = @{ OwnedUserId = $userIds; OwnedAppId = $appIds; OwnedRoleId = $roleIds; OwnedGroupId = $groupIds; Connection = $connection } + $found = [ordered]@{ + SelfRegistration = @(Get-OneLoginSeededObject -Type SelfRegistration -Connection $connection) + Hooks = @(Get-OneLoginSeededObject -Type Hooks @owned) + Mappings = @(Get-OneLoginSeededObject -Type Mappings @owned) + AppRules = @(Get-OneLoginSeededObject -Type AppRules @owned) + ApiAuthorizations = @(Get-OneLoginSeededObject -Type ApiAuthorizations -Connection $connection) + Roles = $roles + Groups = $groups + Policies = @(Get-OneLoginSeededObject -Type Policies @owned) + Apps = $apps + Users = $users + Attributes = @(Get-OneLoginSeededObject -Type Attributes -Connection $connection) + } + + foreach ($type in 'SelfRegistration', 'Hooks', 'Mappings', 'AppRules', 'ApiAuthorizations', 'Roles', 'Groups', 'Policies', 'Apps') { + foreach ($refused in @(Get-OneLoginSeededObject -Type $type -Unproven @owned)) { $leftAlone.Add($refused) } + } + + $noun = @{ + SelfRegistration = 'self-registration profile'; Hooks = 'Smart Hook'; Mappings = 'mapping'; AppRules = 'app rule' + ApiAuthorizations = 'API authorization server'; Roles = 'role'; Groups = 'group'; Policies = 'user policy'; Apps = 'app' + Users = 'user'; Attributes = 'custom user field' + } + $pathOf = { + param([string]$Type, $Object) + switch ($Type) { + 'SelfRegistration' { "self_registration_profiles/$($Object.id)" } + 'Hooks' { "hooks/$($Object.id)" } + 'Mappings' { "mappings/$($Object.id)" } + 'AppRules' { "apps/$($Object.AppId)/rules/$($Object.id)" } + 'ApiAuthorizations' { "api_authorizations/$($Object.id)" } + 'Roles' { "roles/$($Object.id)" } + 'Groups' { "groups/$($Object.id)" } + 'Policies' { "policies/$($Object.id)" } + 'Apps' { "apps/$($Object.id)" } + 'Users' { "users/$($Object.id)" } + 'Attributes' { "users/custom_attributes/$($Object.id)" } + } + } + $labelOf = { + param([string]$Type, $Object) + if ($Type -eq 'Users') { return [string]$Object.username } + if ($Type -eq 'Attributes') { return [string]$Object.shortname } + return [string]$Object.name + } + + # Saved app secrets, by the app they open. Read only when the apps are going. + $secretByAppId = @{} + if (-not $kept.Contains('Apps')) { + foreach ($record in @(Get-OneLoginAppSecretRecord -Subdomain $connection.Subdomain)) { $secretByAppId[$record.AppId] = $record } + } + $removeSecret = { + param($Record) + try { + if (Remove-OneLoginAppSecret -Record $Record) { $secretsDeleted.Add($Record.Path) } + } + catch { + $errors.Add("Could not delete the saved client secret at $($Record.Path): $($_.Exception.Message)") + } + } + $secretsDeleted = [System.Collections.Generic.List[string]]::new() + + foreach ($type in $order) { + if ($kept.Contains($type)) { continue } + # An app rule goes with its app; it is deleted on its own only when the app stays. + if ($type -eq 'AppRules' -and -not $kept.Contains('Apps')) { continue } + if (@($found[$type]).Count -eq 0) { continue } + + Write-Host ('Removing {0}' -f $noun[$type]) + foreach ($object in $found[$type]) { + $label = & $labelOf $type $object + $secret = if ($type -eq 'Apps') { $secretByAppId[[string]$object.id] } else { $null } + if (-not $PSCmdlet.ShouldProcess($label, "Delete OneLogin $($noun[$type])")) { + # Under -WhatIf, name the saved secret that would go with the app; it removes nothing. + if ($secret) { & $removeSecret $secret } + continue + } + try { + $null = Invoke-OneLoginRequest -Method DELETE -Path (& $pathOf $type $object) -Connection $connection -IgnoreStatus 404 + $removed[$type]++ + if ($secret) { + & $removeSecret $secret + $secretByAppId.Remove([string]$object.id) + } + } + catch { + $errors.Add("Could not delete $($noun[$type]) '${label}': $($_.Exception.Message)") + Write-Warning "Could not delete $($noun[$type]) '${label}': $($_.Exception.Message)" + } + } + } + + # Saved secrets whose app is no longer in the account, whatever removed it. Read against every + # app in the account, not only the seeded ones, so a record is never deleted while its app lives. + if (-not $kept.Contains('Apps') -and $secretByAppId.Count -gt 0) { + $liveAppIds = $null + try { + $liveAppIds = @(Invoke-OneLoginRequest -Method GET -Path 'apps' -Paginate -Connection $connection | + Where-Object { $null -ne $_ } | ForEach-Object { [string]$_.id }) + } + catch { + $errors.Add("Could not list the account's apps, so saved client secrets of apps deleted elsewhere were left: $($_.Exception.Message)") + } + if ($null -ne $liveAppIds) { + foreach ($appId in @($secretByAppId.Keys)) { + if ($liveAppIds -notcontains $appId) { & $removeSecret $secretByAppId[$appId] } + } + } + } + $appSecretsRemoved = $secretsDeleted.Count + + $result = & $buildResult $false + + Write-TestMessage -Message 'Teardown Summary' -Type Header + Write-Host ('Objects removed: {0}' -f $result.TotalRemoved) + foreach ($type in $removed.Keys) { Write-Host (' {0,-17} {1}' -f $type, $removed[$type]) } + if ($appSecretsRemoved -gt 0) { Write-Host ('Saved app secrets deleted from this machine: {0}' -f $appSecretsRemoved) } + if ($leftAlone.Count -gt 0) { + Write-Host ('Left alone, because they could not be proved seeded: {0}' -f $leftAlone.Count) + foreach ($item in $leftAlone) { Write-Host (' {0} {1} ({2}): {3}' -f $noun[$item.Type], $item.Name, $item.Id, $item.Reason) } + } + foreach ($message in $errors) { Write-Host (' ! {0}' -f $message) } + + if ($PassThru) { return $result } +} diff --git a/Providers/OneLogin/Public/Test-OneLoginEnvironment.ps1 b/Providers/OneLogin/Public/Test-OneLoginEnvironment.ps1 new file mode 100644 index 0000000..f68bea8 --- /dev/null +++ b/Providers/OneLogin/Public/Test-OneLoginEnvironment.ps1 @@ -0,0 +1,291 @@ +function Test-OneLoginEnvironment { + <# + .SYNOPSIS + Verifies that the seeded OneLogin account matches the seed data + .DESCRIPTION + Reads everything the module owns in the connected account, the same way teardown finds it, + and compares it with the seed files: every seeded name should be present, nothing the module owns should be + there that the data does not describe, every user's first and last name should match the + data by codepoint, every lifecycle status and state and every manager should be the one the + data gives - a Locked person locked for at least another day - every directory identifier + should be the one the seed builds, and every group, role, app grant, policy, policy + setting, API scope, claim and client, app rule, Smart Hook and sign-up profile the data + describes should be in place. How many seeded people hold a pre-enrolled MFA factor is + counted but not judged, because the seed enrols one only where the account offers it. + + Names are compared ordinally, not with -eq, because a decomposed and a precomposed name + are equal to -eq and different on the wire; a name that came back mangled is the fault + this exists to catch. Usernames are compared case-insensitively. + + Role memberships and app grants are judged on what is missing only: an enabled mapping + adds people to a role that the data never lists there, and that is not a fault. OneLogin + also settles role membership a few seconds after it is written, so a verification run in + the same breath as the seed can report memberships missing that a second run finds. + .PARAMETER SkipMembership + Do not judge role memberships and app grants + .PARAMETER Quiet + Return the result without writing to the console + .OUTPUTS + PSCustomObject typed TestEnvironmentVerification. Passed is $true when every check passed. + .EXAMPLE + PS> Test-OneLoginEnvironment + + Prints one line per check and returns the result. + .EXAMPLE + PS> Test-OneLoginEnvironment -SkipMembership -Quiet | Select-Object -ExpandProperty Checks + + The object checks alone, as objects. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [switch]$SkipMembership, + + [Parameter()] + [switch]$Quiet + ) + + $connection = Get-OneLoginConnection + $dataPath = Get-OneLoginDataPath + $checks = New-Object System.Collections.Generic.List[object] + + $read = { param($file) @(Import-Csv -LiteralPath (Join-Path -Path $dataPath -ChildPath $file) -Encoding UTF8) } + $displayOf = { param($key) Resolve-OneLoginSeedName -Key $key -Kind DisplayName -Connection $connection } + $usernameOf = { param($key) Resolve-OneLoginSeedName -Key $key -Kind Username -Connection $connection } + + $attributeRows = @(& $read 'OneLoginCustomAttributes.csv') + $roleRows = @(& $read 'OneLoginRoles.csv') + $groupRows = @(& $read 'OneLoginGroups.csv') + $appRows = @(& $read 'OneLoginApps.csv') + $mappingRows = @(& $read 'OneLoginMappings.csv') + $userRows = @(& $read 'OneLoginUsers.csv') + $policyRows = @(& $read 'OneLoginPolicies.csv') + $apiRows = @(& $read 'OneLoginApiAuthorizations.csv') + $ruleRows = @(& $read 'OneLoginAppRules.csv') + $hookRows = @(& $read 'OneLoginHooks.csv') + $registrationRows = @(& $read 'OneLoginSelfRegistrations.csv') + + $attributes = @(Get-OneLoginSeededObject -Type Attributes -Connection $connection) + $users = @(Get-OneLoginSeededObject -Type Users -Connection $connection) + $apps = @(Get-OneLoginSeededObject -Type Apps -Connection $connection) + $userIds = @($users | ForEach-Object { [string]$_.id }) + $roles = @(Get-OneLoginSeededObject -Type Roles -OwnedUserId $userIds -OwnedAppId @($apps | ForEach-Object { [string]$_.id }) -Connection $connection) + $groups = @(Get-OneLoginSeededObject -Type Groups -OwnedUserId $userIds -Connection $connection) + $roleIds = @($roles | ForEach-Object { [string]$_.id }) + $owned = @{ OwnedUserId = $userIds; OwnedAppId = @($apps | ForEach-Object { [string]$_.id }); OwnedRoleId = $roleIds; OwnedGroupId = @($groups | ForEach-Object { [string]$_.id }); Connection = $connection } + $mappings = @(Get-OneLoginSeededObject -Type Mappings @owned) + $policies = @(Get-OneLoginSeededObject -Type Policies @owned) + $apiServers = @(Get-OneLoginSeededObject -Type ApiAuthorizations -Connection $connection) + $appRules = @(Get-OneLoginSeededObject -Type AppRules @owned) + $hooks = @(Get-OneLoginSeededObject -Type Hooks @owned) + $registrations = @(Get-OneLoginSeededObject -Type SelfRegistration -Connection $connection) + + # --- Objects ----------------------------------------------------------------------------- + $checks.Add((New-TestEnvironmentCheck -Name 'Attributes' -FoundCount $attributes.Count -ExpectedCount $attributeRows.Count)) + $checks.Add((New-TestEnvironmentCheck -Name 'Roles' ` + -Expected @($roleRows | ForEach-Object { & $displayOf $_.Name }) -Found @($roles | ForEach-Object { [string]$_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'Groups' ` + -Expected @($groupRows | ForEach-Object { & $displayOf $_.Name }) -Found @($groups | ForEach-Object { [string]$_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'Apps' ` + -Expected @($appRows | ForEach-Object { & $displayOf $_.Name }) -Found @($apps | ForEach-Object { [string]$_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'Mappings' ` + -Expected @($mappingRows | ForEach-Object { & $displayOf $_.Name }) -Found @($mappings | ForEach-Object { [string]$_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'Users' ` + -Expected @($userRows | ForEach-Object { & $usernameOf $_.Key }) -Found @($users | ForEach-Object { [string]$_.username }) -IgnoreCase)) + $checks.Add((New-TestEnvironmentCheck -Name 'Policies' ` + -Expected @($policyRows | ForEach-Object { & $displayOf $_.Name }) -Found @($policies | ForEach-Object { [string]$_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'API authorizations' ` + -Expected @($apiRows | ForEach-Object { & $displayOf $_.Name }) -Found @($apiServers | ForEach-Object { [string]$_.name }))) + $appNameByKey = @{} + foreach ($row in $appRows) { $appNameByKey[$row.Key] = & $displayOf $row.Name } + $appNameById = @{} + foreach ($app in $apps) { $appNameById[[string]$app.id] = [string]$app.name } + $checks.Add((New-TestEnvironmentCheck -Name 'App rules' ` + -Expected @($ruleRows | ForEach-Object { '{0} : {1}' -f $appNameByKey[$_.App], (& $displayOf $_.Name) }) ` + -Found @($appRules | ForEach-Object { '{0} : {1}' -f $appNameById[[string]$_.AppId], $_.name }))) + $checks.Add((New-TestEnvironmentCheck -Name 'Smart hooks' -FoundCount $hooks.Count -ExpectedCount $hookRows.Count)) + $checks.Add((New-TestEnvironmentCheck -Name 'Self-registration' ` + -Expected @($registrationRows | ForEach-Object { & $displayOf $_.Name }) -Found @($registrations | ForEach-Object { [string]$_.name }))) + + # --- Policies ---------------------------------------------------------------------------- + # Which group each policy governs, and the settings the data gives it. + $groupNameByKeyForPolicy = @{} + foreach ($row in $groupRows) { $groupNameByKeyForPolicy[$row.Key] = & $displayOf $row.Name } + $policyNameById = @{} + foreach ($policy in $policies) { $policyNameById[[string]$policy.id] = [string]$policy.name } + $expectedPolicy = foreach ($row in $policyRows) { + foreach ($key in @(([string]$row.Groups -split ';') | Where-Object { $_ })) { '{0} <- {1}' -f $groupNameByKeyForPolicy[$key], (& $displayOf $row.Name) } + } + $foundPolicy = foreach ($group in $groups) { + if ($group.policy_id -and $policyNameById.ContainsKey([string]$group.policy_id)) { '{0} <- {1}' -f $group.name, $policyNameById[[string]$group.policy_id] } + } + $checks.Add((New-TestEnvironmentCheck -Name 'Group policies' -Expected @($expectedPolicy) -Found @($foundPolicy))) + + $settingField = [ordered]@{ + MinimumPasswordLength = 'minimum_password_length'; PasswordExpirationDays = 'password_expiration_days'; PasswordsRemembered = 'passwords_remembered' + MaximumInvalidLoginAttempts = 'maximum_invalid_login_attempts'; LockEffectiveMinutes = 'lock_effective_minutes' + } + $settingsCompared = 0 + $settingMismatch = New-Object System.Collections.Generic.List[string] + foreach ($row in $policyRows) { + $policy = @($policies | Where-Object { [string]$_.name -eq (& $displayOf $row.Name) }) | Select-Object -First 1 + if (-not $policy) { continue } + $settingsCompared++ + $detail = Invoke-OneLoginRequest -Method GET -Path "policies/$($policy.id)" -Connection $connection + foreach ($column in $settingField.Keys) { + if ($row.$column -and [string]$detail.($settingField[$column]) -ne [string]$row.$column) { + $settingMismatch.Add(('{0}: {1} is {2}, should be {3}' -f $row.Key, $settingField[$column], $detail.($settingField[$column]), $row.$column)) + } + } + } + $checks.Add((New-TestEnvironmentCheck -Name 'Policy settings' -Compared $settingsCompared -Mismatch $settingMismatch.ToArray())) + + # --- API authorizations ------------------------------------------------------------------ + $expectedScope = New-Object System.Collections.Generic.List[string] + $foundScope = New-Object System.Collections.Generic.List[string] + $expectedClaim = New-Object System.Collections.Generic.List[string] + $foundClaim = New-Object System.Collections.Generic.List[string] + $expectedClient = New-Object System.Collections.Generic.List[string] + $foundClient = New-Object System.Collections.Generic.List[string] + $listOf = { param([string]$Path) @(Invoke-OneLoginRequest -Method GET -Path $Path -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) } + foreach ($row in $apiRows) { + $serverName = & $displayOf $row.Name + foreach ($entry in @(([string]$row.Scopes -split '\|') | Where-Object { $_ })) { $expectedScope.Add(('{0} : {1}' -f $serverName, ($entry -split '=', 2)[0])) } + foreach ($entry in @(([string]$row.Claims -split '\|') | Where-Object { $_ })) { $expectedClaim.Add(('{0} : {1}' -f $serverName, ($entry -split '=', 2)[0])) } + foreach ($entry in @(([string]$row.Clients -split '\|') | Where-Object { $_ })) { + $appKey, $scopeList = $entry -split '=', 2 + foreach ($scope in @(([string]$scopeList -split ' ') | Where-Object { $_ })) { $expectedClient.Add(('{0} <- {1} : {2}' -f $serverName, $appNameByKey[$appKey], $scope)) } + } + } + foreach ($server in $apiServers) { + foreach ($scope in (& $listOf "api_authorizations/$($server.id)/scopes")) { $foundScope.Add(('{0} : {1}' -f $server.name, $scope.value)) } + foreach ($claim in (& $listOf "api_authorizations/$($server.id)/claims")) { $foundClaim.Add(('{0} : {1}' -f $server.name, $claim.name)) } + foreach ($client in (& $listOf "api_authorizations/$($server.id)/clients")) { + $clientName = if ($appNameById.ContainsKey([string]$client.app_id)) { $appNameById[[string]$client.app_id] } else { "app $($client.app_id)" } + foreach ($scope in @($client.scopes | Where-Object { $null -ne $_ })) { $foundClient.Add(('{0} <- {1} : {2}' -f $server.name, $clientName, $scope.value)) } + } + } + $checks.Add((New-TestEnvironmentCheck -Name 'API scopes' -Expected $expectedScope -Found $foundScope)) + # The claims OneLogin adds to every server are not the data's to list, so a claim is judged on + # what is missing only. + $checks.Add((New-TestEnvironmentCheck -Name 'API claims' -Expected $expectedClaim -Found $foundClaim -MissingOnly)) + $checks.Add((New-TestEnvironmentCheck -Name 'API clients' -Expected $expectedClient -Found $foundClient)) + + # --- People ------------------------------------------------------------------------------ + $userByLogin = @{} + $loginById = @{} + foreach ($user in $users) { + if ($user.username) { $userByLogin[([string]$user.username).ToLowerInvariant()] = $user } + $loginById[[string]$user.id] = [string]$user.username + } + $groupNameByKey = @{} + foreach ($row in $groupRows) { $groupNameByKey[$row.Key] = & $displayOf $row.Name } + $groupNameById = @{} + foreach ($group in $groups) { $groupNameById[[string]$group.id] = [string]$group.name } + + $compared = 0 + $nameMismatch = New-Object System.Collections.Generic.List[string] + $lifecycleMismatch = New-Object System.Collections.Generic.List[string] + $managerMismatch = New-Object System.Collections.Generic.List[string] + $managersCompared = 0 + $directoryMismatch = New-Object System.Collections.Generic.List[string] + $roleDataName = @{} + foreach ($row in $roleRows) { $roleDataName[$row.Key] = $row.Name } + $groupDataName = @{} + foreach ($row in $groupRows) { $groupDataName[$row.Key] = $row.Name } + $withFactor = 0 + $expectedGroup = New-Object System.Collections.Generic.List[string] + $foundGroup = New-Object System.Collections.Generic.List[string] + + foreach ($row in $userRows) { + $login = & $usernameOf $row.Key + $user = $userByLogin[$login.ToLowerInvariant()] + if ($row.Group) { $expectedGroup.Add(('{0} <- {1}' -f $groupNameByKey[$row.Group], $login)) } + if (-not $user) { continue } + $compared++ + + if (-not [string]::Equals([string]$user.firstname, [string]$row.GivenName, [StringComparison]::Ordinal)) { + $nameMismatch.Add(("{0}: first name '{1}' should be '{2}'" -f $row.Key, $user.firstname, $row.GivenName)) + } + if (-not [string]::Equals([string]$user.lastname, [string]$row.Surname, [StringComparison]::Ordinal)) { + $nameMismatch.Add(("{0}: last name '{1}' should be '{2}'" -f $row.Key, $user.lastname, $row.Surname)) + } + if ([int]$user.status -ne [int]$script:OneLoginUserStatus[$row.Status]) { + $lifecycleMismatch.Add(('{0}: status {1} should be {2} ({3})' -f $row.Key, $user.status, $script:OneLoginUserStatus[$row.Status], $row.Status)) + } + elseif ($row.Status -eq 'Locked') { + $until = $null + if ($user.locked_until) { try { $until = [datetime]$user.locked_until } catch { $until = $null } } + if (-not $until -or $until -lt (Get-Date).AddDays(1)) { + $lifecycleMismatch.Add(('{0}: locked until {1}, which is less than a day away' -f $row.Key, $user.locked_until)) + } + } + $directory = Resolve-OneLoginDirectoryIdentity -Row $row -RoleNameByKey $roleDataName -GroupNameByKey $groupDataName -Connection $connection + foreach ($name in $directory.Keys) { + if (-not [string]::Equals([string]$user.$name, [string]$directory[$name], [StringComparison]::Ordinal)) { + $directoryMismatch.Add(("{0}: {1} '{2}' should be '{3}'" -f $row.Key, $name, $user.$name, $directory[$name])) + } + } + if ($row.Mfa) { + $devices = @(Invoke-OneLoginRequest -Method GET -Path "mfa/users/$($user.id)/devices" -Connection $connection | ForEach-Object { $_ } | Where-Object { $null -ne $_ }) + if ($devices.Count -gt 0) { $withFactor++ } + } + if ([int]$user.state -ne [int]$script:OneLoginUserState[$row.State]) { + $lifecycleMismatch.Add(('{0}: state {1} should be {2} ({3})' -f $row.Key, $user.state, $script:OneLoginUserState[$row.State], $row.State)) + } + if ($row.Manager) { + $managersCompared++ + $manager = $userByLogin[(& $usernameOf $row.Manager).ToLowerInvariant()] + $actual = if ($user.manager_user_id) { $loginById[[string]$user.manager_user_id] } else { $null } + if (-not $manager -or [string]$user.manager_user_id -ne [string]$manager.id) { + $shown = if ($actual) { $actual } elseif ($user.manager_user_id) { "user $($user.manager_user_id)" } else { 'nobody' } + $managerMismatch.Add(('{0}: manager is {1}, should be {2}' -f $row.Key, $shown, (& $usernameOf $row.Manager))) + } + } + if ($user.group_id -and $groupNameById.ContainsKey([string]$user.group_id)) { + $foundGroup.Add(('{0} <- {1}' -f $groupNameById[[string]$user.group_id], [string]$user.username)) + } + } + + $checks.Add((New-TestEnvironmentCheck -Name 'User names' -Compared $compared -Mismatch $nameMismatch.ToArray())) + $checks.Add((New-TestEnvironmentCheck -Name 'User lifecycle' -Compared $compared -Mismatch $lifecycleMismatch.ToArray())) + $checks.Add((New-TestEnvironmentCheck -Name 'Managers' -Compared $managersCompared -Mismatch $managerMismatch.ToArray())) + $checks.Add((New-TestEnvironmentCheck -Name 'Directory fields' -Compared $compared -Mismatch $directoryMismatch.ToArray())) + # Observational: a factor is enrolled only where the account offers it. + $checks.Add((New-TestEnvironmentCheck -Name 'MFA factors' -FoundCount $withFactor)) + $checks.Add((New-TestEnvironmentCheck -Name 'Group membership' -Expected $expectedGroup -Found $foundGroup -IgnoreCase -MissingOnly)) + + # --- Access ------------------------------------------------------------------------------ + if (-not $SkipMembership) { + $roleNameByKey = @{} + foreach ($row in $roleRows) { $roleNameByKey[$row.Key] = & $displayOf $row.Name } + $expectedRole = New-Object System.Collections.Generic.List[string] + foreach ($row in $userRows) { + foreach ($key in @(([string]$row.Roles -split ';') | Where-Object { $_ })) { + $expectedRole.Add(('{0} <- {1}' -f $roleNameByKey[$key], (& $usernameOf $row.Key))) + } + } + $foundRole = New-Object System.Collections.Generic.List[string] + $expectedGrant = New-Object System.Collections.Generic.List[string] + foreach ($row in $appRows) { + foreach ($key in @(([string]$row.Roles -split ';') | Where-Object { $_ })) { + $expectedGrant.Add(('{0} <- {1}' -f $roleNameByKey[$key], (& $displayOf $row.Name))) + } + } + $foundGrant = New-Object System.Collections.Generic.List[string] + foreach ($role in $roles) { + foreach ($id in @($role.users | Where-Object { $null -ne $_ })) { + if ($loginById.ContainsKey([string]$id)) { $foundRole.Add(('{0} <- {1}' -f $role.name, $loginById[[string]$id])) } + } + foreach ($id in @($role.apps | Where-Object { $null -ne $_ })) { + if ($appNameById.ContainsKey([string]$id)) { $foundGrant.Add(('{0} <- {1}' -f $role.name, $appNameById[[string]$id])) } + } + } + + $checks.Add((New-TestEnvironmentCheck -Name 'Role memberships' -Expected $expectedRole -Found $foundRole -IgnoreCase -MissingOnly)) + $checks.Add((New-TestEnvironmentCheck -Name 'App assignments' -Expected $expectedGrant -Found $foundGrant -MissingOnly)) + } + + return New-TestEnvironmentVerification -Provider 'OneLogin' -Target $connection.Subdomain -Check $checks.ToArray() -Quiet:$Quiet +} diff --git a/Providers/OneLogin/README.md b/Providers/OneLogin/README.md new file mode 100644 index 0000000..0c8e78d --- /dev/null +++ b/Providers/OneLogin/README.md @@ -0,0 +1,215 @@ +# The OneLogin provider + +Part of [TestEnvironment](../../README.md). + +Seeds users, roles, groups, apps, user mappings, security policies, API authorization servers, app +rules, a Smart Hook, a self-registration profile, MFA factors and custom user fields into one +**OneLogin** account, reports on them, and removes them again, proving ownership of each object +before deleting it. It works through the OneLogin API as an API credential. It is built to be safe in +an account real people sign in to, not only in a lab: nothing it creates can reach a real person, and +nothing real is ever pulled into what it creates. + +## ✅ What it creates, reports and removes + +| Object | Count | Detail | +|---|---|---| +| Custom user fields | 4 | Text; one carries the seed tag on every seeded person | +| Users | 321 | 21 hand-designed + 300 generated; every lifecycle status and state OneLogin keeps, one person locked, a manager for 317 of them, the custom fields above, and the directory identifiers a synchronised account carries | +| Licensed users | 10 | The Approved people. Everyone else is Unlicensed or Rejected on purpose, so a seed spends ten licences at most | +| Roles | 4 | 14 explicit memberships, and one more added by the enabled mapping | +| Groups | 5 | By office; 319 people placed in them | +| Security policies | 2 | Different password and lockout rules on three of the groups; the other two fall back to the default | +| Apps | 5 | OIDC web clients with Basic and Post authentication, a public client with PKCE, a native client, and SAML; 5 grants to roles | +| App rules | 2 | One enabled and one disabled, each setting a seeded app's groups claim by seeded role | +| API authorizations | 2 | 4 scopes (one granted to no app), 3 claims, and 3 seeded apps allowed to ask for them | +| Mappings | 2 | One enabled and one disabled, both gated so they can act on seeded people only | +| Smart Hooks | 1 | Pre-authentication, disabled, and gated on a seeded role | +| Self-registration profiles | 1 | Disabled, moderated, and open to the lab domain only | +| MFA factors | 3 | Email, pre-enrolled and verified, only where the account already offers it - a trial offers none, so none | + +Every object in that table carries the seed prefix on its name, and nothing else in the account is +created, changed or removed. + +## ⛔ What it does not touch + +Nothing outside the table above. In particular, the provider creates, edits and deletes none of: + +- the Default role or the default policy, or any role, group, app, policy, rule, hook, API server + or profile it did not create; +- which policy any group but its own uses, which factors the account offers, or the account's risk + rules; +- branding, privileges, directories or trusted identity providers; +- the account owner, any administrator, or any person it did not create - nobody who is not + seeded is ever added to a role or group, given a manager, made one, or given a factor; +- the API credential it authenticates as. + +Several things OneLogin offers are left out on purpose. **Devices**: OneLogin has no API to create +one; a device appears when an agent enrols it. **Risk rules**: a rule applies to everybody in the +account and cannot be scoped to seeded people. **Branding, privileges, trusted identity providers +and directories**: a trial refuses them, and each would change the whole account. + +Two lifecycle values are not seeded because OneLogin does not keep them, each checked against a +live account: **Unactivated**, which read back as PasswordPending moments after creation, and the +**Unapproved** state, which read back as Approved. **Locked** does not hold when it is sent as a +status either, so the locked person is created Active and then locked for a year through OneLogin's +lock call, which holds on a licensed person; a re-run locks them again when less than a month is +left. + +## 📋 What it needs + +- **An API credential** in the OneLogin admin portal: Developers, API Credentials, New Credential, + created by an account owner or super user, with the scope **Manage All**. Manage Users covers + people only, and the seed also creates roles, apps, mappings, policies, hooks and custom fields. +- **Room on the account's plan** for four roles, five apps and ten licensed people. A trial has five + roles (the Default role among them), five apps and twelve user licences (the owner among them). +- **No pre-authentication Smart Hook of the account's own.** OneLogin allows one hook of each type; + in an account that has one, the seed reports the refusal, and `-Skip Hooks` leaves the step out. +- **Windows PowerShell 5.1 or PowerShell 7**, and nothing else installed. + +Verified end to end against a OneLogin trial, from both PowerShell editions. + +```powershell +$secret = Read-Host 'Client secret' -AsSecureString +Connect-TestEnvironment -Provider OneLogin -Subdomain contoso -ClientId -ClientSecret $secret -SaveSecret + +New-TestEnvironment -WhatIf # list what it would create, creating nothing +New-TestEnvironment -ShowProgress # create it +Get-TestEnvironmentReport # report what it created +Test-TestEnvironment # verify it against the seed data +Remove-TestEnvironment -Force # remove it all + +# Every run after the first +Connect-TestEnvironment -Provider OneLogin -Subdomain contoso -UseStoredCredential +``` + +## ⏱️ Seed: ~2 minutes, up to ~6. Teardown: ~2 minutes. + +Measured for the full 321 people and every object type on a trial: a seed of 1 minute 47 seconds and +a teardown of 1 minute 42 on one run, and a seed of 5 minutes 49 seconds on another, the difference +being how long OneLogin took that day to show the role grants the users step waits for. The teardown +took 2 minutes 12 seconds on the slower run. `New-TestEnvironment -Tier Core` seeds every object +type with only the 21 hand-designed people. A seed and a teardown together stay far inside the +credential's five thousand API calls an hour. + +## 🔑 Connecting + +Every call goes to the account's own host, `https://.onelogin.com`, which serves the +token and the API whichever region the account lives in, so there is no region to name. +`-Subdomain` takes the bare name, the host or the portal URL. + +`-SaveSecret` writes the client id and secret to `~/.testenvironment/.onelogin.json` once +the connection has been proved, with the secret protected by DPAPI on Windows, or in a SecretStore +vault with `-UseSecretStore`. `-UseStoredCredential` reads it back on later runs. +`Disconnect-TestEnvironment` revokes the token, which otherwise lives ten hours. + +## 🛡️ Ownership + +Nothing is deleted for matching a name, and this provider has to prove more than most, because a +OneLogin role, group or mapping has nothing but a name to prove anything with. + +- **A person** needs the tag `ZZ-TEST-seed` in the custom field `zztest_seed_tag`, which the seed + creates and nobody else writes, **and** the prefix on the username. +- **An app** needs the tag in its description **and** the prefix on its name. The description says + "Seeded by TestEnvironment. Safe to delete." so an administrator who finds one knows what made it. +- **A role** needs the prefix, no administrators, at least one member, and every member a proved + seeded person and every app a proved seeded app. A prefixed role holding one real person is + refused. So is an empty one. +- **A group** needs the prefix, no administrators, at least one member, every member a proved + seeded person, and no policy unless it is a prefixed one that is not the default. +- **A policy** needs the prefix, not to be the default, and to be used by proved seeded groups and + nothing else. +- **A mapping** needs the prefix, match **all**, the seed-tag condition, and only add-role actions + naming seeded roles. +- **An app rule** needs the prefix and to sit on a proved seeded app, naming seeded roles only. +- **A Smart Hook** needs the marker line `// Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]` + first in its code, and conditions naming seeded roles only. A hook has no name or description. +- **An API authorization server** and **a self-registration profile** need the tag in their + description or help text **and** the prefix on the name. +- **A custom field** needs to be declared by this provider's data **and** to start `zztest_`. + +A mapping, rule or hook may also name a role that no longer exists, because teardown deletes the +roles and a re-run must still be able to prove what was left; it may never name a role that exists +and is somebody else's. + +Teardown proves everything before it deletes anything, because a role is proved by the people and +apps in it, a policy by its groups, and a mapping, rule or hook by the roles it names. A prefixed +object that fails its proof is listed with the reason and left alone. `-Keep` keeps what the kept +type is proved by, and says so: keeping people keeps the field that proves them, keeping roles keeps +their people and apps, keeping policies keeps their groups, and keeping mappings or the hook keeps +the roles they name. + +## 🚫 What it never does, and has no parameter for + +- **No mapping is created without the seed-tag condition, with match all.** An enabled mapping acts + on everybody who matches it; this one can only match seeded people. The condition is added by the + code, not the data. +- **No role or group is created that nobody being seeded will hold**, because an empty one could + never be proved and would be left behind. +- **No app's client secret is kept unless you ask.** OneLogin returns it on create and never again; + by default the provider keeps the id and drops it. See [Saved app secrets](#-saved-app-secrets). +- **No real person is touched.** Every id the seed sends is one it created or proved. +- **No seeded identifier can match a real one.** Every sAMAccountName and external id starts with + the prefix, every user principal name and distinguished name is in the lab domain, and every phone + number is in the 555-0100 to 555-0199 range reserved for fiction, so a tool that joins on any of + them finds the seed and nothing else. +- **No policy is made the default or attached to a group that holds anybody real.** +- **No API server lets an app outside the seed ask for its tokens,** and no seeded app is made a + client of a server outside it. +- **No app rule sits on an app outside the seed or names a role outside it,** and its action is + fixed: the groups claim, from `member_of`, which on a seeded person names seeded groups only. +- **The Smart Hook is always disabled and always gated on a seeded role**, and a re-run disables it + again. +- **The self-registration profile is always disabled, moderated, open to the lab domain only and + given no default role or group**, and a re-run puts that back. +- **No MFA factor is turned on for the account**, and a factor is enrolled on seeded people only and + only as already verified, so nothing is sent anywhere. + +## 🔐 Saved app secrets + +`New-TestEnvironment -SaveAppSecret` keeps the client secrets of the two confidential apps it +creates - Expenses Web and Payroll Console - so a relying party can be pointed at them. The public +and native clients and the SAML app have no secret. Each is written through the same record writer +as the connection's own credential: DPAPI-protected in `~/.testenvironment/.onelogin-app..json`, +or in a SecretStore vault with `-UseSecretStore`. + +```powershell +New-TestEnvironment -SaveAppSecret +Get-OneLoginAppCredential -Key expenses # the client id and secret as a PSCredential +``` + +OneLogin shows a secret only in the answer to the create, so an app that already existed has none to +save; the seed names it and warns. `Remove-TestEnvironment` deletes each saved secret with its app, +and any saved secret whose app is no longer in the account, so they do not build up; `-WhatIf` lists +them and deletes nothing, and `-Keep Apps` leaves them alone. The report shows which apps have one. + +The redirect URLs are under the lab email domain, so a test client has to be reachable there, or have +its own redirect URL added in the portal. The client credentials grant is not enabled on the seeded +apps, and a token request made with it is refused. + +## 🧭 What OneLogin does without saying so + +Each of these was found against a live trial, and the seed data and the provider are built around it. + +| OneLogin behaviour | What the provider does | +|---|---| +| Approves a person only while a licence is free, and otherwise makes them Unlicensed - while the create answers as if it had approved them | Ten people are Approved; the users step reads them back and names anyone left unlicensed | +| Accepts a role grant for anyone, and keeps it only for an Approved person whose status is Active, Suspended, Locked, PasswordExpired or AwaitingPasswordReset | The data gives roles to those people only, and a test holds it to that | +| Keeps a Rejected person out of any group as well as any role | The rejected partner is in no group | +| Shows a role grant some seconds after answering it, once minutes, and sometimes never | The users step waits up to four minutes until every grant it sent is visible, sending again the ones that do not appear | +| Lists users without their custom fields, roles, manager or directory fields unless they are asked for by name | Every user listing names its fields | +| Runs an enabled mapping as each person is created | Mappings are created before people | +| Returns only enabled mappings and app rules unless asked for the disabled ones | Both are asked for | +| Lists Smart Hooks without their code, and groups without their administrators | Each is read in detail before it is proved | +| Undoes a Locked status sent on a create or update | The person is locked through the lock call instead | + +## 🧪 Verification + +`Test-TestEnvironment` reads the account through the same ownership proofs teardown uses and checks +every name by codepoint, every lifecycle status and state (a locked person locked for at least +another day), every manager, every directory identifier, every group placement, every policy on its +groups and every policy setting, every API scope, claim and client, every app rule, the hook, the +sign-up profile, and every role membership and app grant the data lists. Role memberships are judged +on what is missing only, because the enabled mapping adds a person the data never lists there. How +many people hold an MFA factor is counted and not judged, because the seed enrols one only where the +account offers it. `Repair-TestEnvironment` re-runs the steps that own any failed check. +`Compare-TestEnvironment` matches the seeded people against any other connected provider's. diff --git a/Providers/OneLogin/Tools/New-OneLoginTestSeedData.ps1 b/Providers/OneLogin/Tools/New-OneLoginTestSeedData.ps1 new file mode 100644 index 0000000..ebf9e86 --- /dev/null +++ b/Providers/OneLogin/Tools/New-OneLoginTestSeedData.ps1 @@ -0,0 +1,394 @@ +#Requires -Version 5.1 + +<# + .SYNOPSIS + Regenerates the OneLogin provider's seed data CSVs + + .DESCRIPTION + This is an authoring tool, not part of the module. It runs by hand when the seed data + needs rebuilding, and the CSVs it writes are the committed artifact - the module never + calls this, and nothing it reads is needed at run time. + + The data has two halves and the distinction matters: + + - A hand-designed CORE, written into this file and preserved verbatim. These are the rows + chosen to be awkward in a way that breaks scripts: every lifecycle status and state that + OneLogin keeps once it is set, a suspended manager still managing two people, a rejected + partner, an unlicensed service account in no group, the boolean OneLogin makes you store + as text, the mapping that widens a one-person role and the one that is switched off and + would widen another if anyone switched it on, and the writing systems that are the only + coverage this module has for how string handling goes wrong. + + Two states are deliberately absent, each checked against a live account: Unactivated, which + read back as PasswordPending moments after creation, and the Unapproved state, which read + back as Approved. A column that is written and silently undone is worse than no column. + Locked is seeded, but not as a status - a status of 3 unlocks itself fifteen minutes later - + through the lock call, for a year, on a licensed person. + + Ten Core people are Approved, and so hold a user licence. A OneLogin trial has twelve, + the owner among them, and an account out of licences makes a person Unlicensed without + saying so, so the Core is sized to leave the owner and one more. The writing-system + cohort is Unlicensed: what those people test is their names, which no licence changes. + + Only an Approved person whose status is Active, Suspended, PasswordExpired or + AwaitingPasswordReset can hold a role: OneLogin accepts a grant to anyone else and drops + it. So the data gives roles to those people only, and every role has at least one of them + in the Core, which a test holds the data to. A OneLogin role or group has nothing but its + name to say who made it, so teardown proves one by what it holds; an empty one cannot be + proved, and would be left behind. + + - A generated BULK: the people read from Providers/AD/Data/ADUsers.csv, given a group by + office, a manager by the AD chain, and a lifecycle status here. Every one is Unlicensed + on purpose: three hundred licensed people would not fit a trial and would spend a paid + account's licences, and an unlicensed person still pages, reports, sits in a group and + has a manager. They hold no roles, because an unlicensed person cannot. This is what + makes pagination real and makes a report that works on twenty users prove something + about three hundred. + + Everything here is deterministic. Attributes that need to vary are derived from a stable + hash of the object's own key rather than from Get-Random, so regenerating produces + identical files and a diff shows real changes rather than churn. + + .PARAMETER AdDataPath + The folder holding ADUsers.csv. Defaults to the copy in this repository. + + .PARAMETER OutputPath + Where to write the CSVs. Defaults to the OneLogin provider's own Data folder. + + .EXAMPLE + PS> .\New-OneLoginTestSeedData.ps1 -Verbose + + DESCRIPTION: Regenerates every OneLogin seed CSV + OUTPUT: Row counts per file + USE CASE: After changing the core rows, or after the shared people file changes + + .NOTES + Author: Jeffrey Stuhr + Blog: https://www.techbyjeff.net + LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + #> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter()] + [string]$AdDataPath = (Join-Path (Join-Path $PSScriptRoot '..\..\AD') 'Data'), + + [Parameter()] + [string]$OutputPath = (Join-Path $PSScriptRoot '..\Data') +) + +$ErrorActionPreference = 'Stop' + +$AdDataPath = (Resolve-Path -LiteralPath $AdDataPath).ProviderPath +$OutputPath = (Resolve-Path -LiteralPath $OutputPath).ProviderPath +Write-Verbose "Reading the AD people from $AdDataPath" +Write-Verbose "Writing OneLogin data to $OutputPath" + +function Get-StableHash { + param([string]$Text) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { $hash = $sha.ComputeHash($bytes) } finally { $sha.Dispose() } + return [BitConverter]::ToUInt32($hash, 0) +} + +# The same key derivation the other tools use, so a person has the same key in every lab. +function ConvertTo-Key { + param([string]$Text) + $clean = ($Text -replace '[^A-Za-z0-9]', '').ToLowerInvariant() + if ($clean.Length -gt 40) { $clean = $clean.Substring(0, 40) } + return $clean +} + +# -------------------------------------------------------------------------------------- +# The hand-designed core. Preserved verbatim; never generated. +# -------------------------------------------------------------------------------------- + +# The shared people. Every name this generator writes for a person who also exists in the other +# providers comes from Core/Data/SeedPeople.csv, so one identity exists in every lab and a name +# cannot drift between generators. The decomposed Jose lives there too, and a test pins its +# codepoints, because an editor that normalised a file on save would erase the case without +# changing a visible character. +$P = @{} +foreach ($sharedPerson in (Import-Csv -LiteralPath (Join-Path $PSScriptRoot '..\..\..\Core\Data\SeedPeople.csv') -Encoding UTF8)) { + $P[$sharedPerson.Key] = $sharedPerson +} +# The AD data logs the shared people in under logins of its own - josen for jnino - and every one +# of them is already a core row here. Their AD rows are skipped as people, so nobody exists twice +# under two logins, and remembered by name under the shared key, so a bulk person whose manager +# they are still points at them. +$sharedKeyByName = @{} +foreach ($sharedPerson in $P.Values) { $sharedKeyByName[$sharedPerson.DisplayName] = $sharedPerson.Key } + +# Custom user fields. OneLogin's are text and nothing else. The first is the ownership marker and is +# not optional: a OneLogin user has no description, so without it a seeded user could be claimed by +# nothing but its username. Every shortname starts zztest_, because a field has no description +# either and the shortname is the only part of it teardown can check. +$coreAttributes = @( + [ordered]@{ Shortname = 'zztest_seed_tag'; Name = 'ZZ-TEST seed tag'; Tier = 'Core'; Purpose = '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' } + [ordered]@{ Shortname = 'zztest_badge_id'; Name = 'ZZ-TEST badge id'; Tier = 'Core'; Purpose = 'An identifier outside the directory''s own, absent on some users, so a report has to cope with a field that is simply empty' } + [ordered]@{ Shortname = 'zztest_contractor'; Name = 'ZZ-TEST contractor'; Tier = 'Core'; Purpose = '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' } + [ordered]@{ Shortname = 'zztest_cost_center'; Name = 'ZZ-TEST cost center'; Tier = 'Core'; Purpose = 'A value shared by whole departments, which a mapping or report can group on' } +) + +# Roles. OneLogin grants app access through roles, so these are where access lives. Four, because a +# trial allows five and the Default role is one of them. Every one has a Core member able to hold it, +# so every tier that includes Core can prove every role at teardown. +$coreRoles = @( + [ordered]@{ Key = 'all-staff'; Name = 'All Staff'; Tier = 'Core'; Purpose = 'The broad role nearly every licensed employee holds, and the one most apps are granted through; it holds a suspended manager and people whose passwords have lapsed' } + [ordered]@{ Key = 'engineering'; Name = 'Engineering'; Tier = 'Core'; Purpose = 'A department role, and the one the disabled mapping would hand to every contractor if anybody enabled it' } + [ordered]@{ Key = 'finance'; Name = 'Finance'; Tier = 'Core'; Purpose = 'One member in the data and a second from the enabled mapping, so who can reach payroll depends on whether mappings have run' } + [ordered]@{ Key = 'contractors'; Name = 'Contractors'; Tier = 'Core'; Purpose = 'Held by one contractor, while another contractor is licensed, still waiting for a password, and holds nothing' } +) + +# Groups. A OneLogin user is in one group at most, and a group is where a security policy is +# applied, so these follow office location the way a real account's do. The seed never gives one a +# policy: it creates none, and attaching somebody else's would change how real people sign in. +$coreGroups = @( + [ordered]@{ Key = 'seattle-hq'; Name = 'Seattle HQ'; Tier = 'Core'; Purpose = 'Where most of the seeded people are, which makes it the group a per-group report is dominated by' } + [ordered]@{ Key = 'london'; Name = 'London'; Tier = 'Core'; Purpose = 'An office outside the US, holding a person awaiting a password reset and an unlicensed partner' } + [ordered]@{ Key = 'new-york'; Name = 'New York'; Tier = 'Core'; Purpose = 'A second US office, so anything that assumes one is wrong' } + [ordered]@{ Key = 'us-regional'; Name = 'US Regional Offices'; Tier = 'Core'; Purpose = 'Several small offices in one group, including the suspended manager' } + [ordered]@{ Key = 'remote'; Name = 'Remote Workers'; Tier = 'Core'; Purpose = 'Contractors with no office' } +) + +# Users. Hand-designed people, each carrying one lifecycle state, manager shape or name shape +# OneLogin has to store and report correctly. Managers are Core people only, so a Core-only seed +# is complete in itself; the Bulk chain hangs from awhitfield. Ten are Approved; roles go only +# to those whose status lets them hold one. +$coreUsers = @( + [ordered]@{ Key = 'awhitfield'; GivenName = $P['awhitfield'].GivenName; Surname = $P['awhitfield'].Surname; Title = 'Chief Executive'; Department = 'Executive'; Company = ''; Manager = ''; Status = 'Active'; State = 'Approved'; Group = 'seattle-hq'; Roles = 'all-staff'; BadgeId = 'B-1001'; Contractor = 'FALSE'; CostCenter = 'CC-100'; Tier = 'Core'; Purpose = 'Top of every manager chain' } + [ordered]@{ Key = 'jnino'; GivenName = $P['jnino'].GivenName; Surname = $P['jnino'].Surname; Title = 'Principal Engineer'; Department = 'Engineering'; Company = ''; Manager = 'awhitfield'; Status = 'Active'; State = 'Approved'; Group = 'seattle-hq'; Roles = 'all-staff;engineering'; BadgeId = 'B-1002'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Accented name with an ASCII username, managing most of the writing-system cohort' } + [ordered]@{ Key = 'zmueller'; GivenName = $P['zmueller'].GivenName; Surname = $P['zmueller'].Surname; Title = 'Staff Engineer'; Department = 'Engineering'; Company = ''; Manager = 'jnino'; Status = 'PasswordExpired'; State = 'Approved'; Group = 'seattle-hq'; Roles = 'all-staff;engineering'; BadgeId = 'B-1003'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Accented name, and a password that has expired while every role stays in place' } + [ordered]@{ Key = 'mbell'; GivenName = $P['mbell'].GivenName; Surname = $P['mbell'].Surname; Title = 'Sales Director'; Department = 'Sales'; Company = ''; Manager = 'awhitfield'; Status = 'Suspended'; State = 'Approved'; Group = 'us-regional'; Roles = 'all-staff'; BadgeId = 'B-1004'; Contractor = 'FALSE'; CostCenter = 'CC-300'; Tier = 'Core'; Purpose = 'Suspended but still holding All Staff and still the manager of two people; the half-finished offboarding' } + [ordered]@{ Key = 'praghunathan'; GivenName = $P['praghunathan'].GivenName; Surname = $P['praghunathan'].Surname; Title = 'Financial Controller'; Department = 'Finance'; Company = ''; Manager = 'awhitfield'; Status = 'Active'; State = 'Approved'; Group = 'seattle-hq'; Roles = 'all-staff;finance'; BadgeId = 'B-1005'; Contractor = 'FALSE'; CostCenter = 'CC-400'; Tier = 'Core'; Purpose = 'The only member of Finance the data names, and the only person the payroll app was meant for' } + [ordered]@{ Key = 'talvarez'; GivenName = $P['talvarez'].GivenName; Surname = $P['talvarez'].Surname; Title = 'Systems Architect'; Department = 'IT'; Company = ''; Manager = 'jnino'; Status = 'Active'; State = 'Approved'; Group = 'new-york'; Roles = 'all-staff;engineering'; BadgeId = 'B-1006'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'In Engineering by role and IT by department, and the manager of the contractors' } + [ordered]@{ Key = 'hkobayashi'; GivenName = $P['hkobayashi'].GivenName; Surname = $P['hkobayashi'].Surname; Title = 'Security Consultant'; Department = 'Contractors'; Company = 'Northwind Staffing'; Manager = 'talvarez'; Status = 'Active'; State = 'Approved'; Group = 'remote'; Roles = 'contractors'; BadgeId = 'C-2001'; Contractor = 'TRUE'; CostCenter = 'CC-900'; Tier = 'Core'; Purpose = 'Kanji name, a contractor managed by an employee, and no part of All Staff' } + [ordered]@{ Key = 'ofitzgerald'; GivenName = $P['ofitzgerald'].GivenName; Surname = $P['ofitzgerald'].Surname; Title = 'UX Contractor'; Department = 'Contractors'; Company = 'Northwind Staffing'; Manager = 'talvarez'; Status = 'Locked'; State = 'Approved'; Group = 'remote'; Roles = 'contractors'; BadgeId = ''; Contractor = 'FALSE'; CostCenter = ''; Tier = 'Core'; Purpose = 'Locked out for a year and still holding the Contractors role; no badge, no cost center, and a contractor field that says false' } + [ordered]@{ Key = 'nsorensen'; GivenName = $P['nsorensen'].GivenName; Surname = $P['nsorensen'].Surname; Title = 'People Partner'; Department = 'Human Resources'; Company = ''; Manager = 'awhitfield'; Status = 'AwaitingPasswordReset'; State = 'Approved'; Group = 'london'; Roles = 'all-staff'; BadgeId = 'B-1009'; Contractor = 'FALSE'; CostCenter = 'CC-500'; Tier = 'Core'; Purpose = 'A stroked o, and a password reset she has been asked for and not done' } + [ordered]@{ Key = 'svcreporting'; GivenName = 'Reporting'; Surname = 'Service'; Title = 'Service Account'; Department = 'IT'; Company = ''; Manager = ''; Status = 'Active'; State = 'Unlicensed'; Group = ''; Roles = ''; BadgeId = 'S-9001'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'A non-human account, unlicensed, in no group and no role, which must never be mistaken for the API credential' } + [ordered]@{ Key = 'pmorel'; GivenName = 'Pascale'; Surname = 'Morel'; Title = 'Partner Analyst'; Department = 'Partners'; Company = 'Fabrikam Partners'; Manager = ''; Status = 'PasswordPending'; State = 'Rejected'; Group = ''; Roles = ''; BadgeId = 'P-3001'; Contractor = 'TRUE'; CostCenter = 'CC-900'; Tier = 'Core'; Purpose = 'Rejected by an administrator and never given a password; present, visible, and unable to do anything. In no group, because OneLogin drops a rejected person''s group as it drops her roles' } + [ordered]@{ Key = 'lpetit'; GivenName = ('L' + [char]0x00E9 + 'a'); Surname = 'Petit'; Title = 'Partner Consultant'; Department = 'Partners'; Company = 'Fabrikam Partners'; Manager = ''; Status = 'Active'; State = 'Unlicensed'; Group = 'london'; Roles = ''; BadgeId = 'P-3002'; Contractor = 'TRUE'; CostCenter = 'CC-900'; Tier = 'Core'; Purpose = 'Active and unlicensed, which reads as able to sign in to anything that checks status alone' } + + # The writing systems. Every username stays plain ASCII: it is what the directory constrains + # and what the seed prefix is built onto, and the script belongs in the name. + [ordered]@{ Key = 'jjiang'; GivenName = $P['jjiang'].GivenName; Surname = $P['jjiang'].Surname; Title = 'Site Reliability Engineer'; Department = 'Engineering'; Company = ''; Manager = 'jnino'; Status = 'Active'; State = 'Unlicensed'; Group = 'seattle-hq'; Roles = ''; BadgeId = 'B-1021'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Han name, family name first, joined by an ideographic space that is not U+0020' } + [ordered]@{ Key = 'tyoshida'; GivenName = $P['tyoshida'].GivenName; Surname = $P['tyoshida'].Surname; Title = 'Build Engineer'; Department = 'Engineering'; Company = ''; Manager = 'jnino'; Status = 'Active'; State = 'Unlicensed'; Group = 'seattle-hq'; Roles = ''; BadgeId = 'B-1022'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'A surname above the basic plane, so one character is two UTF-16 units and truncation splits it' } + [ordered]@{ Key = 'dvolkov'; GivenName = $P['dvolkov'].GivenName; Surname = $P['dvolkov'].Surname; Title = 'Infrastructure Engineer'; Department = 'IT'; Company = ''; Manager = 'talvarez'; Status = 'Active'; State = 'Unlicensed'; Group = 'new-york'; Roles = ''; BadgeId = 'B-1023'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Cyrillic homoglyphs, which a duplicate check made by eye cannot tell from Latin' } + [ordered]@{ Key = 'gpapadopoulos'; GivenName = $P['gpapadopoulos'].GivenName; Surname = $P['gpapadopoulos'].Surname; Title = 'Financial Analyst'; Department = 'Finance'; Company = ''; Manager = 'praghunathan'; Status = 'Active'; State = 'Approved'; Group = 'seattle-hq'; Roles = 'all-staff'; BadgeId = 'B-1024'; Contractor = 'FALSE'; CostCenter = 'CC-400'; Tier = 'Core'; Purpose = 'Greek final sigma, and in Finance by department but not by role, until the enabled mapping puts him there' } + [ordered]@{ Key = 'malahmad'; GivenName = $P['malahmad'].GivenName; Surname = $P['malahmad'].Surname; Title = 'Network Engineer'; Department = 'IT'; Company = ''; Manager = 'talvarez'; Status = 'Active'; State = 'Unlicensed'; Group = 'seattle-hq'; Roles = ''; BadgeId = 'B-1025'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Right-to-left, stored in one order and displayed in another wherever it is joined to a Latin username' } + [ordered]@{ Key = 'jmarchetti'; GivenName = $P['jmarchetti'].GivenName; Surname = $P['jmarchetti'].Surname; Title = 'Campaign Manager'; Department = 'Marketing'; Company = ''; Manager = 'mbell'; Status = 'Active'; State = 'Unlicensed'; Group = 'seattle-hq'; Roles = ''; BadgeId = 'B-1026'; Contractor = 'FALSE'; CostCenter = 'CC-600'; Tier = 'Core'; Purpose = 'The same José the eye reads on jnino and a different string to every comparison, because this one is stored decomposed; and reports to a suspended manager' } + [ordered]@{ Key = 'iisik'; GivenName = $P['iisik'].GivenName; Surname = $P['iisik'].Surname; Title = 'HR Advisor'; Department = 'Human Resources'; Company = ''; Manager = 'nsorensen'; Status = 'Active'; State = 'Unlicensed'; Group = 'london'; Roles = ''; BadgeId = 'B-1027'; Contractor = 'FALSE'; CostCenter = 'CC-500'; Tier = 'Core'; Purpose = 'Turkish dotted and dotless i, which lower-case differently under a Turkish culture' } + [ordered]@{ Key = 'jweiss'; GivenName = $P['jweiss'].GivenName; Surname = $P['jweiss'].Surname; Title = 'Account Executive'; Department = 'Sales'; Company = ''; Manager = 'mbell'; Status = 'Active'; State = 'Unlicensed'; Group = 'us-regional'; Roles = ''; BadgeId = 'B-1028'; Contractor = 'FALSE'; CostCenter = 'CC-300'; Tier = 'Core'; Purpose = 'An eszett, which upper-cases into two characters, and a second report of the suspended manager' } + [ordered]@{ Key = 'schaudhary'; GivenName = $P['schaudhary'].GivenName; Surname = $P['schaudhary'].Surname; Title = 'Data Engineer'; Department = 'Engineering'; Company = ''; Manager = 'jnino'; Status = 'Active'; State = 'Unlicensed'; Group = 'seattle-hq'; Roles = ''; BadgeId = 'B-1029'; Contractor = 'FALSE'; CostCenter = 'CC-200'; Tier = 'Core'; Purpose = 'Devanagari combining vowel signs, so character count and visible marks are different numbers' } +) + +# The directory-style columns every Core person also carries: the name as a directory displays it, +# which puts the family name first where the person's own name does; an employee id the provider +# writes as external_id behind the seed prefix; a phone number in the 555 range kept for fiction; a +# preferred locale for the people whose names test it; and the MFA factor to pre-enrol, which the +# provider does only where the account offers that factor. +$coreLocale = @{ iisik = 'tr-TR'; hkobayashi = 'ja'; jweiss = 'de' } +$coreMfa = @{ awhitfield = 'Email'; praghunathan = 'Email'; talvarez = 'Email' } +$number = 0 +foreach ($row in $coreUsers) { + $number++ + $row['DisplayName'] = if ($P.ContainsKey($row.Key)) { $P[$row.Key].DisplayName } else { ('{0} {1}' -f $row.GivenName, $row.Surname) } + $row['EmployeeId'] = 'EMP-C{0:D3}' -f $number + $row['Phone'] = '(206) 555-{0:D4}' -f (100 + $number) + $row['Locale'] = if ($coreLocale.ContainsKey($row.Key)) { $coreLocale[$row.Key] } else { '' } + $row['Mfa'] = if ($coreMfa.ContainsKey($row.Key)) { $coreMfa[$row.Key] } else { '' } +} + +# User security policies. OneLogin applies one to everybody in a group, so each is attached to seeded +# groups only and is never the account's default; a group with none falls back to that default, which +# is the comparison a policy review makes. The settings are the password and lockout ones a review +# reads first. +$corePolicies = @( + [ordered]@{ Key = 'strict-office'; Name = 'Strict Office'; Groups = 'seattle-hq;new-york'; MinimumPasswordLength = '14'; PasswordExpirationDays = '60'; PasswordsRemembered = '24'; MaximumInvalidLoginAttempts = '5'; LockEffectiveMinutes = '30'; Tier = 'Core'; Purpose = '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' } + [ordered]@{ Key = 'contractor-access'; Name = 'Contractor Access'; Groups = 'remote'; MinimumPasswordLength = '12'; PasswordExpirationDays = '30'; PasswordsRemembered = '6'; MaximumInvalidLoginAttempts = '3'; LockEffectiveMinutes = '60'; Tier = 'Core'; Purpose = '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' } +) + +# API authorization servers: OneLogin as the authorization server for an API, with its scopes, the +# claims it puts in a token, and the seeded apps allowed to ask for it. Scopes are value=description, +# claims name=user attribute, clients app key=space-separated scopes; each list separated by |. The +# API's identifier is its Path under the connection's domain. +$coreApiAuthorizations = @( + [ordered]@{ Key = 'orders-api'; Name = 'Orders API'; Path = 'orders'; TokenMinutes = '60'; Scopes = 'orders:read=Read orders|orders:write=Create and change orders|orders:admin=Administer orders'; Claims = 'department=department|cost_center=custom_attribute_zztest_cost_center'; Clients = 'expenses=orders:read|contractor-portal=orders:read orders:write'; Description = 'Seeded API'; Tier = 'Core'; Purpose = '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' } + [ordered]@{ Key = 'reports-api'; Name = 'Reports API'; Path = 'reports'; TokenMinutes = '10'; Scopes = 'reports:read=Read reports'; Claims = 'department=department'; Clients = 'payroll=reports:read'; Description = 'Seeded API'; Tier = 'Core'; Purpose = 'One scope and a ten-minute token, so two servers do not look alike' } +) + +# App rules: entitlements an app hands out by role. Each sets the OIDC groups claim from member_of, +# through an expression that keeps the group names; the provider writes that action, so a rule can +# only ever name a seeded role on a seeded app. +$coreAppRules = @( + [ordered]@{ Key = 'expenses-groups'; App = 'expenses'; Name = 'Directory groups for staff'; Enabled = 'TRUE'; Role = 'all-staff'; Operator = 'ri'; Expression = 'CN=([^,]+)'; Tier = 'Core'; Purpose = 'Enabled: anybody in All Staff gets their directory group names in the token, so a claim changes with group membership' } + [ordered]@{ Key = 'payroll-dormant'; App = 'payroll'; Name = 'Groups for everyone outside Finance'; Enabled = 'FALSE'; Role = 'finance'; Operator = 'rin'; Expression = '.*'; Tier = 'Core'; Purpose = 'Disabled, and would hand payroll every group of everyone who is not in Finance if anybody enabled it' } +) + +# Smart Hooks. One pre-authentication hook, always created disabled and conditioned on a seeded role, +# so even if somebody enabled it, it would run for seeded people alone. It changes nothing: it hands +# back the policy the person already has. +$coreHooks = @( + [ordered]@{ Key = 'contractor-preauth'; Type = 'pre-authentication'; Role = 'contractors'; Tier = 'Core'; Purpose = 'A disabled pre-authentication hook gated on a seeded role, which a review of what runs at sign-in has to find' } +) + +# Self-registration. Always disabled and moderated, with no default role or group, and open only to +# addresses at the lab domain, so no real person could register through it even if it were enabled. +$coreSelfRegistrations = @( + [ordered]@{ Key = 'partner-signup'; Name = 'Partner Sign-up'; Tier = 'Core'; Purpose = 'A dormant public sign-up page, which an access review has to find because enabling it is one click' } +) + +# Apps, across the two protocols and every OIDC client shape the connector offers. Five, because a +# trial allows five. On OneLogin a public client is chosen by token endpoint authentication None, +# and that is PKCE: the connector has no public client without it. Every URL is under the +# connection's own domain. +$coreApps = @( + [ordered]@{ Key = 'expenses'; Name = 'Expenses Web'; Connector = 'OIDC'; AppType = 'Web'; TokenAuth = 'Basic'; LoginUrl = 'https://expenses.{domain}/'; RedirectUri = 'https://expenses.{domain}/callback'; Audience = ''; ConsumerUrl = ''; Visible = 'TRUE'; Roles = 'all-staff'; Description = 'Seeded confidential web app'; Tier = 'Core'; Purpose = 'The ordinary confidential web app, granted through a role rather than to people' } + [ordered]@{ Key = 'payroll'; Name = 'Payroll Console'; Connector = 'OIDC'; AppType = 'Web'; TokenAuth = 'Post'; LoginUrl = 'https://payroll.{domain}/'; RedirectUri = 'https://payroll.{domain}/callback'; Audience = ''; ConsumerUrl = ''; Visible = 'TRUE'; Roles = 'finance'; Description = 'Seeded confidential web app'; Tier = 'Core'; Purpose = 'Granted to Finance, whose audience a mapping widens, and posting its secret in the body rather than a header' } + [ordered]@{ Key = 'contractor-portal'; Name = 'Contractor Portal SPA'; Connector = 'OIDC'; AppType = 'Web'; TokenAuth = 'None'; LoginUrl = ''; RedirectUri = 'https://portal.{domain}/'; Audience = ''; ConsumerUrl = ''; Visible = 'TRUE'; Roles = 'contractors'; Description = 'Seeded public client'; Tier = 'Core'; Purpose = 'A public client with PKCE and no secret, granted only to contractors' } + [ordered]@{ Key = 'field-native'; Name = 'Field App'; Connector = 'OIDC'; AppType = 'Native'; TokenAuth = 'None'; LoginUrl = ''; RedirectUri = 'com.example.field://callback'; Audience = ''; ConsumerUrl = ''; Visible = 'FALSE'; Roles = ''; Description = 'Seeded native client'; Tier = 'Core'; Purpose = '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' } + [ordered]@{ Key = 'wiki-saml'; Name = 'Wiki SAML'; Connector = 'SAML'; AppType = ''; TokenAuth = ''; LoginUrl = 'https://wiki.{domain}/login'; RedirectUri = ''; Audience = 'https://wiki.{domain}/sp'; ConsumerUrl = 'https://wiki.{domain}/acs'; Visible = 'TRUE'; Roles = 'all-staff;contractors'; Description = 'Seeded SAML app'; Tier = 'Core'; Purpose = 'SAML rather than OIDC, so anything that assumes every app has a client id has one that does not; granted to two roles' } +) + +# Mappings. Every one the seed creates also carries a condition that the seed tag field holds the +# tag, with match all, whatever this file says - the provider adds it and has no switch to leave it +# out - so an enabled mapping can only ever act on seeded people. Conditions here are +# source|operator|value, separated by semicolons. +$coreMappings = @( + [ordered]@{ Key = 'finance-dept'; Name = 'Finance department gets Finance'; Enabled = 'TRUE'; Conditions = 'department|=|Finance'; Role = 'finance'; Tier = 'Core'; Purpose = 'Enabled, so Finance holds a person the data never lists there, and verification has to judge role membership on what is missing only' } + [ordered]@{ Key = 'contractor-eng'; Name = 'Contractors get Engineering'; Enabled = 'FALSE'; Conditions = 'custom_attribute_zztest_contractor|=|true'; Role = 'engineering'; Tier = 'Core'; Purpose = 'Disabled, and would put every contractor into Engineering if anyone enabled it; the dormant rule an access review has to find' } +) + +# -------------------------------------------------------------------------------------- +# Bulk, generated from the AD people +# -------------------------------------------------------------------------------------- + +$adUsers = @(Import-Csv -LiteralPath (Join-Path $AdDataPath 'ADUsers.csv') -Encoding UTF8) +Write-Verbose "Read $($adUsers.Count) people" + +$coreKeys = [System.Collections.Generic.HashSet[string]]::new([string[]]@($coreUsers.Key)) + +# A key for every AD person, by name, so a manager written as a distinguished name resolves. +$keyByName = @{} +foreach ($adUser in $adUsers) { + if ($sharedKeyByName.ContainsKey($adUser.Name)) { $keyByName[$adUser.Name] = $sharedKeyByName[$adUser.Name]; continue } + $key = ConvertTo-Key $adUser.SamAccountName + if (-not $key) { $key = ConvertTo-Key $adUser.Name } + if ($key) { $keyByName[$adUser.Name] = $key } +} + +$groupOf = { + param($adUser) + if (-not $adUser.City -or $adUser.Office -like 'Remote*') { return 'remote' } + switch ($adUser.City) { + 'Seattle' { return 'seattle-hq' } + 'Bellevue' { return 'seattle-hq' } + 'London' { return 'london' } + 'New York' { return 'new-york' } + } + return 'us-regional' +} + +$seenKey = @{} +$bulkRows = [System.Collections.Generic.List[object]]::new() +$managerOf = @{} + +foreach ($adUser in $adUsers) { + if ($sharedKeyByName.ContainsKey($adUser.Name)) { continue } + $key = $keyByName[$adUser.Name] + if (-not $key -or $coreKeys.Contains($key) -or $seenKey.ContainsKey($key)) { continue } + $seenKey[$key] = $true + + $hash = Get-StableHash $key + $isContractor = ($adUser.EmployeeType -eq 'Contractor' -or $adUser.Department -eq '1099 Contractor') + + + # The chain hangs from awhitfield: the AD data's top person reports to her. + $manager = 'awhitfield' + if ($adUser.Manager) { + $managerName = $adUser.Manager -replace '^CN=', '' + if ($keyByName.ContainsKey($managerName)) { $manager = $keyByName[$managerName] } + } + $managerOf[$key] = $manager + + $bucket = $hash % 100 + $status = if ($adUser.Enabled -eq 'False') { 'Suspended' } + elseif ($bucket -lt 80) { 'Active' } + elseif ($bucket -lt 92) { 'PasswordPending' } + elseif ($bucket -lt 97) { 'PasswordExpired' } + else { 'AwaitingPasswordReset' } + + $department = if ($adUser.Department) { $adUser.Department } else { 'General' } + $costCenter = 'CC-{0}' -f (700 + ((Get-StableHash $department) % 200)) + + $bulkRows.Add([ordered]@{ + Key = $key + GivenName = $adUser.GivenName + Surname = $adUser.Surname + Title = $adUser.Title + Department = $adUser.Department + Company = $(if ($isContractor) { 'Northwind Staffing' } else { '' }) + Manager = $manager + Status = $status + State = 'Unlicensed' + Group = (& $groupOf $adUser) + Roles = '' + BadgeId = '' + Contractor = $(if ($isContractor) { 'TRUE' } else { 'FALSE' }) + CostCenter = $costCenter + DisplayName = $adUser.Name + EmployeeId = $(if ($adUser.EmployeeID) { $adUser.EmployeeID } else { 'EMP-B{0:D3}' -f ($bulkRows.Count + 1) }) + # Never the AD row's own number, which is not reliably fictional; 555-0100 to 555-0199 is + # the range reserved for fiction, so no seeded number can reach a real phone. + Phone = '(206) 555-01{0:D2}' -f ($hash % 100) + Locale = '' + Mfa = '' + Tier = 'Bulk' + Purpose = "Bulk directory volume ($department)" + }) +} + +# Badge ids in file order, after the sort, so they read as a sequence. +# Managers before the people who report to them, so the seed can set a manager at creation rather +# than coming back for it. Depth first, then key, so the order is stable. +$bulkByKey = @{} +foreach ($row in $bulkRows) { $bulkByKey[$row.Key] = $row } +$depthOf = { + param([string]$Key) + $depth = 0; $cursor = $Key; $guard = 0 + while ($bulkByKey.ContainsKey($cursor) -and $guard -lt 50) { $depth++; $cursor = $managerOf[$cursor]; $guard++ } + return $depth +} +$sortedBulk = @($bulkRows | Sort-Object -Property @{ Expression = { & $depthOf $_.Key } }, @{ Expression = { $_.Key } }) +$badge = 3000 +foreach ($row in $sortedBulk) { $badge++; $row.BadgeId = 'B-{0}' -f $badge } + +Write-Verbose "Users: $($coreUsers.Count) core + $($sortedBulk.Count) bulk" + +# -------------------------------------------------------------------------------------- +# Write +# -------------------------------------------------------------------------------------- + +$outputs = @( + @{ Name = 'OneLoginCustomAttributes'; Rows = $coreAttributes } + @{ Name = 'OneLoginRoles'; Rows = $coreRoles } + @{ Name = 'OneLoginGroups'; Rows = $coreGroups } + @{ Name = 'OneLoginApps'; Rows = $coreApps } + @{ Name = 'OneLoginMappings'; Rows = $coreMappings } + @{ Name = 'OneLoginPolicies'; Rows = $corePolicies } + @{ Name = 'OneLoginApiAuthorizations'; Rows = $coreApiAuthorizations } + @{ Name = 'OneLoginAppRules'; Rows = $coreAppRules } + @{ Name = 'OneLoginHooks'; Rows = $coreHooks } + @{ Name = 'OneLoginSelfRegistrations'; Rows = $coreSelfRegistrations } + @{ Name = 'OneLoginUsers'; Rows = (@($coreUsers) + @($sortedBulk)) } +) + +foreach ($output in $outputs) { + $path = Join-Path $OutputPath "$($output.Name).csv" + if ($PSCmdlet.ShouldProcess($path, 'Write seed data')) { + @($output.Rows) | ForEach-Object { [PSCustomObject]$_ } | + Export-Csv -LiteralPath $path -NoTypeInformation -Encoding UTF8 + } + Write-Output ('{0,-26} {1,5} rows -> {2}' -f $output.Name, @($output.Rows).Count, $path) +} diff --git a/Public/Connect-TestEnvironment.ps1 b/Public/Connect-TestEnvironment.ps1 index 98b0993..dc5f39e 100644 --- a/Public/Connect-TestEnvironment.ps1 +++ b/Public/Connect-TestEnvironment.ps1 @@ -9,7 +9,7 @@ [OutputType('TestEnvironmentConnection')] param( [Parameter(Mandatory = $true, Position = 0)] - [ValidateSet('AD', 'Authentik', 'Entra', 'FreeIPA', 'Okta', 'PingOne')] + [ValidateSet('AD', 'Authentik', 'Entra', 'FreeIPA', 'Okta', 'OneLogin', 'PingOne')] [string]$Provider, [Parameter()] diff --git a/README.md b/README.md index 6ddb644..b0c80af 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) > Seeds a realistic identity test environment in **Entra ID**, **Active Directory**, **Okta**, -> **Authentik**, **FreeIPA** or **PingOne SSO**, and tears it down again cleanly, proving ownership of every object +> **Authentik**, **FreeIPA**, **PingOne SSO** or **OneLogin**, and tears it down again cleanly, proving ownership of every object > before deleting it. ## 📖 Purpose @@ -25,7 +25,7 @@ Test-TestEnvironment Remove-TestEnvironment -Force ``` -**Six providers.** +**Seven providers.** | Provider | Connecting needs | Seeds | |---|---|---| @@ -35,6 +35,7 @@ Remove-TestEnvironment -Force | [`Authentik`](Providers/Authentik/README.md) | a service account token, bootstrapped once from an API token | ~490 objects across seventeen types, under a user path of their own | | [`FreeIPA`](Providers/FreeIPA/README.md) | a service account password, bootstrapped once from an administrator's credential | ~1,010 objects across thirty-three types, seed-tagged, in DNS zones of their own | | [`PingOne`](Providers/PingOne/README.md) | a worker application's client id and secret | ~350 PingOne SSO objects: populations, users, groups, custom user attributes, resources and applications, held in populations of their own | +| [`OneLogin`](Providers/OneLogin/README.md) | an API credential's client id and secret | ~350 objects: users in every lifecycle state OneLogin keeps, roles, groups, policies, apps, app rules, API authorizations, user mappings, a Smart Hook, a sign-up profile and custom user fields, proved by what they hold | Each provider has its own README, linked above, covering what it seeds, how it connects, what it needs, and the things about that directory that are only learnable by running against it. This @@ -43,12 +44,12 @@ page covers what every provider shares. **What every provider guarantees:** - ✅ **Ownership is proven** — teardown asks the container it created, or the tag it wrote, and nothing is deleted for merely looking like test data -- ✅ **Safe in a directory you care about** — a seeded Conditional Access policy is never enforcing, a seeded role eligibility is never active, a seeded Authentik flow is never anyone's default, a FreeIPA rule the realm shipped with is never touched, and none of those states is a parameter +- ✅ **Safe in a directory you care about** — a seeded Conditional Access policy is never enforcing, a seeded role eligibility is never active, a seeded Authentik flow is never anyone's default, a FreeIPA rule the realm shipped with is never touched, a seeded OneLogin mapping can act on seeded people only and its hook and sign-up page are always off, and none of those states is a parameter - ✅ **`-WhatIf` beats `-Force`** — on every destructive command, pinned by tests - ✅ **Idempotent** — a re-run reuses what exists rather than duplicating it - ✅ **One prefix and one tag everywhere** — `ZZ-TEST-` on names and `ZZ-TEST-seed` where the directory can store it, so seeded objects can be found across a hybrid estate with one filter - ✅ **No dependencies** — no SDKs, no gallery installs, works on a stock 5.1 host; the AD provider imports RSAT at connect time and says so when it is absent -- ✅ **Shared people** — the AD, Entra, Authentik, FreeIPA and PingOne providers seed the same people, so hybrid identity matching is testable, and `Compare-TestEnvironment` proves two of them agree +- ✅ **Shared people** — the AD, Entra, Authentik, FreeIPA, PingOne and OneLogin providers seed the same people, so hybrid identity matching is testable, and `Compare-TestEnvironment` proves two of them agree - ✅ **Verifiable** — `Test-TestEnvironment` checks the seeded estate against the seed data and names what is missing, what is extra and which name came back wrong ## 📦 Installation @@ -69,7 +70,7 @@ Windows PowerShell 5.1 is kept because a freshly built domain controller has not REST providers are better served by PowerShell 7.4, and the module detects which it is running on once, at import, and uses what that PowerShell can do. `Get-TestEnvironmentRuntime` shows the decision. -The Entra, Okta, Authentik, FreeIPA and PingOne providers need nothing beyond a stock host, on any platform. +The Entra, Okta, Authentik, FreeIPA, PingOne and OneLogin providers need nothing beyond a stock host, on any platform. From a clone: @@ -93,6 +94,7 @@ Connect-TestEnvironment -Provider Okta -OrgUrl https://.okta.com -ServiceAp Connect-TestEnvironment -Provider Authentik -BaseUrl https:// -ServiceAccount Connect-TestEnvironment -Provider FreeIPA -BaseUrl https:// -ServiceAccount Connect-TestEnvironment -Provider PingOne -EnvironmentId -ClientId -UseStoredSecret +Connect-TestEnvironment -Provider OneLogin -Subdomain -UseStoredCredential # ...or, for any provider that has stored its credential, the one name they all answer to: Connect-TestEnvironment -Provider ... -UseStoredCredential @@ -150,7 +152,7 @@ the real command rather than from a pass-through that accepts anything. Each provider also exports the commands that build one object type at a time, so a single type can be rebuilt without re-seeding everything: `New-Entra*`, `New-ADTest*`, `New-Okta*`, -`New-Authentik*`, `New-FreeIPA*` and `New-PingOne*`. The provider READMEs list them, and `Get-Help` describes each. +`New-Authentik*`, `New-FreeIPA*`, `New-PingOne*` and `New-OneLogin*`. The provider READMEs list them, and `Get-Help` describes each. ## 🧹 Teardown @@ -163,8 +165,9 @@ Remove-TestEnvironment -Keep Users, Groups -Force # rebuild everything above the **Teardown asks the container, then proves ownership.** Entra enumerates the administrative units it created, AD enumerates `OU=TestData`, Okta reads the seed tag, Authentik lists the users under the seed path, FreeIPA filters on the tag in `userclass` and requires the marker in a -description, and PingOne asks the populations it created before falling back to a custom attribute -carrying the tag. Nothing is deleted for merely matching a name, and the fallback paths +description, PingOne asks the populations it created before falling back to a custom attribute +carrying the tag, and OneLogin proves a person by the tag in a custom field and a role, group or +mapping by holding seeded people and nothing else. Nothing is deleted for merely matching a name, and the fallback paths that run when a container is gone still refuse objects that are not ours. **Rights are judged before anything is prompted for.** A layer the identity cannot delete is set @@ -194,7 +197,7 @@ running `./Build/Build-Help.ps1`, which the project instructions describe. ## 🧪 Tests -More than 2,400 Pester tests, with every Graph call, RSAT cmdlet, Okta, Authentik, FreeIPA and +More than 2,900 Pester tests, with every Graph call, RSAT cmdlet, Okta, Authentik, FreeIPA, OneLogin and PingOne request mocked, so the suite reaches no tenant, no domain, no org, no instance and no realm, and is safe to run on a workstation. It takes about a minute on a workstation, and about four minutes on a GitHub-hosted runner. @@ -219,7 +222,9 @@ TestEnvironment/ │ ├── Entra/ README.md Private/ Public/ Data/ Tools/ │ ├── Okta/ README.md Private/ Public/ Data/ + Initialize.ps1 │ ├── Authentik/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 -│ └── FreeIPA/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ ├── FreeIPA/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ ├── PingOne/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ └── OneLogin/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 ├── Public/ the provider-agnostic surface, which dispatches └── Tests/Unit/ Core/, Providers//, and the module-wide contract ``` @@ -235,7 +240,7 @@ replaced still work. - **Author**: Jeffrey Stuhr - **PowerShell**: 5.1+ (Desktop/Core compatible); PowerShell 7.4 gets the faster HTTP paths, detected at import - **Dependencies**: none -- **Providers**: Entra, Active Directory, Okta, Authentik, FreeIPA, PingOne +- **Providers**: Entra, Active Directory, Okta, Authentik, FreeIPA, PingOne, OneLogin - **Module GUID**: c4e91b7d-5a63-4f28-9d10-8b2e6f3a71c5 ## 📞 Support & contributing diff --git a/TestEnvironment.psd1 b/TestEnvironment.psd1 index 860a174..cd380d5 100644 --- a/TestEnvironment.psd1 +++ b/TestEnvironment.psd1 @@ -1,12 +1,12 @@ @{ # Module manifest for TestEnvironment RootModule = 'TestEnvironment.psm1' - ModuleVersion = '1.4.0' + ModuleVersion = '1.5.0' GUID = 'c4e91b7d-5a63-4f28-9d10-8b2e6f3a71c5' Author = 'Jeffrey Stuhr' CompanyName = 'Jeffrey Stuhr' Copyright = '(c) 2026 Jeffrey Stuhr. All rights reserved.' - Description = 'Seeds a realistic identity test environment in Entra ID, Active Directory, Okta, Authentik or FreeIPA - users in every lifecycle state, groups, devices and hosts, and the access policy over them - and tears it down again cleanly, proving ownership of every object before deleting it. One connect-seed-report-teardown surface for all five, no module dependencies, Windows PowerShell 5.1 and PowerShell 7.' + Description = 'Seeds a realistic identity test environment in Entra ID, Active Directory, Okta, Authentik, FreeIPA, PingOne or OneLogin - users in every lifecycle state, groups, devices and hosts, and the access policy over them - and tears it down again cleanly, proving ownership of every object before deleting it. One connect-seed-report-teardown surface for all seven, no module dependencies, Windows PowerShell 5.1 and PowerShell 7.' # PowerShell Version Requirements PowerShellVersion = '5.1' @@ -129,7 +129,22 @@ 'New-PingOneUser', 'New-PingOneGroup', 'New-PingOneResource', - 'New-PingOneApplication' + 'New-PingOneApplication', + + # OneLogin provider components + 'New-OneLoginCustomAttribute', + 'New-OneLoginRole', + 'New-OneLoginGroup', + 'New-OneLoginApp', + 'New-OneLoginMapping', + 'New-OneLoginUser', + 'New-OneLoginPolicy', + 'New-OneLoginAppRule', + 'New-OneLoginApiAuthorization', + 'New-OneLoginSmartHook', + 'New-OneLoginSelfRegistration', + 'New-OneLoginMfaFactor', + 'Get-OneLoginAppCredential' ) # Cmdlets to Export @@ -146,7 +161,7 @@ PSData = @{ Tags = @( 'TestData', 'TestEnvironment', 'SeedData', 'Identity', 'IAM', 'IdentityManagement', - 'Entra', 'EntraID', 'AzureAD', 'MicrosoftGraph', 'ActiveDirectory', 'Okta', 'Authentik', 'FreeIPA', 'PingOne', + 'Entra', 'EntraID', 'AzureAD', 'MicrosoftGraph', 'ActiveDirectory', 'Okta', 'Authentik', 'FreeIPA', 'PingOne', 'OneLogin', 'Kerberos', 'LDAP', 'ConditionalAccess', 'PIM', 'HBAC', 'Sudo', 'Automation', 'Pester', 'Lab', 'PSEdition_Desktop', 'PSEdition_Core', 'Windows', 'Linux', 'MacOS' @@ -159,6 +174,34 @@ # with 'IconUrl cannot be empty', so Publish-PSResource fails before it ever # reaches the Gallery. The same applies to HelpInfoURI below. ReleaseNotes = @' +1.5.0 - A seventh provider: OneLogin. + +OneLogin joins Entra ID, Active Directory, Okta, Authentik, FreeIPA and PingOne. It seeds one +account through the OneLogin API as an API credential: four custom user fields, 321 people in +every lifecycle state OneLogin keeps - one of them locked - with managers and the directory +identifiers a synchronised account carries, four roles, five office groups, two security policies, +five OIDC and SAML apps with two app rules, two API authorization servers with scopes, claims and +seeded clients, two user mappings, a disabled Smart Hook, a disabled self-registration profile and +MFA factors where the account offers them. It reports, verifies, repairs and tears down like every +other provider, and the shared people carry exactly the shared names. + +It is built to run in an account real people sign in to. Most OneLogin objects carry nothing but +a name, so each is proved by what it holds or names: a role or group by holding seeded people and +nothing else, a policy by its seeded groups, a mapping by a seed-tag condition it always carries, +an app rule by its seeded app, a hook by a marker line in its code. Nothing seeded can reach a +real object and nothing real is drawn in: a mapping acts on seeded people only, the hook and the +sign-up page are always off, a policy is never the default, only seeded apps are API clients, no +MFA factor is switched on for the account, and every directory identifier is under the prefix or +the lab domain. None of that has a parameter. + +An app's client secret is dropped by default. New-TestEnvironment -SaveAppSecret keeps the two +confidential apps' secrets through the shared credential record writer, DPAPI or the SecretStore; +Get-OneLoginAppCredential returns them as credentials, and teardown deletes each with its app and +any whose app is gone, so they do not build up. + +Thirteen new exported commands. Verified live against a OneLogin trial on Windows PowerShell 5.1 +and PowerShell 7: every verification check passing and a teardown that left nothing behind. + 1.4.0 - The module knows which PowerShell it is running on, and says which one it prefers. Windows PowerShell 5.1 stays, because a freshly built domain controller has nothing else; the diff --git a/TestEnvironment.psm1 b/TestEnvironment.psm1 index 37f29d5..ee81d76 100644 --- a/TestEnvironment.psm1 +++ b/TestEnvironment.psm1 @@ -196,7 +196,23 @@ Export-ModuleMember -Function @( 'New-PingOneUser', 'New-PingOneGroup', 'New-PingOneResource', - 'New-PingOneApplication' + 'New-PingOneApplication', + + # OneLogin provider components. No Test infix, for the same reason as the other REST + # providers: OneLogin ships no PowerShell cmdlets for these names to collide with. + 'New-OneLoginCustomAttribute', + 'New-OneLoginRole', + 'New-OneLoginGroup', + 'New-OneLoginApp', + 'New-OneLoginMapping', + 'New-OneLoginUser', + 'New-OneLoginPolicy', + 'New-OneLoginAppRule', + 'New-OneLoginApiAuthorization', + 'New-OneLoginSmartHook', + 'New-OneLoginSelfRegistration', + 'New-OneLoginMfaFactor', + 'Get-OneLoginAppCredential' ) $ExecutionContext.SessionState.Module.OnRemove = { @@ -206,6 +222,7 @@ $ExecutionContext.SessionState.Module.OnRemove = { Remove-Variable -Name OktaConnection -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name AuthentikConnection -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name PingOneConnection -Scope Script -ErrorAction SilentlyContinue + Remove-Variable -Name OneLoginConnection -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name ActiveProvider -Scope Script -ErrorAction SilentlyContinue Remove-Variable -Name TestEnvironmentProvider -Scope Script -ErrorAction SilentlyContinue } diff --git a/Tests/README.md b/Tests/README.md index 3653f2e..47f3455 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -4,8 +4,8 @@ Part of [TestEnvironment](../README.md). Pester 6 unit tests live under `Unit\`, mirroring the module's own layout: shared concerns under `Core\`, provider-specific ones under `Providers\\`, and the module-wide contract at -the root. Every Graph call, RSAT cmdlet, Okta request, Authentik request, FreeIPA request and -PingOne request is mocked, so the suite reaches no tenant, no domain, no org, no realm and no +the root. Every Graph call, RSAT cmdlet, Okta request, Authentik request, FreeIPA request, +PingOne request and OneLogin request is mocked, so the suite reaches no tenant, no domain, no org, no realm and no environment, creates nothing, and is safe to run on a workstation. It runs in about a minute on a workstation, and about four minutes on a GitHub-hosted runner. @@ -126,6 +126,15 @@ promise the README makes, or a regression for a bug that reached a real director | `Providers\PingOne\Remove-PingOneEnvironment.Tests.ps1` | That `-WhatIf` beats `-Force`, an unanswerable confirmation stops the run before anything is enumerated, removal runs in the order PingOne will delete in, and `-Keep Users` keeps the populations and attributes they depend on | | `Providers\PingOne\New-PingOnePopulation.Tests.ps1` | That no seeded population is ever made the default, no parameter exists to change that, and an existing population is reused | | `Providers\PingOne\New-PingOneApplication.Tests.ps1` | That a public client is never created without PKCE, a SAML application is granted no scopes, access is restricted through groups, and redirect URLs never name a real host | +| `Providers\OneLogin\Invoke-OneLoginRequest.Tests.ps1` | Bodies sent as UTF-8 bytes and responses decoded from the raw stream, a JSON array body passed through untouched, pagination by page and Total-Pages with items emitted one by one, one token renewal on a 401, a short 429 waited out and a long one reported, and every error shape reduced to one line | +| `Providers\OneLogin\Get-OneLoginSeededObject.Tests.ps1` | That a person needs the tag and the prefix and the listing asks for the field that carries it; an app, API server and sign-up profile the tag and the prefix; a role or group every member seeded, at least one member and no administrator, and a group no policy but a prefixed non-default one; a policy only seeded groups using it; a mapping the seed-tag gate, match all and add-role actions; an app rule a seeded app; a hook the marker line in its code; that a mapping, rule or hook may name a deleted role but never somebody else's; a field declaration and the attribute prefix; and that each refusal names its reason | +| `Providers\OneLogin\Remove-OneLoginEnvironment.Tests.ps1` | That `-WhatIf` beats `-Force`, a refusal stops the run before anything is proved, every type is proved before the first deletion and against the objects that prove it, deletions run from the sign-up profile to the fields, an app rule goes with its app unless the app is kept, what is left alone is named, and `-Keep` keeps everything the kept type is proved by | +| `Providers\OneLogin\New-OneLoginEnvironment.Tests.ps1` | Step ordering, `-Skip`, failure isolation, `-Tier` reaching every step that decides what exists and no `-Tier` passed when none was given, the backstop, and that a Bulk-only seed creates no role | +| `Providers\OneLogin\New-OneLoginStep.Tests.ps1` | That every mapping carries the seed-tag gate with no parameter to remove it; that only ids the seed created or proved are ever sent; that role grants go out as JSON arrays, are waited for, and are sent again when OneLogin never applied them; that a reused person is put back sending only what differs; that every directory identifier stays under the prefix or the lab domain; that the locked person is locked through the lock call and never sent status 3, and locked again only when the lock is running out; that a person left unlicensed is named; that no client secret is kept; that a policy is never the default and lands on seeded groups only; that only seeded apps are API clients; that an app rule sits on a seeded app with its fixed action; that the hook is disabled, gated, marked and disabled again; that the sign-up profile keeps its safe shape; that an MFA factor is enrolled verified where offered and skipped where not; that no step has a parameter that could loosen any of it; and that a prefixed role or app belonging to somebody else is left alone | +| `Providers\OneLogin\SeedData.Tests.ps1` | The shape of the OneLogin seed rows and what OneLogin keeps: durable states only, ten licensed people, roles only for people who can hold them, a rejected person in no group, unlicensed roleless Bulk people, the trial's role and app limits, every role and group with a Core member, managers before their reports, and the writing systems and the decomposed name | +| `Providers\OneLogin\Test-OneLoginEnvironment.Tests.ps1` | That an account built from the seed files passes reading only, and that a missing person, a decomposed name, a person left unlicensed, a wrong manager, a lost group placement, a dropped role grant, a changed policy setting, a policy moved off its group, a stray API client, an edited directory field and a lock run out are each named, while a person a mapping added is not | +| `Providers\OneLogin\OneLoginAppSecret.Tests.ps1` | Saved app secrets, written for real into TestDrive: nothing kept without `-SaveAppSecret`; with it, the two confidential clients only, from the create answer, never on the pipeline and not in plaintext on disk on Windows; an app that already existed named as having none; `Get-OneLoginAppCredential` returning the secret as a PSCredential; a record whose content disagrees with its name ignored; and teardown deleting a record with its app and one whose app is gone, never the record of a live app or another account, nothing under `-WhatIf`, nothing read under `-Keep Apps`, and orphans left when the apps cannot be listed | +| `Providers\OneLogin\Connect-OneLoginEnvironment.Tests.ps1` | The account named three ways, nothing stored when the credential is refused, the record written only after the connection is proved and read back with the name alone, the token request, the report shape and files, and the identity snapshot | The AD provider's tests run without RSAT at all, against generated stubs in `Tests\Stubs`, which are appended to `PSModulePath` rather than prepended - so a host that really has RSAT exercises the true binding surface instead. diff --git a/Tests/Unit/Core/SeedPeople.Tests.ps1 b/Tests/Unit/Core/SeedPeople.Tests.ps1 index b44e429..d22f59f 100644 --- a/Tests/Unit/Core/SeedPeople.Tests.ps1 +++ b/Tests/Unit/Core/SeedPeople.Tests.ps1 @@ -63,6 +63,7 @@ Describe 'Every provider seeds the shared people under the shared names' -Tag 'U @{ Provider = 'Authentik'; File = 'AuthentikUsers.csv'; Key = 'Username'; Given = $null; Surname = $null; Display = 'Name' } @{ Provider = 'FreeIPA'; File = 'FreeIPAUsers.csv'; Key = 'Username'; Given = 'GivenName'; Surname = 'Surname'; Display = 'DisplayName' } @{ Provider = 'PingOne'; File = 'PingOneUsers.csv'; Key = 'Key'; Given = 'GivenName'; Surname = 'FamilyName'; Display = $null } + @{ Provider = 'OneLogin'; File = 'OneLoginUsers.csv'; Key = 'Key'; Given = 'GivenName'; Surname = 'Surname'; Display = 'DisplayName' } ) } diff --git a/Tests/Unit/Providers/OneLogin/Connect-OneLoginEnvironment.Tests.ps1 b/Tests/Unit/Providers/OneLogin/Connect-OneLoginEnvironment.Tests.ps1 new file mode 100644 index 0000000..bc20e59 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/Connect-OneLoginEnvironment.Tests.ps1 @@ -0,0 +1,174 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Connecting, the token, the report and the identity snapshot. + + The connect takes the account the way somebody copies it - a bare name, the host, or the portal + URL - and keeps the name. It proves the credential before storing the connection, and writes the + credential record only after that proof, through the shared record writer, so a mistyped secret + is never saved. The token is the client credentials grant against the account's own host. + + The report has the shape every provider shares and counts an app's roles from the roles, because + OneLogin's app listing leaves role_ids out. The snapshot keys a person by the username with the + prefix stripped and calls a suspended or rejected person disabled. + + Everything is mocked. The account is never reached. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Connect-OneLoginEnvironment' -Tag 'Unit', 'Public' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:OneLoginConnection = $null + $script:Secret = ConvertTo-TestSecureString -PlainText 'not-a-real-secret' + Mock Invoke-OneLoginRequest { , @() } + Mock Export-OneLoginCredential { } + } + } + + AfterEach { + InModuleScope TestEnvironment { $script:OneLoginConnection = $null } + } + + It 'reads the account name from a bare name, a host or a portal URL' -ForEach @( + @{ Given = 'contoso' }, @{ Given = 'Contoso.onelogin.com' }, @{ Given = 'https://contoso.onelogin.com/admin2' } + ) { + InModuleScope TestEnvironment -Parameters @{ Given = $Given } { + param($Given) + $connection = Connect-OneLoginEnvironment -Subdomain $Given -ClientId 'abc123' -ClientSecret $script:Secret -PassThru + $connection.Subdomain | Should-Be 'contoso' + $script:OneLoginConnection.ApiHost | Should-Be 'contoso.onelogin.com' + } + } + + It 'refuses something that is not an account name' { + InModuleScope TestEnvironment { + { Connect-OneLoginEnvironment -Subdomain 'https://example.com/con toso' -ClientId 'abc' -ClientSecret $script:Secret } | + Should-Throw -ExceptionMessage '*does not name a OneLogin account*' + } + } + + It 'stores nothing, not even the record, when the credential is refused' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest { throw 'Could not get a OneLogin access token: 401' } + { Connect-OneLoginEnvironment -Subdomain contoso -ClientId 'abc' -ClientSecret $script:Secret -SaveSecret } | + Should-Throw -ExceptionMessage "*Could not connect to OneLogin account 'contoso'*" + $script:OneLoginConnection | Should-BeNull + Should-NotInvoke Export-OneLoginCredential + } + } + + It 'writes the record through the shared writer once the connection is proved' { + InModuleScope TestEnvironment { + $null = Connect-OneLoginEnvironment -Subdomain contoso -ClientId 'abc123' -ClientSecret $script:Secret -SaveSecret + Should-Invoke Export-OneLoginCredential -Times 1 -Exactly -ParameterFilter { + $Subdomain -eq 'contoso' -and $ClientId -eq 'abc123' -and $ClientSecret -eq 'not-a-real-secret' -and $Path -like '*contoso.onelogin.json' + } + } + } + + It 'connects from the stored record with nothing but the account name' { + InModuleScope TestEnvironment { + Mock Import-OneLoginCredential { [PSCustomObject]@{ Subdomain = 'contoso'; ClientId = 'stored-id'; ClientSecret = $script:Secret } } + $connection = Connect-OneLoginEnvironment -Subdomain contoso -UseStoredCredential -PassThru + $connection.ClientId | Should-Be 'stored-id' + Should-Invoke Import-OneLoginCredential -Times 1 -Exactly -ParameterFilter { $Path -like '*contoso.onelogin.json' } + } + } +} + +Describe 'Get-OneLoginAccessToken' -Tag 'Unit', 'Public' { + + It 'asks the account''s own token endpoint for client credentials, with the id and secret as Basic' { + InModuleScope TestEnvironment { + $connection = @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com'; ClientId = 'abc'; ClientSecret = (ConvertTo-TestSecureString -PlainText 's3cret') } + Mock Invoke-TestWebRequest { [PSCustomObject]@{ StatusCode = 200; Content = '{"access_token":"tok","expires_in":36000,"account_id":123456,"token_type":"bearer"}' } } + + Get-OneLoginAccessToken -Connection $connection -AsPlainText | Should-Be 'tok' + $connection.AccountId | Should-Be '123456' + Should-Invoke Invoke-TestWebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://contoso.onelogin.com/auth/oauth2/v2/token' -and $Method -eq 'POST' -and $Body.grant_type -eq 'client_credentials' -and + $Headers.Authorization -eq ('Basic ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('abc:s3cret'))) + } + + # Reused until a minute before it expires. + $null = Get-OneLoginAccessToken -Connection $connection -AsPlainText + Should-Invoke Invoke-TestWebRequest -Times 1 -Exactly + } + } +} + +Describe 'Get-OneLoginEnvironmentReport and the identity snapshot' -Tag 'Unit', 'Public' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Write-TestMessage { } + Mock Write-Host { } + # Never the real credential folder. + Mock Get-OneLoginAppSecretRecord { } + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; AccountId = '123456'; Prefix = 'ZZ-TEST-' } } + $jose = 'Jos' + [string][char]0xE9 + $script:Fixture = @{ + Attributes = @([PSCustomObject]@{ id = 1; shortname = 'zztest_seed_tag'; name = 'ZZ-TEST seed tag' }) + Users = @( + [PSCustomObject]@{ id = 10; username = 'zz-test-jnino'; firstname = $jose; lastname = 'Nino'; status = 1; state = 1; group_id = 20; manager_user_id = $null; role_ids = @(30); custom_attributes = [PSCustomObject]@{ zztest_contractor = 'false' } } + [PSCustomObject]@{ id = 11; username = 'zz-test-mbell'; firstname = 'Marcus'; lastname = 'Bell'; status = 2; state = 1; group_id = $null; manager_user_id = 10; role_ids = @(); custom_attributes = $null } + [PSCustomObject]@{ id = 12; username = 'zz-test-pmorel'; firstname = 'Pascale'; lastname = 'Morel'; status = 7; state = 2; group_id = $null; manager_user_id = $null; role_ids = @(); custom_attributes = $null } + ) + Roles = @([PSCustomObject]@{ id = 30; name = 'ZZ-TEST-All Staff'; users = @(10); apps = @(40) }) + Groups = @([PSCustomObject]@{ id = 20; name = 'ZZ-TEST-Seattle HQ'; MemberIds = @('10') }) + Apps = @([PSCustomObject]@{ id = 40; name = 'ZZ-TEST-Wiki SAML'; connector_id = 110016; visible = $true }) + Mappings = @([PSCustomObject]@{ id = 50; name = 'ZZ-TEST-Finance'; enabled = $true; conditions = @(1, 2); actions = @(1) }) + } + Mock Get-OneLoginSeededObject { @($script:Fixture[$Type]) } + Mock Invoke-OneLoginRequest { throw "Unexpected request $Method $Path" } + } + } + + It 'returns the shared report shape with -PassThru, and nothing without it' { + InModuleScope TestEnvironment { + (Get-OneLoginEnvironmentReport) | Should-BeNull + $report = Get-OneLoginEnvironmentReport -PassThru + $report.PSObject.TypeNames[0] | Should-Be 'OneLoginEnvironmentReport' + $report.Provider | Should-Be 'OneLogin' + $report.Target | Should-Be 'contoso' + @($report.Sections) | Should-BeCollection @('Attributes', 'Users', 'Roles', 'Groups', 'Apps', 'Mappings', 'Policies', 'ApiAuthorizations', 'AppRules', 'Hooks', 'SelfRegistration') + $report.Counts.Users | Should-Be 3 + $report.UsersByStatus.Suspended | Should-Be 1 + $report.UsersByState.Rejected | Should-Be 1 + $report.UsersWithManager | Should-Be 1 + $report.UsersInNoGroup | Should-Be 2 + ($report.Users | Where-Object Username -eq 'zz-test-mbell').Manager | Should-Be 'zz-test-jnino' + ($report.Apps | Where-Object Name -eq 'ZZ-TEST-Wiki SAML').Connector | Should-Be 'SAML' + ($report.Apps | Where-Object Name -eq 'ZZ-TEST-Wiki SAML').Roles | Should-Be 1 + } + } + + It 'writes a file format as UTF-8 through the shared writer and requires a path for one' { + InModuleScope TestEnvironment { + { Get-OneLoginEnvironmentReport -OutputFormat CSV } | Should-Throw -ExceptionMessage '*-OutputPath*' + $folder = Join-Path $TestDrive 'csv' + Get-OneLoginEnvironmentReport -OutputFormat CSV -OutputPath $folder + @(Get-ChildItem $folder -Filter 'OneLoginLab*.csv').Count | Should-Be 11 + $read = (Import-Csv (Join-Path $folder 'OneLoginLabUsers.csv') -Encoding UTF8 | Where-Object Username -eq 'zz-test-jnino').FirstName + [string]::Equals($read, ('Jos' + [string][char]0xE9), [StringComparison]::Ordinal) | Should-BeTrue + } + } + + It 'keys each person by the username without the prefix, keeps the name as parts, and calls suspended and rejected disabled' { + InModuleScope TestEnvironment { + $snapshot = Get-OneLoginIdentitySnapshot + $snapshot.Provider | Should-Be 'OneLogin' + $snapshot.Target | Should-Be 'contoso' + @($snapshot.Identities | ForEach-Object Key) | Should-BeCollection @('jnino', 'mbell', 'pmorel') + @($snapshot.Identities | ForEach-Object Enabled) | Should-BeCollection @($true, $false, $false) + $snapshot.Identities[0].DisplayName | Should-BeNull + $snapshot.Identities[0].Surname | Should-Be 'Nino' + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/Get-OneLoginSeededObject.Tests.ps1 b/Tests/Unit/Providers/OneLogin/Get-OneLoginSeededObject.Tests.ps1 new file mode 100644 index 0000000..6f2b35a --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/Get-OneLoginSeededObject.Tests.ps1 @@ -0,0 +1,378 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Ownership discovery for the OneLogin provider - the only thing teardown deletes from, and what + the report and the verifier read. + + A OneLogin account is as likely to be somebody's production directory as a lab, so the rule + that nothing is deleted for merely matching a name carries more weight here than anywhere. A + role, a group, a policy, a mapping and a hook have nothing but a name - a hook not even that - so + each is proved by what it holds or names. These pin every refusal: a prefixed role holding one + real person, an empty prefixed role, a role with an administrator, a group with somebody else's + policy, a policy used by a real group, the account's default policy, a mapping without the + seed-tag condition, a mapping, rule or hook naming a real role, a hook without the marker, an app + or API server with the prefix and no tag, a sign-up profile with the prefix and no tag, a user + with the prefix and no tag, a custom field the data does not declare. And one acceptance that + keeps teardown able to finish what it started: a role that no longer exists grants nothing, so + naming one does not make a mapping, rule or hook somebody else's. + + Every call is mocked. This suite must never reach an account. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Get-OneLoginSeededObject' -Tag 'Unit', 'Private' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Connection = @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } + # The backstop. A call no test mocked would otherwise run the real function and reach + # OneLogin; it fails loudly instead, naming the path. + Mock Invoke-OneLoginRequest { throw "Escaped the mocks: $Method $Path" } + # Every role in the account: two seeded, the Default role, and one a person made. + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'roles' } { + foreach ($id in 7, 8, 1000001, 555) { [PSCustomObject]@{ id = $id; name = "role $id"; users = @(); apps = @(); admins = @() } } + } + } + } + + Context 'Users need the tag and the prefix' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users/custom_attributes' } { + , @([PSCustomObject]@{ id = 1; shortname = 'zztest_seed_tag' }) + } + } + } + + It 'claims a user with both, and asks for the fields that carry the tag' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users' } { + [PSCustomObject]@{ id = 10; username = 'zz-test-jnino'; custom_attributes = [PSCustomObject]@{ zztest_seed_tag = 'ZZ-TEST-seed' } } + } + @(Get-OneLoginSeededObject -Type Users -Connection $script:Connection).id | Should-BeCollection @(10) + # The listing leaves custom_attributes out unless it is named; found live, when the + # first seed proved nobody. + Should-Invoke Invoke-OneLoginRequest -ParameterFilter { + $Path -eq 'users' -and $Query['custom_attributes.zztest_seed_tag'] -eq 'ZZ-TEST-seed' -and $Query.fields -like '*custom_attributes*' + } + } + } + + It 'refuses a tagged user without the prefix, a prefixed user without the tag, and a tag that only resembles it' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users' } { + [PSCustomObject]@{ id = 1; username = 'jane.real'; custom_attributes = [PSCustomObject]@{ zztest_seed_tag = 'ZZ-TEST-seed' } } + [PSCustomObject]@{ id = 2; username = 'zz-test-lookalike'; custom_attributes = [PSCustomObject]@{ zztest_seed_tag = $null } } + [PSCustomObject]@{ id = 3; username = 'zz-test-loose'; custom_attributes = [PSCustomObject]@{ zztest_seed_tag = 'zz-test-SEED' } } + [PSCustomObject]@{ id = 4; username = 'zz-test-nofields' } + } + @(Get-OneLoginSeededObject -Type Users -Connection $script:Connection) | Should-BeCollection -Count 0 + } + } + + It 'asks for no users at all when the tag field does not exist' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users/custom_attributes' } { , @() } + @(Get-OneLoginSeededObject -Type Users -Connection $script:Connection) | Should-BeCollection -Count 0 + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users' } + } + } + } + + Context 'Apps, API servers and sign-up profiles need the tag and the prefix' { + + It 'claims only an app with both, and names the other prefixed one as unproven' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'apps' } { + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Expenses Web'; description = 'Seeded. [ZZ-TEST-seed]' } + [PSCustomObject]@{ id = 2; name = 'Salesforce'; description = 'Copied from a wiki: ZZ-TEST-seed' } + [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Made by a person'; description = 'Ours, honestly' } + } + @(Get-OneLoginSeededObject -Type Apps -Connection $script:Connection).id | Should-BeCollection @(1) + $unproven = @(Get-OneLoginSeededObject -Type Apps -Unproven -Connection $script:Connection) + $unproven.Id | Should-BeCollection @('3') + $unproven[0].Reason | Should-MatchString 'seed tag' + } + } + + It 'proves an API authorization server the same way' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'api_authorizations' } { + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Orders API'; description = 'Seeded API. [ZZ-TEST-seed]' } + [PSCustomObject]@{ id = 2; name = 'Payments API'; description = 'ZZ-TEST-seed' } + [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Real API'; description = 'The real one' } + } + @(Get-OneLoginSeededObject -Type ApiAuthorizations -Connection $script:Connection).id | Should-BeCollection @(1) + @(Get-OneLoginSeededObject -Type ApiAuthorizations -Unproven -Connection $script:Connection).Id | Should-BeCollection @('3') + } + } + + It 'proves a sign-up profile by the tag in its help text, read from the profile itself' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'self_registration_profiles' } { + [PSCustomObject]@{ self_registration_profiles = @( + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Partner Sign-up' } + [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Real Sign-up' } + [PSCustomObject]@{ id = 3; name = 'Customer Sign-up' } + ) } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'self_registration_profiles/1' } { [PSCustomObject]@{ self_registration_profile = [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Partner Sign-up'; helptext = 'Seeded. [ZZ-TEST-seed]' } } } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'self_registration_profiles/2' } { [PSCustomObject]@{ self_registration_profile = [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Real Sign-up'; helptext = 'Welcome' } } } + @(Get-OneLoginSeededObject -Type SelfRegistration -Connection $script:Connection).id | Should-BeCollection @(1) + @(Get-OneLoginSeededObject -Type SelfRegistration -Unproven -Connection $script:Connection).Id | Should-BeCollection @('2') + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'self_registration_profiles/3' } + } + } + } + + Context 'Roles are proved by what they hold' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'roles' } { + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-All Staff'; users = @(10, 11); apps = @(50); admins = @() } + [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Finance'; users = @(10, 999); apps = @(); admins = @() } + [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Empty'; users = @(); apps = @(); admins = @() } + [PSCustomObject]@{ id = 4; name = 'ZZ-TEST-Admins'; users = @(10); apps = @(); admins = @(999) } + [PSCustomObject]@{ id = 5; name = 'ZZ-TEST-Foreign App'; users = @(10); apps = @(77); admins = @() } + [PSCustomObject]@{ id = 6; name = 'Default'; users = @(10); apps = @(); admins = @() } + } + } + } + + It 'claims a prefixed role holding only seeded users and apps, and nothing else' { + InModuleScope TestEnvironment { + @(Get-OneLoginSeededObject -Type Roles -OwnedUserId '10', '11' -OwnedAppId '50' -Connection $script:Connection).id | + Should-BeCollection @(1) + } + } + + It 'refuses one real person, an empty role, an administrator and a foreign app, and says which' { + InModuleScope TestEnvironment { + $unproven = @(Get-OneLoginSeededObject -Type Roles -Unproven -OwnedUserId '10', '11' -OwnedAppId '50' -Connection $script:Connection) + $byId = @{}; foreach ($item in $unproven) { $byId[$item.Id] = $item.Reason } + ($byId.Keys | Sort-Object) | Should-BeCollection @('2', '3', '4', '5') + $byId['2'] | Should-MatchString 'not seeded' + $byId['3'] | Should-MatchString 'no users and no apps' + $byId['4'] | Should-MatchString 'administrators' + $byId['5'] | Should-MatchString 'app' + } + } + + It 'lets the seed reuse an empty role under -AllowEmpty, and nothing more' { + InModuleScope TestEnvironment { + @(Get-OneLoginSeededObject -Type Roles -AllowEmpty -OwnedUserId '10', '11' -OwnedAppId '50' -Connection $script:Connection).id | + Sort-Object | Should-BeCollection @(1, 3) + } + } + } + + Context 'Groups are proved by their members, and by a policy that is the seed''s' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'policies' } { + , @( + [PSCustomObject]@{ id = 900; name = 'Default policy'; kind = 'user'; is_default = $true } + [PSCustomObject]@{ id = 901; name = 'ZZ-TEST-Strict Office'; kind = 'user'; is_default = $false } + [PSCustomObject]@{ id = 902; name = 'Executives'; kind = 'user'; is_default = $false } + ) + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'groups' } { + foreach ($id in 1..6) { [PSCustomObject]@{ id = $id; name = "ZZ-TEST-Group $id"; policy_id = $null } } + [PSCustomObject]@{ id = 7; name = 'Engineering'; policy_id = $null } + } + # The detail, which is where the administrators are. + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -like 'groups/*' } { + $id = [int]($Path -replace '^groups/', '') + $policy = switch ($id) { 2 { 901 } 3 { 902 } 4 { 900 } default { $null } } + $admins = if ($id -eq 5) { @(999) } else { @() } + [PSCustomObject]@{ id = $id; name = "ZZ-TEST-Group $id"; policy_id = $policy; admins = $admins } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users' -and $Query.group_id -in '1', '2', '3', '4', '5' } { [PSCustomObject]@{ id = 10 } } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users' -and $Query.group_id -eq '6' } { [PSCustomObject]@{ id = 10 }; [PSCustomObject]@{ id = 999 } } + } + } + + It 'claims a group of seeded members with no policy or the seed''s own, and never asks about an unprefixed one' { + InModuleScope TestEnvironment { + $claimed = @(Get-OneLoginSeededObject -Type Groups -OwnedUserId '10' -Connection $script:Connection) + ($claimed.id | Sort-Object) | Should-BeCollection @(1, 2) + $claimed[0].MemberIds | Should-BeCollection @('10') + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'groups/7' } + } + } + + It 'refuses somebody else''s policy, the default policy, an administrator and a real person' { + InModuleScope TestEnvironment { + $unproven = @(Get-OneLoginSeededObject -Type Groups -Unproven -OwnedUserId '10' -Connection $script:Connection) + ($unproven.Id | Sort-Object) | Should-BeCollection @('3', '4', '5', '6') + ($unproven | Where-Object Id -eq '3').Reason | Should-MatchString 'policy' + ($unproven | Where-Object Id -eq '5').Reason | Should-MatchString 'administrators' + } + } + } + + Context 'Policies are proved by the groups that use them' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'policies' } { + , @( + [PSCustomObject]@{ id = 900; name = 'ZZ-TEST-Default'; kind = 'user'; is_default = $true } + [PSCustomObject]@{ id = 901; name = 'ZZ-TEST-Strict Office'; kind = 'user'; is_default = $false } + [PSCustomObject]@{ id = 902; name = 'ZZ-TEST-Shared'; kind = 'user'; is_default = $false } + [PSCustomObject]@{ id = 903; name = 'ZZ-TEST-Unused'; kind = 'user'; is_default = $false } + [PSCustomObject]@{ id = 904; name = 'Executives'; kind = 'user'; is_default = $false } + ) + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'groups' } { + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Seattle HQ'; policy_id = 901 } + [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-London'; policy_id = 902 } + [PSCustomObject]@{ id = 3; name = 'Finance'; policy_id = 902 } + [PSCustomObject]@{ id = 4; name = 'ZZ-TEST-Remote'; policy_id = 900 } + } + } + } + + It 'claims a prefixed policy used only by seeded groups' { + InModuleScope TestEnvironment { + $claimed = @(Get-OneLoginSeededObject -Type Policies -OwnedUserId '10' -OwnedGroupId '1', '2', '4' -Connection $script:Connection) + $claimed.id | Should-BeCollection @(901) + $claimed[0].GroupIds | Should-BeCollection @('1') + } + } + + It 'refuses the default, a policy a real group uses, and an unused one - and never names an unprefixed one' { + InModuleScope TestEnvironment { + $unproven = @(Get-OneLoginSeededObject -Type Policies -Unproven -OwnedUserId '10' -OwnedGroupId '1', '2', '4' -Connection $script:Connection) + ($unproven.Id | Sort-Object) | Should-BeCollection @('900', '902', '903') + ($unproven | Where-Object Id -eq '900').Reason | Should-MatchString 'default' + ($unproven | Where-Object Id -eq '902').Reason | Should-MatchString 'not seeded' + ($unproven | Where-Object Id -eq '903').Reason | Should-MatchString 'No group uses it' + } + } + } + + Context 'Mappings, app rules and hooks are proved by the roles they name' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Gate = [PSCustomObject]@{ source = 'custom_attribute_zztest_seed_tag'; operator = '='; value = 'ZZ-TEST-seed' } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'mappings' -and -not $Query } { + , @( + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Finance dept'; match = 'all'; conditions = @($script:Gate, [PSCustomObject]@{ source = 'department'; operator = '='; value = 'Finance' }); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('7') }) } + [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Ungated'; match = 'all'; conditions = @([PSCustomObject]@{ source = 'department'; operator = '='; value = 'Finance' }); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('7') }) } + [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Any'; match = 'any'; conditions = @($script:Gate); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('7') }) } + ) + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'mappings' -and $Query.enabled -eq 'false' } { + # One disabled mapping, which the listing hands back as the object itself: found + # live, when joining it to the enabled ones failed and hid both from teardown. + [PSCustomObject]@{ id = 4; name = 'ZZ-TEST-Disabled'; match = 'all'; conditions = @($script:Gate); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('8') }) } + } + } + } + + It 'claims enabled and disabled mappings that are gated and add only seeded roles, even when a listing holds one' { + InModuleScope TestEnvironment { + @(Get-OneLoginSeededObject -Type Mappings -OwnedRoleId '7', '8' -Connection $script:Connection).id | Sort-Object | + Should-BeCollection @(1, 4) + } + } + + It 'refuses the ungated and the any-match, and a mapping that adds a real role' { + InModuleScope TestEnvironment { + $unproven = @(Get-OneLoginSeededObject -Type Mappings -Unproven -OwnedRoleId '7' -Connection $script:Connection) + ($unproven.Id | Sort-Object) | Should-BeCollection @('2', '3', '4') + ($unproven | Where-Object Id -eq '2').Reason | Should-MatchString 'seed tag' + ($unproven | Where-Object Id -eq '4').Reason | Should-MatchString 'not seeded' + } + } + + It 'accepts a role that no longer exists, so a teardown that stopped halfway can finish' { + # Found live: a mapping whose role an earlier teardown had deleted could never be claimed + # again. A deleted role grants nothing; only an existing role that is somebody else's + # makes the mapping theirs. + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'mappings' -and -not $Query } { , @() } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'mappings' -and $Query.enabled -eq 'false' } { + , @( + [PSCustomObject]@{ id = 5; name = 'ZZ-TEST-Orphan'; match = 'all'; conditions = @($script:Gate); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('4040') }) } + [PSCustomObject]@{ id = 6; name = 'ZZ-TEST-Real'; match = 'all'; conditions = @($script:Gate); actions = @([PSCustomObject]@{ action = 'add_role'; value = @('555') }) } + ) + } + @(Get-OneLoginSeededObject -Type Mappings -OwnedRoleId '7' -Connection $script:Connection).id | Should-BeCollection @(5) + } + } + + It 'claims an app rule on a seeded app naming only seeded roles, and never looks at another app' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'apps/50/rules' -and -not $Query } { + [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-Directory groups'; conditions = @([PSCustomObject]@{ source = 'has_role'; operator = 'ri'; value = '7' }) } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'apps/50/rules' -and $Query.enabled -eq 'false' } { + [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Real role'; conditions = @([PSCustomObject]@{ source = 'has_role'; operator = 'ri'; value = '555' }) } + [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Everyone'; conditions = @() } + } + $claimed = @(Get-OneLoginSeededObject -Type AppRules -OwnedAppId '50' -OwnedRoleId '7' -Connection $script:Connection) + $claimed.id | Should-BeCollection @(1) + $claimed[0].AppId | Should-Be '50' + (@(Get-OneLoginSeededObject -Type AppRules -Unproven -OwnedAppId '50' -OwnedRoleId '7' -Connection $script:Connection).Id | Sort-Object) | + Should-BeCollection @('2', '3') + } + } + + It 'claims a hook by the marker in its code, read from the hook itself, and refuses one gated on nothing or on a real role' { + InModuleScope TestEnvironment { + $marked = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("// Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]`nexports.handler = async () => ({})")) + $plain = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("exports.handler = async () => ({})")) + # The listing leaves the code out, as OneLogin's does. + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'hooks' } { , @('a', 'b', 'c', 'd' | ForEach-Object { [PSCustomObject]@{ id = $_; type = 'pre-authentication' } }) } + $script:Code = @{ a = $marked; b = $marked; c = $marked; d = $plain } + $script:Conditions = @{ + a = @([PSCustomObject]@{ source = 'roles'; operator = '~'; value = '7' }) + b = @() + c = @([PSCustomObject]@{ source = 'roles'; operator = '~'; value = '555' }) + d = @([PSCustomObject]@{ source = 'roles'; operator = '~'; value = '7' }) + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -like 'hooks/*' } { + $id = $Path -replace '^hooks/', '' + [PSCustomObject]@{ id = $id; type = 'pre-authentication'; disabled = $true; function = $script:Code[$id]; conditions = $script:Conditions[$id] } + } + @(Get-OneLoginSeededObject -Type Hooks -OwnedRoleId '7' -Connection $script:Connection).id | Should-BeCollection @('a') + # A hook without the marker is not a candidate at all, and is never named. + (@(Get-OneLoginSeededObject -Type Hooks -Unproven -OwnedRoleId '7' -Connection $script:Connection).Id | Sort-Object) | Should-BeCollection @('b', 'c') + } + } + } + + Context 'Custom fields need declaring and the attribute prefix' { + + It 'claims only the fields the data declares' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'users/custom_attributes' } { + , @( + [PSCustomObject]@{ id = 1; shortname = 'zztest_seed_tag' } + [PSCustomObject]@{ id = 2; shortname = 'zztest_badge_id' } + [PSCustomObject]@{ id = 3; shortname = 'cost_center' } + [PSCustomObject]@{ id = 4; shortname = 'zztest_made_by_hand' } + [PSCustomObject]@{ id = 5; shortname = 'ZZTEST_SEED_TAG' } + ) + } + @(Get-OneLoginSeededObject -Type Attributes -Connection $script:Connection).id | Sort-Object | Should-BeCollection @(1, 2) + } + } + } + + It 'refuses -Unproven for users and fields, which are not ours without their proof' { + InModuleScope TestEnvironment { + { Get-OneLoginSeededObject -Type Users -Unproven -Connection $script:Connection } | Should-Throw -ExceptionMessage '*-Unproven applies to*' + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/Invoke-OneLoginRequest.Tests.ps1 b/Tests/Unit/Providers/OneLogin/Invoke-OneLoginRequest.Tests.ps1 new file mode 100644 index 0000000..0f68a11 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/Invoke-OneLoginRequest.Tests.ps1 @@ -0,0 +1,245 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The single network path. Invoke-WebRequest is mocked; nothing reaches OneLogin. + + The encoding handling follows the HTTP encoding invariant in CLAUDE.md and these pin it through + this provider's own function, both directions: a body goes out as UTF-8 bytes, and a response is + decoded from its raw bytes as UTF-8 whatever charset it declares. + + The rest pins what was learned against a live account: a list pages by limit and page and says + how many pages in Total-Pages; a JSON array is one object to Windows PowerShell's + ConvertFrom-Json, which once would have ended pagination after the first page; a 401 is a + token to renew once; a 429 is waited out briefly and reported when the wait is long. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + # Imported once per run, not once per file. CI runs the suite shuffled, so state one file + # leaves behind for another fails there rather than hiding in file order. + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Invoke-OneLoginRequest' -Tag 'Unit', 'Private' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Connection = @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com' } + Mock Get-OneLoginAccessToken { 'test-token' } + Mock Start-Sleep { } + + # What Invoke-WebRequest hands back for a JSON body. Content is decoded as ISO-8859-1 on + # purpose - 28591, by number because .NET Framework has no Latin1 property - which is + # what Windows PowerShell does when a charset is missing, so only the raw stream is right. + $script:Respond = { + param([string]$Json, [hashtable]$Headers = @{}) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) + $all = @{ 'Content-Type' = 'application/json' } + foreach ($key in $Headers.Keys) { $all[$key] = $Headers[$key] } + [PSCustomObject]@{ + StatusCode = 200 + Content = [System.Text.Encoding]::GetEncoding(28591).GetString($bytes) + Headers = $all + RawContentStream = New-Object System.IO.MemoryStream(, $bytes) + } + } + # A failed response, the way PowerShell 7 hands one over under -SkipHttpErrorCheck. + $script:Fail = { + param([int]$Status, [string]$Json, [hashtable]$Headers = @{}) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Json) + [PSCustomObject]@{ + StatusCode = $Status + Content = $Json + Headers = $Headers + RawContentStream = New-Object System.IO.MemoryStream(, $bytes) + } + } + } + } + + Context 'Addressing' { + + It 'puts a relative path below /api/2 on the account host, and an absolute one on the host itself' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '{"id":1}' } + $null = Invoke-OneLoginRequest -Method GET -Path 'roles/5' -Connection $script:Connection + $null = Invoke-OneLoginRequest -Method GET -Path '/auth/rate_limit' -Connection $script:Connection + Should-Invoke Invoke-WebRequest -ParameterFilter { $Uri -eq 'https://contoso.onelogin.com/api/2/roles/5' } + Should-Invoke Invoke-WebRequest -ParameterFilter { $Uri -eq 'https://contoso.onelogin.com/auth/rate_limit' } + } + } + + It 'sends the bearer token and escapes the query' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '[]' } + $null = Invoke-OneLoginRequest -Method GET -Path 'users' -Query @{ 'custom_attributes.zztest_seed_tag' = 'ZZ-TEST-seed'; fields = 'id,username' } -Connection $script:Connection + Should-Invoke Invoke-WebRequest -ParameterFilter { + $Headers.Authorization -eq 'Bearer test-token' -and + $Uri -eq 'https://contoso.onelogin.com/api/2/users?custom_attributes.zztest_seed_tag=ZZ-TEST-seed&fields=id%2Cusername' + } + } + } + } + + Context 'Encoding' { + + It 'sends a body as UTF-8 bytes with an explicit charset, never as a string' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '{"id":1}' } + $name = 'Jos' + [char]0x00E9 + ' Jos' + [char]0x0065 + [char]0x0301 + ' ' + [char]0x59DC + [char]0xD842 + [char]0xDFB7 + + $null = Invoke-OneLoginRequest -Method POST -Path 'users' -Body @{ firstname = $name } -Connection $script:Connection + + Should-Invoke Invoke-WebRequest -ParameterFilter { $Body -is [byte[]] -and $ContentType -eq 'application/json; charset=utf-8' } + Should-Invoke Invoke-WebRequest -ParameterFilter { + $sent = ([System.Text.Encoding]::UTF8.GetString($Body) | ConvertFrom-Json).firstname + [string]::Equals($sent, ('Jos' + [char]0x00E9 + ' Jos' + [char]0x0065 + [char]0x0301 + ' ' + [char]0x59DC + [char]0xD842 + [char]0xDFB7), [StringComparison]::Ordinal) + } + } + } + + It 'decodes a response from its raw bytes as UTF-8, on a single object and on every page' { + InModuleScope TestEnvironment { + $name = 'Jos' + [char]0x0065 + [char]0x0301 + ' ' + [char]0xD842 + [char]0xDFB7 + [char]0x7530 + Mock Invoke-WebRequest -ParameterFilter { $Uri -like '*users/1' } { & $script:Respond ('{"firstname":"' + $name + '"}') } + Mock Invoke-WebRequest -ParameterFilter { $Uri -like '*users?*' } { & $script:Respond ('[{"id":1,"firstname":"' + $name + '"}]') } + + $one = Invoke-OneLoginRequest -Method GET -Path 'users/1' -Connection $script:Connection + $many = @(Invoke-OneLoginRequest -Method GET -Path 'users' -Paginate -Connection $script:Connection) + + # Ordinal, never -eq: PowerShell compares strings linguistically and would call a + # decomposed and a precomposed name equal. + [string]::Equals($one.firstname, $name, [StringComparison]::Ordinal) | Should-BeTrue + [string]::Equals($many[0].firstname, $name, [StringComparison]::Ordinal) | Should-BeTrue + } + } + + It 'passes a pre-serialised JSON array through untouched' { + # The role endpoints take a bare array of ids, and a one-element array piped to + # ConvertTo-Json becomes a bare number; the steps serialise it themselves. + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '[{"id":7}]' } + $null = Invoke-OneLoginRequest -Method POST -Path 'roles/1/users' -Body '[7]' -Connection $script:Connection + Should-Invoke Invoke-WebRequest -ParameterFilter { [System.Text.Encoding]::UTF8.GetString($Body) -eq '[7]' } + } + } + } + + Context 'Pagination' { + + It 'asks page after page until a page comes back short, and emits every item' { + InModuleScope TestEnvironment { + $script:OneLoginPageSize = 2 + try { + Mock Invoke-WebRequest -ParameterFilter { $Uri -like '*page=1*' } { & $script:Respond '[{"id":1},{"id":2}]' } + Mock Invoke-WebRequest -ParameterFilter { $Uri -like '*page=2*' } { & $script:Respond '[{"id":3}]' } + + @(Invoke-OneLoginRequest -Method GET -Path 'users' -Paginate -Connection $script:Connection).id | Should-BeCollection @(1, 2, 3) + Should-Invoke Invoke-WebRequest -Times 2 -Exactly + Should-Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*limit=2&page=1' } + } + finally { $script:OneLoginPageSize = 100 } + } + } + + It 'stops at the last page Total-Pages names even when that page is full' { + InModuleScope TestEnvironment { + $script:OneLoginPageSize = 2 + try { + Mock Invoke-WebRequest { & $script:Respond '[{"id":1},{"id":2}]' @{ 'Total-Pages' = '1' } } + @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $script:Connection) | Should-BeCollection -Count 2 + Should-Invoke Invoke-WebRequest -Times 1 -Exactly + } + finally { $script:OneLoginPageSize = 100 } + } + } + + It 'emits items one by one, so a pipeline filter selects a single item' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '[{"id":1,"name":"ZZ-TEST-All Staff"},{"id":2,"name":"Default"}]' } + $match = @(Invoke-OneLoginRequest -Method GET -Path 'roles' -Paginate -Connection $script:Connection | Where-Object name -eq 'Default') + $match | Should-BeCollection -Count 1 + $match[0].id | Should-Be 2 + } + } + + It 'returns nothing for an empty list or an empty body' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Respond '[]' } + @(Invoke-OneLoginRequest -Method GET -Path 'groups' -Paginate -Connection $script:Connection) | Should-BeCollection -Count 0 + Mock Invoke-WebRequest { & $script:Respond '' } + Invoke-OneLoginRequest -Method DELETE -Path 'groups/1' -Connection $script:Connection | Should-BeNull + } + } + } + + Context 'Errors, renewal and backoff' { + + It 'returns nothing for a status the caller asked to ignore' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Fail 404 '{"status":404,"error":"NotFoundError","description":"Resource not found"}' } + Invoke-OneLoginRequest -Method DELETE -Path 'roles/1' -IgnoreStatus 404 -Connection $script:Connection | Should-BeNull + } + } + + It 'throws with the status, the error name and OneLogin''s message for anything else' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Fail 422 '{"name":"UnprocessableEntityError","message":"Validation failed: Username must be unique","statusCode":422}' } + { Invoke-OneLoginRequest -Method POST -Path 'users' -Body @{ username = 'x' } -Connection $script:Connection } | + Should-Throw -ExceptionMessage '*HTTP 422: UnprocessableEntityError: Validation failed: Username must be unique*' + } + } + + It 'renews the token once on a 401 and repeats the call' { + InModuleScope TestEnvironment { + $script:Calls = 0 + Mock Invoke-WebRequest { + $script:Calls++ + if ($script:Calls -eq 1) { return (& $script:Fail 401 '{"name":"UnauthorizedError","message":"Unauthorized","statusCode":401}') } + & $script:Respond '{"id":1}' + } + $script:Connection.AccessToken = 'stale' + (Invoke-OneLoginRequest -Method GET -Path 'roles/1' -Connection $script:Connection).id | Should-Be 1 + $script:Connection.AccessToken | Should-BeNull + Should-Invoke Invoke-WebRequest -Times 2 -Exactly + } + } + + It 'does not renew forever: a second 401 is thrown' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Fail 401 '{"name":"UnauthorizedError","message":"Unauthorized","statusCode":401}' } + { Invoke-OneLoginRequest -Method GET -Path 'roles' -Connection $script:Connection } | Should-Throw -ExceptionMessage '*HTTP 401*' + Should-Invoke Invoke-WebRequest -Times 2 -Exactly + } + } + + It 'waits out a short 429 and retries' { + InModuleScope TestEnvironment { + $script:Calls = 0 + Mock Invoke-WebRequest { + $script:Calls++ + if ($script:Calls -eq 1) { return (& $script:Fail 429 '{"statusCode":429,"name":"TooManyRequests","message":"Rate limit"}' @{ 'Retry-After' = '7' }) } + & $script:Respond '{"id":1}' + } + (Invoke-OneLoginRequest -Method GET -Path 'roles/1' -Connection $script:Connection -WarningAction SilentlyContinue).id | Should-Be 1 + Should-Invoke Start-Sleep -Times 1 -Exactly -ParameterFilter { $Seconds -eq 7 } + } + } + + It 'reports rather than sleeps through a reset that is far away' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Fail 429 '{"statusCode":429,"message":"Rate limit"}' @{ 'X-RateLimit-Reset' = '1800' } } + { Invoke-OneLoginRequest -Method GET -Path 'roles' -Connection $script:Connection } | Should-Throw -ExceptionMessage '*resets in 1800 seconds*' + Should-NotInvoke Start-Sleep + } + } + + It 'reduces an HTML error page to its status' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { & $script:Fail 400 '400: BAD REQUEST' } + { Invoke-OneLoginRequest -Method GET -Path 'privileges' -Connection $script:Connection } | + Should-Throw -ExceptionMessage '*HTTP 400: OneLogin answered with an HTML error page*' + } + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/New-OneLoginEnvironment.Tests.ps1 b/Tests/Unit/Providers/OneLogin/New-OneLoginEnvironment.Tests.ps1 new file mode 100644 index 0000000..9cd4cd5 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/New-OneLoginEnvironment.Tests.ps1 @@ -0,0 +1,140 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The orchestrator's job is ordering and honesty. Fields must exist before people carry the tag, + roles before apps are granted to them and mappings add them, and mappings before people so an + enabled one acts on each person as they are created; so the order is asserted rather than + assumed. A step that returned errors has not succeeded. -Tier reaches every step that decides + what exists, and a seed with no -Tier passes none - which is pinned because the first live seed + passed $null, failed four ValidateSets and skipped roles, groups, apps and mappings entirely. + + The backstop mock is the reason this suite can never reach an account. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'New-OneLoginEnvironment' -Tag 'Unit', 'Public' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } } + Mock Write-TestMessage { } + Mock Write-Host { } + + $script:StepOrder = [System.Collections.Generic.List[string]]::new() + Mock New-OneLoginCustomAttribute { $script:StepOrder.Add('Attributes'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginRole { $script:StepOrder.Add('Roles'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginGroup { $script:StepOrder.Add('Groups'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginApp { $script:StepOrder.Add('Apps'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginMapping { $script:StepOrder.Add('Mappings'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginUser { $script:StepOrder.Add('Users'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginPolicy { $script:StepOrder.Add('Policies'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginAppRule { $script:StepOrder.Add('AppRules'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginApiAuthorization { $script:StepOrder.Add('ApiAuthorizations'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginSmartHook { $script:StepOrder.Add('Hooks'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginSelfRegistration { $script:StepOrder.Add('SelfRegistration'); [PSCustomObject]@{ Errors = @() } } + Mock New-OneLoginMfaFactor { $script:StepOrder.Add('Mfa'); [PSCustomObject]@{ Errors = @() } } + + # The backstop. A step added later and not mocked here throws rather than reaching + # whatever account the developer is connected to. + Mock Invoke-OneLoginRequest { throw "A network call escaped the mocks: $Method $Path" } + } + } + + It 'runs the steps in dependency order' { + InModuleScope TestEnvironment { + $null = New-OneLoginEnvironment -Confirm:$false + $script:StepOrder | Should-BeCollection @('Attributes', 'Roles', 'Groups', 'Policies', 'Apps', 'AppRules', 'ApiAuthorizations', 'Mappings', 'Hooks', 'SelfRegistration', 'Users', 'Mfa') + } + } + + It 'skips what it is told to and attempts the rest' { + InModuleScope TestEnvironment { + $r = New-OneLoginEnvironment -Skip Apps, Mappings, Hooks -PassThru -Confirm:$false + $script:StepOrder | Should-BeCollection @('Attributes', 'Roles', 'Groups', 'Policies', 'AppRules', 'ApiAuthorizations', 'SelfRegistration', 'Users', 'Mfa') + $r.Summary.TotalSteps | Should-Be 12 + $r.Summary.AttemptedSteps | Should-Be 9 + @($r.Skipped) | Should-BeCollection @('Apps', 'Mappings', 'Hooks') + } + } + + It 'counts a step that returned errors as failed, and keeps going past one that throws' { + InModuleScope TestEnvironment { + Mock New-OneLoginUser { $script:StepOrder.Add('Users'); [PSCustomObject]@{ Errors = @('7 people were made Unlicensed') } } + Mock New-OneLoginGroup { $script:StepOrder.Add('Groups'); throw 'plan limit' } + + $r = New-OneLoginEnvironment -PassThru -Confirm:$false -WarningAction SilentlyContinue + + @($r.Steps | Where-Object Name -eq 'Users').Success | Should-BeFalse + @($r.Steps | Where-Object Name -eq 'Groups')[0].Errors[0] | Should-MatchString 'plan limit' + $script:StepOrder | Should-ContainCollection @('Apps', 'Mappings', 'Users') + $r.Summary.FailedSteps | Should-Be 2 + $r.Summary.SuccessfulSteps | Should-Be 10 + } + } + + It 'passes -Tier to every step that decides what exists, and -ShowProgress to the users step' { + InModuleScope TestEnvironment { + $null = New-OneLoginEnvironment -Tier Core -ShowProgress -Confirm:$false + foreach ($step in 'New-OneLoginRole', 'New-OneLoginGroup', 'New-OneLoginPolicy', 'New-OneLoginApp', 'New-OneLoginAppRule', 'New-OneLoginMapping', 'New-OneLoginSmartHook', 'New-OneLoginMfaFactor') { + Should-Invoke $step -Times 1 -Exactly -ParameterFilter { @($Tier) -eq 'Core' } + } + # What a tier cannot change - an API and a sign-up profile exist for nobody in particular - takes none. + Should-Invoke New-OneLoginApiAuthorization -Times 1 -Exactly + Should-Invoke New-OneLoginSelfRegistration -Times 1 -Exactly + Should-Invoke New-OneLoginUser -Times 1 -Exactly -ParameterFilter { @($Tier) -eq 'Core' -and $ShowProgress } + Should-Invoke New-OneLoginCustomAttribute -Times 1 -Exactly + } + } + + It 'passes no -Tier at all when none was given' { + InModuleScope TestEnvironment { + $null = New-OneLoginEnvironment -Confirm:$false + foreach ($step in 'New-OneLoginRole', 'New-OneLoginGroup', 'New-OneLoginPolicy', 'New-OneLoginApp', 'New-OneLoginAppRule', 'New-OneLoginMapping', 'New-OneLoginSmartHook', 'New-OneLoginUser', 'New-OneLoginMfaFactor') { + Should-Invoke $step -Times 1 -Exactly -ParameterFilter { $null -eq $Tier } + } + } + } + + It 'returns nothing without -PassThru' { + InModuleScope TestEnvironment { + @(New-OneLoginEnvironment -Confirm:$false) | Should-BeCollection -Count 0 + } + } + + It 'makes no HTTP call, whatever steps the orchestrator gains later' { + InModuleScope TestEnvironment { + Mock Invoke-WebRequest { throw 'A unit test attempted a real HTTP request.' } + Mock Invoke-RestMethod { throw 'A unit test attempted a real HTTP request.' } + $null = New-OneLoginEnvironment -PassThru -Confirm:$false + Should-NotInvoke Invoke-WebRequest + Should-NotInvoke Invoke-RestMethod + Should-NotInvoke Invoke-OneLoginRequest + } + } +} + +Describe 'Get-OneLoginSeedScope' -Tag 'Unit', 'Private' { + + It 'with every tier, covers every role and group the data declares; with Bulk alone, none' { + # Bulk people are unlicensed and hold no roles, and every role and group has a Core member, + # so a Bulk-only seed creates no role at all rather than creating one it could never prove. + InModuleScope TestEnvironment { + $data = Get-OneLoginDataPath + $roles = @(Import-Csv -LiteralPath (Join-Path $data 'OneLoginRoles.csv') -Encoding UTF8).Key + $groups = @(Import-Csv -LiteralPath (Join-Path $data 'OneLoginGroups.csv') -Encoding UTF8).Key + + $all = Get-OneLoginSeedScope + @($roles | Where-Object { -not $all.Roles.Contains($_) }) | Should-BeCollection -Count 0 + @($groups | Where-Object { -not $all.Groups.Contains($_) }) | Should-BeCollection -Count 0 + + $core = Get-OneLoginSeedScope -Tier Core + $core.Roles.Count | Should-Be $roles.Count + + (Get-OneLoginSeedScope -Tier Bulk).Roles.Count | Should-Be 0 + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/New-OneLoginStep.Tests.ps1 b/Tests/Unit/Providers/OneLogin/New-OneLoginStep.Tests.ps1 new file mode 100644 index 0000000..545bc7a --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/New-OneLoginStep.Tests.ps1 @@ -0,0 +1,524 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + The seed steps, and the safety properties that have no parameter. + + - Every mapping carries the seed-tag condition with match all, whatever the data says, so an + enabled one can act on seeded people and nobody else. No switch leaves it out. + - Nobody who is not seeded is ever sent anywhere: the users step adds to roles and names as + managers only ids it created or proved, and the roles, apps and mappings steps use only roles + that are empty or hold seeded people alone. A prefixed role holding somebody else is left + alone and said so. + - An app's client secret, which OneLogin returns on create, is not kept unless -SaveAppSecret asks; + OneLoginAppSecret.Tests.ps1 covers the switch. + - A role grant goes out as a JSON array even for one id; ConvertTo-Json would have sent the + bare number. + - A person OneLogin quietly left unlicensed is reported by the seed, not only by verification. + + Every call is mocked. This suite must never reach an account. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'The OneLogin seed steps' -Tag 'Unit', 'Public' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } } + Mock Invoke-OneLoginRequest { throw "Escaped the mocks: $Method $Path" } + Mock Write-TestProgress { } + # Never the real credential folder. + Mock Get-OneLoginAppSecretRecord { } + Mock Export-OneLoginAppSecret { throw 'An app secret must not be saved without -SaveAppSecret.' } + $script:Sent = [System.Collections.Generic.List[object]]::new() + } + } + + Context 'New-OneLoginMapping' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { + [PSCustomObject]@{ id = 31; name = 'ZZ-TEST-Finance' } + [PSCustomObject]@{ id = 32; name = 'ZZ-TEST-Engineering' } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'mappings' } { , @() } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'mappings' } { $script:Sent.Add($Body); [PSCustomObject]@{ id = 1 } } + } + } + + It 'gates every mapping on the seed tag with match all, and adds only a role' { + InModuleScope TestEnvironment { + $null = New-OneLoginMapping -Confirm:$false + $script:Sent.Count | Should-Be 2 + foreach ($body in $script:Sent) { + $body.match | Should-Be 'all' + @($body.conditions | Where-Object { $_.source -ceq 'custom_attribute_zztest_seed_tag' -and $_.operator -eq '=' -and $_.value -ceq 'ZZ-TEST-seed' }) | Should-BeCollection -Count 1 + @($body.actions | ForEach-Object action) | Should-BeCollection @('add_role') + } + ($script:Sent | Where-Object { $_.enabled }).actions[0].value | Should-BeCollection @('31') + } + } + + It 'has no parameter that could leave the gate out or widen the match' { + InModuleScope TestEnvironment { + @((Get-Command New-OneLoginMapping).Parameters.Keys | Where-Object { $_ -match 'Gate|Match|Condition|Ungated' }) | Should-BeCollection -Count 0 + } + } + + It 'refuses to reuse a prefixed mapping someone made without the gate' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'mappings' -and -not $Query } { + , @([PSCustomObject]@{ id = 9; name = 'ZZ-TEST-Finance department gets Finance'; match = 'all'; enabled = $true; conditions = @([PSCustomObject]@{ source = 'department'; operator = '='; value = 'Finance' }) }) + } + $result = New-OneLoginMapping -Key finance-dept -PassThru -Confirm:$false + $result.Errors[0] | Should-MatchString 'without the seed-tag condition' + $script:Sent | Should-BeCollection -Count 0 + } + } + } + + Context 'New-OneLoginUser' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:NextId = 5000 + # One person already seeded, exactly as the data describes her; nothing else in the + # account is ours. + $data = Get-OneLoginDataPath + $roleNames = @{}; foreach ($row in (Import-Csv (Join-Path $data 'OneLoginRoles.csv') -Encoding UTF8)) { $roleNames[$row.Key] = $row.Name } + $groupNames = @{}; foreach ($row in (Import-Csv (Join-Path $data 'OneLoginGroups.csv') -Encoding UTF8)) { $groupNames[$row.Key] = $row.Name } + $adaRow = Import-Csv (Join-Path $data 'OneLoginUsers.csv') -Encoding UTF8 | Where-Object Key -eq 'awhitfield' + $script:AdaDirectory = Resolve-OneLoginDirectoryIdentity -Row $adaRow -RoleNameByKey $roleNames -GroupNameByKey $groupNames ` + -Connection @{ Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } + $script:NewAda = { + param([int]$Status) + $ada = [PSCustomObject]@{ id = 4001; username = 'zz-test-awhitfield'; firstname = 'Ada'; lastname = 'Whitfield'; title = 'Chief Executive'; department = 'Executive'; company = $null + status = $Status; state = 1; group_id = 610; manager_user_id = $null + custom_attributes = [PSCustomObject]@{ zztest_seed_tag = 'ZZ-TEST-seed'; zztest_badge_id = 'B-1001'; zztest_contractor = 'false'; zztest_cost_center = 'CC-100' } } + foreach ($name in $script:AdaDirectory.Keys) { $ada | Add-Member -NotePropertyName $name -NotePropertyValue $script:AdaDirectory[$name] } + $ada + } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { & $script:NewAda 1 } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Groups' } { + foreach ($name in 'Seattle HQ', 'London', 'New York', 'US Regional Offices', 'Remote Workers') { [PSCustomObject]@{ id = 600 + $name.Length; name = "ZZ-TEST-$name" } } + } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { + [PSCustomObject]@{ id = 31; name = 'ZZ-TEST-All Staff'; users = @(4001) } + [PSCustomObject]@{ id = 32; name = 'ZZ-TEST-Engineering'; users = @() } + [PSCustomObject]@{ id = 33; name = 'ZZ-TEST-Finance'; users = @() } + [PSCustomObject]@{ id = 34; name = 'ZZ-TEST-Contractors'; users = @() } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'users' } { + $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body }) + $script:NextId++ + [PSCustomObject]@{ id = $script:NextId } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -in 'POST', 'PUT' -and $Path -ne 'users' } { + $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body }) + } + # The read-back after granting: by default OneLogin shows every grant already sent. + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { + foreach ($roleId in 31, 32, 33, 34) { + $ids = foreach ($grant in @($script:Sent | Where-Object Path -eq "roles/$roleId/users")) { $parsed = $grant.Body | ConvertFrom-Json; @($parsed) } + [PSCustomObject]@{ id = $roleId; users = @($ids) } + } + } + Mock Start-Sleep { } + } + } + + It 'waits until OneLogin shows every grant it sent, and says so if it never does' { + InModuleScope TestEnvironment { + $script:Reads = 0 + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { + $script:Reads++ + if ($script:Reads -lt 3) { return } + foreach ($roleId in 31, 32, 33, 34) { + $ids = foreach ($grant in @($script:Sent | Where-Object Path -eq "roles/$roleId/users")) { $parsed = $grant.Body | ConvertFrom-Json; @($parsed) } + [PSCustomObject]@{ id = $roleId; users = @($ids) } + } + } + $settled = New-OneLoginUser -Tier Core -PassThru -Confirm:$false + $settled.GrantsNotYetShown | Should-Be 0 + Should-Invoke Start-Sleep -Times 2 -Exactly + + $script:Sent.Clear() + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { } + $pending = New-OneLoginUser -Tier Core -PassThru -Confirm:$false -WarningVariable warned -WarningAction SilentlyContinue + $pending.GrantsNotYetShown | Should-BeGreaterThan 0 + @($warned | Where-Object { "$_" -like 'OneLogin has not yet shown*' }) | Should-BeCollection -Count 1 + } + } + + It 'sends a grant OneLogin answered and never applied again, and stops waiting once it shows' { + # Found live: on some runs OneLogin answers a role grant 200 and applies nothing, and a + # grant sent again is applied. Here the first grants are ignored and only the re-sent + # ones count. + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { + foreach ($roleId in 31, 32, 33, 34) { + $sends = @($script:Sent | Where-Object Path -eq "roles/$roleId/users") + $ids = foreach ($grant in @($sends | Select-Object -Skip 1)) { $parsed = $grant.Body | ConvertFrom-Json; @($parsed) } + [PSCustomObject]@{ id = $roleId; users = @($ids) } + } + } + $result = New-OneLoginUser -Tier Core -PassThru -Confirm:$false -WarningAction SilentlyContinue + $result.GrantsNotYetShown | Should-Be 0 + foreach ($roleId in 31, 32, 33, 34) { + @($script:Sent | Where-Object Path -eq "roles/$roleId/users").Count | Should-Be 2 + } + Should-Invoke Start-Sleep -Times 6 -Exactly + } + } + + It 'sends only ids it created or proved, to roles and as managers' { + InModuleScope TestEnvironment { + $null = New-OneLoginUser -Tier Core -Confirm:$false + $known = @(4001) + @(5001..($script:NextId)) + $created = @($script:Sent | Where-Object Path -eq 'users') + @($created | Where-Object { $_.Body.manager_user_id -and $known -notcontains [int]$_.Body.manager_user_id }) | Should-BeCollection -Count 0 + foreach ($grant in @($script:Sent | Where-Object Path -like 'roles/*/users')) { + # Assigned before it is wrapped: Windows PowerShell's ConvertFrom-Json emits a + # JSON array as one object, so @(... | ConvertFrom-Json) nests it there. + $parsed = $grant.Body | ConvertFrom-Json + $ids = @($parsed) + @($ids | Where-Object { $known -notcontains [int]$_ }) | Should-BeCollection -Count 0 + } + } + } + + It 'sends role grants as a JSON array, one request per role, and never re-adds a member' { + InModuleScope TestEnvironment { + $null = New-OneLoginUser -Tier Core -Confirm:$false + $grants = @($script:Sent | Where-Object Path -like 'roles/*/users') + ($grants.Path | Sort-Object) | Should-BeCollection @('roles/31/users', 'roles/32/users', 'roles/33/users', 'roles/34/users') + foreach ($grant in $grants) { $grant.Body | Should-MatchString '^\[\d+(,\d+)*\]$' } + ($grants | Where-Object Path -eq 'roles/31/users').Body | Should-NotMatchString '4001' + ($grants | Where-Object Path -eq 'roles/33/users').Body | Should-MatchString '^\[\d+\]$' + } + } + + It 'writes the seed tag, the lifecycle and the group on each person it creates, and reuses the one already seeded' { + InModuleScope TestEnvironment { + $result = New-OneLoginUser -Username awhitfield, jnino, mbell -PassThru -Confirm:$false + $result.ReusedUsers | Should-Be 1 + $created = @($script:Sent | Where-Object Path -eq 'users') + $created.Count | Should-Be 2 + foreach ($request in $created) { $request.Body.custom_attributes.zztest_seed_tag | Should-Be 'ZZ-TEST-seed' } + $mbell = ($created | Where-Object { $_.Body.username -eq 'zz-test-mbell' }).Body + $mbell.status | Should-Be 2 + $mbell.state | Should-Be 1 + $mbell.manager_user_id | Should-Be 4001 + $mbell.custom_attributes.zztest_contractor | Should-Be 'false' + } + } + + It 'puts a reused person back as the data describes, sending only what differs' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { & $script:NewAda 2 } + $result = New-OneLoginUser -Username awhitfield -PassThru -Confirm:$false + $result.UpdatedUsers | Should-Be 1 + $update = @($script:Sent | Where-Object Path -eq 'users/4001') + $update.Count | Should-Be 1 + @($update[0].Body.Keys) | Should-BeCollection @('status') + $update[0].Body.status | Should-Be 1 + } + } + + It 'builds every directory identifier inside the seed''s namespace, so none can match a real account' { + InModuleScope TestEnvironment { + $null = New-OneLoginUser -Tier Core -Confirm:$false + $created = @($script:Sent | Where-Object Path -eq 'users' | ForEach-Object Body) + $created.Count | Should-BeGreaterThan 0 + foreach ($body in $created) { + $body.samaccountname | Should-MatchString '^zz-test-' + $body.samaccountname.Length | Should-BeLessThanOrEqual 20 + $body.userprincipalname | Should-MatchString '@onelogin-lab\.example\.com$' + $body.distinguished_name | Should-MatchString ',OU=ZZ-TEST-Users,DC=onelogin-lab,DC=example,DC=com$' + foreach ($dn in @($body.member_of -split ';' | Where-Object { $_ })) { $dn | Should-MatchString '^CN=ZZ-TEST-.*,OU=ZZ-TEST-Groups,DC=onelogin-lab,DC=example,DC=com$' } + $body.external_id | Should-MatchString '^ZZ-TEST-' + $body.comment | Should-MatchString '\[ZZ-TEST-seed\]' + } + # The display name is the common name, escaped: the ideographic space survives. + ($created | Where-Object username -eq 'zz-test-jjiang').distinguished_name.IndexOf([char]0x3000) | Should-BeGreaterThan 0 + } + } + + It 'locks the Locked person through the lock call for a year, and never sends a status of 3' { + InModuleScope TestEnvironment { + $null = New-OneLoginUser -Tier Core -Confirm:$false + $ofitz = ($script:Sent | Where-Object { $_.Path -eq 'users' -and $_.Body.username -eq 'zz-test-ofitzgerald' }).Body + $ofitz.status | Should-Be 1 + @($script:Sent | Where-Object { $_.Body.status -eq 3 }) | Should-BeCollection -Count 0 + $lock = @($script:Sent | Where-Object Path -like '/api/1/users/*/lock_user') + $lock.Count | Should-Be 1 + $lock[0].Body.locked_until | Should-Be 525600 + } + } + + It 'locks again only when the lock is missing or has less than a month left' { + InModuleScope TestEnvironment { + $script:Until = (Get-Date).AddDays(200) + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { + [PSCustomObject]@{ id = 4008; username = 'zz-test-ofitzgerald'; status = 3; state = 1; locked_until = $script:Until.ToString('o') } + } + $null = New-OneLoginUser -Username ofitzgerald -Confirm:$false + @($script:Sent | Where-Object Path -like '/api/1/users/*/lock_user') | Should-BeCollection -Count 0 + + $script:Sent.Clear() + $script:Until = (Get-Date).AddDays(3) + $null = New-OneLoginUser -Username ofitzgerald -Confirm:$false + @($script:Sent | Where-Object Path -eq '/api/1/users/4008/lock_user') | Should-BeCollection -Count 1 + } + } + + It 'says when OneLogin left a person it was asked to approve unlicensed' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { + if ($script:Sent.Count -gt 0) { + [PSCustomObject]@{ id = 5001; username = 'zz-test-jnino'; status = 1; state = 3 } + } + } + $result = New-OneLoginUser -Username jnino -PassThru -Confirm:$false + @($result.Errors | Where-Object { $_ -like '1 person(s) the data approves were made Unlicensed*zz-test-jnino*' }) | Should-BeCollection -Count 1 + } + } + + It 'creates and grants nothing under -WhatIf' { + InModuleScope TestEnvironment { + $null = New-OneLoginUser -Tier Core -WhatIf + $script:Sent | Should-BeCollection -Count 0 + } + } + } + + Context 'The steps that could reach out of the seed, and do not' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { [PSCustomObject]@{ id = 4001; username = 'zz-test-awhitfield' } } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { + [PSCustomObject]@{ id = 31; name = 'ZZ-TEST-All Staff' } + [PSCustomObject]@{ id = 32; name = 'ZZ-TEST-Engineering' } + [PSCustomObject]@{ id = 33; name = 'ZZ-TEST-Finance' } + [PSCustomObject]@{ id = 34; name = 'ZZ-TEST-Contractors' } + } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Apps' } { + foreach ($name in 'Expenses Web', 'Payroll Console', 'Contractor Portal SPA', 'Field App', 'Wiki SAML') { [PSCustomObject]@{ id = 700 + $name.Length; name = "ZZ-TEST-$name" } } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -in 'POST', 'PUT' } { $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body; Method = $Method }); [PSCustomObject]@{ id = 99 } } + } + } + + It 'attaches a policy only to groups the seed may use, never makes it the default, and leaves a real group alone' { + InModuleScope TestEnvironment { + # Seattle HQ is the seed's; New York has somebody else in it, so it is not offered. + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Groups' } { [PSCustomObject]@{ id = 610; name = 'ZZ-TEST-Seattle HQ'; policy_id = $null } } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Policies' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'policies' } { , @([PSCustomObject]@{ id = 1; name = 'Default policy'; is_default = $true }) } + $result = New-OneLoginPolicy -Key strict-office -PassThru -Confirm:$false + $created = @($script:Sent | Where-Object Path -eq 'policies') + $created.Count | Should-Be 1 + $created[0].Body.Keys | Should-NotContainCollection 'is_default' + $created[0].Body.minimum_password_length | Should-Be 14 + @($script:Sent | Where-Object Path -like 'groups/*').Path | Should-BeCollection @('groups/610') + @($result.Errors | Where-Object { $_ -like '*New York*' -or $_ -like "*new-york*" }) | Should-BeCollection -Count 1 + } + } + + It 'refuses a same-named policy it cannot prove, and attaches it to nothing' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Groups' } { [PSCustomObject]@{ id = 610; name = 'ZZ-TEST-Seattle HQ' } } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Policies' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'policies' } { , @([PSCustomObject]@{ id = 5; name = 'ZZ-TEST-Strict Office'; is_default = $false }) } + $result = New-OneLoginPolicy -Key strict-office -PassThru -Confirm:$false + $result.Errors[0] | Should-MatchString 'left alone' + $script:Sent | Should-BeCollection -Count 0 + } + } + + It 'lets only seeded apps ask for an API, and tags the server' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -like 'api_authorizations/*/scopes' } { + $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body }); [PSCustomObject]@{ id = 900 + $script:Sent.Count } + } + $null = New-OneLoginApiAuthorization -Confirm:$false + foreach ($server in @($script:Sent | Where-Object Path -eq 'api_authorizations')) { + $server.Body.description | Should-MatchString '\[ZZ-TEST-seed\]' + $server.Body.configuration.resource_identifier | Should-MatchString '^https://api\.onelogin-lab\.example\.com/' + } + $appIds = foreach ($name in 'Expenses Web', 'Payroll Console', 'Contractor Portal SPA', 'Field App', 'Wiki SAML') { 700 + $name.Length } + $clients = @($script:Sent | Where-Object Path -like 'api_authorizations/*/clients') + $clients.Count | Should-Be 3 + @($clients | Where-Object { $appIds -notcontains [int]$_.Body.app_id }) | Should-BeCollection -Count 0 + } + } + + It 'puts app rules only on seeded apps, naming seeded roles, with the provider''s own action' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' } { } + $null = New-OneLoginAppRule -Confirm:$false + $rules = @($script:Sent | Where-Object Path -like 'apps/*/rules') + $rules.Count | Should-Be 2 + foreach ($rule in $rules) { + (@('31', '33') -contains [string]$rule.Body.conditions[0].value) | Should-BeTrue + $rule.Body.actions[0].action | Should-Be 'set_groups' + @($rule.Body.actions[0].value) | Should-BeCollection @('member_of') + } + } + } + + It 'creates the hook disabled, gated on a seeded role, with the marker as its first line' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Hooks' } { } + $null = New-OneLoginSmartHook -Confirm:$false + $hook = @($script:Sent | Where-Object Path -eq 'hooks') + $hook.Count | Should-Be 1 + $hook[0].Body.disabled | Should-BeTrue + @($hook[0].Body.conditions) | Should-BeCollection -Count 1 + $hook[0].Body.conditions[0].value | Should-Be '34' + $code = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($hook[0].Body.function)) + $code | Should-MatchString '^// Seeded by TestEnvironment\. Safe to delete\. \[ZZ-TEST-seed\]' + } + } + + It 'turns its own hook off again if somebody enabled it' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Hooks' } { [PSCustomObject]@{ id = 'h1'; type = 'pre-authentication'; disabled = $false; name = 'pre-authentication hook h1' } } + $result = New-OneLoginSmartHook -PassThru -Confirm:$false + $result.DisabledAgain | Should-Be 1 + ($script:Sent | Where-Object Path -eq 'hooks/h1').Body.disabled | Should-BeTrue + @($script:Sent | Where-Object Path -eq 'hooks') | Should-BeCollection -Count 0 + } + } + + It 'creates the sign-up profile disabled, moderated, lab-domain only and with no default role or group, and puts that back' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'self_registration_profiles' } { [PSCustomObject]@{ self_registration_profiles = @() } } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'SelfRegistration' } { } + $null = New-OneLoginSelfRegistration -Confirm:$false + $profileBody = ($script:Sent | Where-Object Path -eq 'self_registration_profiles').Body.self_registration_profile + $profileBody.enabled | Should-BeFalse + $profileBody.moderated | Should-BeTrue + $profileBody.domain_whitelist | Should-Be 'onelogin-lab.example.com' + $profileBody.default_role_id | Should-BeNull + $profileBody.default_group_id | Should-BeNull + + $script:Sent.Clear() + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'self_registration_profiles' } { [PSCustomObject]@{ self_registration_profiles = @([PSCustomObject]@{ id = 8; name = 'ZZ-TEST-Partner Sign-up' }) } } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'SelfRegistration' } { [PSCustomObject]@{ id = 8; name = 'ZZ-TEST-Partner Sign-up'; enabled = $true; moderated = $true; domain_whitelist = 'onelogin-lab.example.com' } } + $restored = New-OneLoginSelfRegistration -PassThru -Confirm:$false + $restored.Restored | Should-Be 1 + ($script:Sent | Where-Object Path -eq 'self_registration_profiles/8').Body.self_registration_profile.enabled | Should-BeFalse + } + } + + It 'enrols an MFA factor only where the account offers it, only verified, and only on seeded people' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Users' } { + foreach ($key in 'awhitfield', 'praghunathan', 'talvarez') { [PSCustomObject]@{ id = 4000 + $key.Length; username = "zz-test-$key" } } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -like 'mfa/users/*/devices' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -like 'mfa/users/*/factors' } { } + $skipped = New-OneLoginMfaFactor -PassThru -Confirm:$false + @($skipped.Skipped).Count | Should-Be 3 + @($skipped.Errors) | Should-BeCollection -Count 0 + $script:Sent | Should-BeCollection -Count 0 + + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -like 'mfa/users/*/factors' } { , @([PSCustomObject]@{ factor_id = 77; name = 'Email'; auth_factor_name = 'OneLogin Email' }) } + $enrolled = New-OneLoginMfaFactor -PassThru -Confirm:$false + $enrolled.EnrolledFactors | Should-Be 3 + foreach ($request in @($script:Sent | Where-Object Path -like 'mfa/users/*/registrations')) { + $request.Body.verified | Should-BeTrue + $request.Body.factor_id | Should-Be 77 + } + } + } + + It 'has no parameter on any step that could loosen what keeps the seed in' { + InModuleScope TestEnvironment { + foreach ($command in 'New-OneLoginPolicy', 'New-OneLoginApiAuthorization', 'New-OneLoginAppRule', 'New-OneLoginSmartHook', 'New-OneLoginSelfRegistration', 'New-OneLoginMfaFactor', 'New-OneLoginMapping', 'New-OneLoginUser') { + @((Get-Command $command).Parameters.Keys | Where-Object { $_ -match 'Enable|Disable|Default|Moderat|Domain|Gate|Condition|Admin|Verified|Group$|Role$' }) | + Should-BeCollection -Count 0 -Because "$command must not be able to reach beyond the seed" + } + } + } + } + + Context 'New-OneLoginRole and New-OneLoginApp' { + + It 'leaves a prefixed role that holds somebody else alone, and creates the rest' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { + [PSCustomObject]@{ id = 31; name = 'ZZ-TEST-All Staff'; users = @(900000001) } + } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'roles' } { $script:Sent.Add($Body.name); [PSCustomObject]@{ id = 40 } } + + $result = New-OneLoginRole -PassThru -Confirm:$false + $result.Errors[0] | Should-MatchString "'ZZ-TEST-All Staff' already exists and holds" + $script:Sent | Should-NotContainCollection 'ZZ-TEST-All Staff' + $script:Sent.Count | Should-Be 3 + } + } + + It 'names the trial''s limit when OneLogin refuses a role for the plan' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'roles' } { } + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'roles' } { + throw 'OneLogin POST roles failed with HTTP 422: Your current plan max role limit is exceed can not create more roles.' + } + $result = New-OneLoginRole -Key finance -PassThru -Confirm:$false -WarningAction SilentlyContinue + $result.Errors[0] | Should-MatchString 'a OneLogin trial allows five, the Default role among them' + } + } + + It 'keeps no client secret, gives a public client PKCE, and grants apps to seeded roles as one JSON array per role' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { + [PSCustomObject]@{ id = 31; name = 'ZZ-TEST-All Staff' } + [PSCustomObject]@{ id = 33; name = 'ZZ-TEST-Finance' } + [PSCustomObject]@{ id = 34; name = 'ZZ-TEST-Contractors' } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -like 'roles/*/apps' } { } + $script:NextApp = 800 + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'apps' } { + $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body }) + $script:NextApp++ + [PSCustomObject]@{ id = $script:NextApp; sso = [PSCustomObject]@{ client_id = 'cid'; client_secret = 'do-not-keep-me' } } + } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'PUT' -and $Path -like 'roles/*/apps' } { $script:Sent.Add([PSCustomObject]@{ Path = $Path; Body = $Body }) } + + $result = New-OneLoginApp -PassThru -Confirm:$false + ($result | ConvertTo-Json -Depth 6) | Should-NotMatchString 'do-not-keep-me' + + $apps = @($script:Sent | Where-Object Path -eq 'apps' | ForEach-Object Body) + foreach ($app in $apps) { $app.description | Should-MatchString '\[ZZ-TEST-seed\]' } + @($apps | Where-Object { $_.connector_id -eq 108419 -and $_.configuration.token_endpoint_auth_method -eq 2 }).Count | Should-BeGreaterThan 0 + @($apps | ForEach-Object { $_.configuration.redirect_uri; $_.configuration.consumer_url } | Where-Object { $_ -like 'http*' -and $_ -notlike '*onelogin-lab.example.com*' }) | + Should-BeCollection -Count 0 + + $grants = @($script:Sent | Where-Object Path -like 'roles/*/apps') + ($grants.Path | Sort-Object) | Should-BeCollection @('roles/31/apps', 'roles/33/apps', 'roles/34/apps') + foreach ($grant in $grants) { $grant.Body | Should-MatchString '^\[\d+(,\d+)*\]$' } + } + } + + It 'refuses to reuse a prefixed app without the seed tag' { + InModuleScope TestEnvironment { + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { [PSCustomObject]@{ id = 9; name = 'ZZ-TEST-Expenses Web'; description = 'Ours, really' } } + $result = New-OneLoginApp -Key expenses -PassThru -Confirm:$false + $result.Errors[0] | Should-MatchString 'without the seed tag' + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -in 'POST', 'PUT' } + } + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/OneLoginAppSecret.Tests.ps1 b/Tests/Unit/Providers/OneLogin/OneLoginAppSecret.Tests.ps1 new file mode 100644 index 0000000..2828bfc --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/OneLoginAppSecret.Tests.ps1 @@ -0,0 +1,258 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Saved app secrets: New-OneLoginApp -SaveAppSecret, Get-OneLoginAppCredential, and teardown. + + OneLogin shows an app's client secret once, when the app is created. By default the seed drops + it. With -SaveAppSecret it keeps the secrets of the confidential clients it creates - the two + that authenticate with one - through the shared record writer, and nothing else: not a public + or native client's, not the SAML app's, never one for an app it did not create in this run. + + The point of the teardown half is that saved secrets do not build up: each record goes with its + app, and a record whose app is no longer in the account goes too. It must not go while its app + lives, must not go under -WhatIf, must not be read at all under -Keep Apps, and a file that is + not one of this account's records - another account's, or one whose content disagrees with its + name - is never touched. + + The records are written for real into TestDrive:, with the credential folder pointed there, so + the round trip is the real writer and reader. Every OneLogin call is mocked. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Saved OneLogin app secrets' -Tag 'Unit', 'Private', 'Credential' { + + BeforeEach { + $root = Join-Path $TestDrive ([Guid]::NewGuid().ToString('N')) + $null = New-Item -ItemType Directory -Path $root + InModuleScope TestEnvironment -Parameters @{ Root = $root } { + param($Root) + $script:CredentialRoot = $Root + Mock Get-TestCredentialRoot { $script:CredentialRoot } + Mock Protect-TestFile { } + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } } + Mock Invoke-OneLoginRequest { throw "Escaped the mocks: $Method $Path" } + Mock Write-TestMessage { } + Mock Write-Host { } + } + } + + Context 'The record, written and read back' { + + It 'writes a record the reader lists and the credential command returns as a PSCredential, with no secret on the pipeline' { + InModuleScope TestEnvironment { + $secret = 'app-secret-' + [char]0xE9 + '-42' + $null = Export-OneLoginAppSecret -Subdomain Contoso -AppId 4001 -AppKey expenses -AppName 'ZZ-TEST-Expenses Web' ` + -ClientId 'client-4001' -ClientSecret $secret -Confirm:$false -WarningAction SilentlyContinue + + $path = Join-Path $script:CredentialRoot 'contoso.onelogin-app.4001.json' + Test-Path -LiteralPath $path | Should-BeTrue + if ($env:OS -eq 'Windows_NT') { + [IO.File]::ReadAllText($path).Contains($secret) | Should-BeFalse -Because 'on Windows the secret is DPAPI-protected' + } + + $records = @(Get-OneLoginAppSecretRecord -Subdomain contoso) + $records.Count | Should-Be 1 + $records[0].AppKey | Should-Be 'expenses' + ($records[0].PSObject.Properties.Value -contains $secret) | Should-BeFalse + + $app = @(Get-OneLoginAppCredential -WarningAction SilentlyContinue) + $app.Count | Should-Be 1 + $app[0].Credential.UserName | Should-Be 'client-4001' + [string]::Equals($app[0].Credential.GetNetworkCredential().Password, $secret, [StringComparison]::Ordinal) | Should-BeTrue + ($app[0] | Select-Object Key, Name, AppId, Subdomain, Protection | ConvertTo-Json) | Should-NotMatchString ([regex]::Escape($secret)) + } + } + + It 'filters by key, and reads nothing for another account' { + InModuleScope TestEnvironment { + foreach ($entry in @(@{ Id = 1; Key = 'expenses' }, @{ Id = 2; Key = 'payroll' })) { + $null = Export-OneLoginAppSecret -Subdomain contoso -AppId $entry.Id -AppKey $entry.Key -AppName "ZZ-TEST-$($entry.Key)" ` + -ClientId "c$($entry.Id)" -ClientSecret "s$($entry.Id)" -Confirm:$false -WarningAction SilentlyContinue + } + @(Get-OneLoginAppCredential -Key payroll -WarningAction SilentlyContinue).Key | Should-BeCollection @('payroll') + @(Get-OneLoginAppCredential -Subdomain fabrikam) | Should-BeCollection -Count 0 + } + } + + It 'ignores a file whose content names another app than its file name' { + InModuleScope TestEnvironment { + $null = Export-OneLoginAppSecret -Subdomain contoso -AppId 7 -AppKey expenses -AppName 'ZZ-TEST-Expenses Web' ` + -ClientId c -ClientSecret s -Confirm:$false -WarningAction SilentlyContinue + Rename-Item -LiteralPath (Join-Path $script:CredentialRoot 'contoso.onelogin-app.7.json') -NewName 'contoso.onelogin-app.8.json' + @(Get-OneLoginAppSecretRecord -Subdomain contoso -WarningAction SilentlyContinue) | Should-BeCollection -Count 0 + } + } + + It 'removes the record, and the vault secret it points to, only when ShouldProcess allows' { + InModuleScope TestEnvironment { + $null = Export-OneLoginAppSecret -Subdomain contoso -AppId 9 -AppKey expenses -AppName 'ZZ-TEST-Expenses Web' ` + -ClientId c -ClientSecret s -Confirm:$false -WarningAction SilentlyContinue + $record = @(Get-OneLoginAppSecretRecord -Subdomain contoso)[0] + + Remove-OneLoginAppSecret -Record $record -WhatIf | Should-BeFalse + Test-Path -LiteralPath $record.Path | Should-BeTrue + + Mock Remove-TestVaultSecret { $true } + $vaulted = [PSCustomObject]@{ Path = $record.Path; AppId = '9'; AppName = 'x'; Protection = 'SecretStore'; VaultName = 'OneLoginEnvironment'; SecretName = 'OneLoginEnvironment-contoso-app-9' } + Remove-OneLoginAppSecret -Record $vaulted -Confirm:$false | Should-BeTrue + Test-Path -LiteralPath $record.Path | Should-BeFalse + Should-Invoke Remove-TestVaultSecret -Times 1 -Exactly -ParameterFilter { $VaultName -eq 'OneLoginEnvironment' -and $SecretName -eq 'OneLoginEnvironment-contoso-app-9' } + } + } + } + + Context 'New-OneLoginApp -SaveAppSecret' { + + BeforeEach { + InModuleScope TestEnvironment { + $script:Sent = [System.Collections.Generic.List[object]]::new() + Mock Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' } { } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { } + $script:NextApp = 800 + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' -and $Path -eq 'apps' } { + $script:NextApp++ + [PSCustomObject]@{ id = $script:NextApp; name = $Body.name; sso = [PSCustomObject]@{ client_id = "cid-$script:NextApp"; client_secret = "secret-$script:NextApp" } } + } + Mock Export-OneLoginAppSecret { $script:Sent.Add([PSCustomObject]@{ AppKey = $AppKey; ClientId = $ClientId; ClientSecret = $ClientSecret; UseSecretStore = [bool]$UseSecretStore }) } + } + } + + It 'keeps nothing without the switch' { + InModuleScope TestEnvironment { + $result = New-OneLoginApp -PassThru -Confirm:$false + Should-NotInvoke Export-OneLoginAppSecret + $result.SecretsSaved | Should-Be 0 + } + } + + It 'saves the two confidential clients only, from the create answer, and never returns a secret' { + InModuleScope TestEnvironment { + $result = New-OneLoginApp -SaveAppSecret -PassThru -Confirm:$false + ($script:Sent.AppKey | Sort-Object) | Should-BeCollection @('expenses', 'payroll') + foreach ($saved in $script:Sent) { $saved.ClientSecret | Should-MatchString '^secret-\d+$'; $saved.ClientId | Should-MatchString '^cid-\d+$' } + $result.SecretsSaved | Should-Be 2 + ($result | ConvertTo-Json -Depth 6) | Should-NotMatchString 'secret-\d' + } + } + + It 'passes -UseSecretStore through' { + InModuleScope TestEnvironment { + $null = New-OneLoginApp -Key expenses -SaveAppSecret -UseSecretStore -Confirm:$false + $script:Sent[0].UseSecretStore | Should-BeTrue + } + } + + It 'says an app that already existed has no secret to save, and saves nothing for it' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { + [PSCustomObject]@{ id = 55; name = 'ZZ-TEST-Expenses Web'; description = 'Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]' } + } + $result = New-OneLoginApp -Key expenses -SaveAppSecret -PassThru -Confirm:$false -WarningVariable warned -WarningAction SilentlyContinue + Should-NotInvoke Export-OneLoginAppSecret + @($result.SecretsUnavailable) | Should-BeCollection @('expenses') + ($warned -join ' ') | Should-MatchString 'only when the app is created' + } + } + + It 'does not warn about an existing app whose secret is already saved' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { + [PSCustomObject]@{ id = 55; name = 'ZZ-TEST-Expenses Web'; description = 'Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]' } + } + Mock Get-OneLoginAppSecretRecord { [PSCustomObject]@{ AppId = '55'; AppKey = 'expenses' } } + $result = New-OneLoginApp -Key expenses -SaveAppSecret -PassThru -Confirm:$false -WarningVariable warned + @($result.SecretsUnavailable) | Should-BeCollection -Count 0 + @($warned) | Should-BeCollection -Count 0 + } + } + + It 'saves nothing under -WhatIf' { + InModuleScope TestEnvironment { + $null = New-OneLoginApp -SaveAppSecret -WhatIf + Should-NotInvoke Export-OneLoginAppSecret + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'POST' } + } + } + + It 'is passed on by the orchestrator only when asked for' { + InModuleScope TestEnvironment { + foreach ($step in 'New-OneLoginCustomAttribute', 'New-OneLoginRole', 'New-OneLoginGroup', 'New-OneLoginPolicy', 'New-OneLoginAppRule', + 'New-OneLoginApiAuthorization', 'New-OneLoginMapping', 'New-OneLoginSmartHook', 'New-OneLoginSelfRegistration', 'New-OneLoginUser', 'New-OneLoginMfaFactor') { + Mock $step { [PSCustomObject]@{ Errors = @() } } + } + Mock New-OneLoginApp { [PSCustomObject]@{ Errors = @() } } + $null = New-OneLoginEnvironment -Confirm:$false + Should-Invoke New-OneLoginApp -Times 1 -Exactly -ParameterFilter { -not $SaveAppSecret } + $null = New-OneLoginEnvironment -SaveAppSecret -UseSecretStore -Confirm:$false + Should-Invoke New-OneLoginApp -Times 1 -Exactly -ParameterFilter { $SaveAppSecret -and $UseSecretStore } + } + } + } + + Context 'Teardown removes saved secrets with their apps' { + + BeforeEach { + InModuleScope TestEnvironment { + # Seeded app 50 is deleted; app 60 is somebody else's and stays in the account; + # app 70 is already gone. Every app has a saved record, and another account has one too. + foreach ($entry in @(@{ Id = 50; Sub = 'contoso' }, @{ Id = 60; Sub = 'contoso' }, @{ Id = 70; Sub = 'contoso' }, @{ Id = 50; Sub = 'fabrikam' })) { + $null = Export-OneLoginAppSecret -Subdomain $entry.Sub -AppId $entry.Id -AppKey expenses -AppName "app $($entry.Id)" ` + -ClientId c -ClientSecret s -Confirm:$false -WarningAction SilentlyContinue + } + Mock Get-OneLoginSeededObject -ParameterFilter { $Unproven } { } + Mock Get-OneLoginSeededObject -ParameterFilter { -not $Unproven } { + if ($Type -eq 'Apps') { [PSCustomObject]@{ id = 50; name = 'ZZ-TEST-Expenses Web' } } + } + $script:AppDeleted = $false + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'DELETE' } { if ($Path -eq 'apps/50') { $script:AppDeleted = $true } } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { + [PSCustomObject]@{ id = 60 } + if (-not $script:AppDeleted) { [PSCustomObject]@{ id = 50 } } + } + $script:Left = { @(Get-ChildItem -LiteralPath $script:CredentialRoot -Filter '*.onelogin-app.*.json' | ForEach-Object Name | Sort-Object) } + } + } + + It 'deletes the deleted app''s record and the orphan''s, and keeps the live app''s and the other account''s' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -PassThru + $result.AppSecretsRemoved | Should-Be 2 + (& $script:Left) | Should-BeCollection @('contoso.onelogin-app.60.json', 'fabrikam.onelogin-app.50.json') + $result.TotalRemoved | Should-Be 1 -Because 'a saved secret is not an object in the account' + } + } + + It 'deletes nothing under -Force -WhatIf' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -WhatIf -PassThru + $result.AppSecretsRemoved | Should-Be 0 + @(& $script:Left).Count | Should-Be 4 + } + } + + It 'reads no saved secret at all under -Keep Apps' { + InModuleScope TestEnvironment { + Mock Get-OneLoginAppSecretRecord { throw 'Records must not be read when the apps are kept.' } + $result = Remove-OneLoginEnvironment -Force -Keep Apps -PassThru + $result.AppSecretsRemoved | Should-Be 0 + @(& $script:Left).Count | Should-Be 4 + } + } + + It 'leaves the orphans when the account''s apps cannot be listed, and says so' { + InModuleScope TestEnvironment { + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -eq 'apps' } { throw 'HTTP 503' } + $result = Remove-OneLoginEnvironment -Force -PassThru -WarningAction SilentlyContinue + $result.AppSecretsRemoved | Should-Be 1 + $left = & $script:Left + ($left -contains 'contoso.onelogin-app.70.json') | Should-BeTrue -Because 'the orphan cannot be told from a live app without the listing' + ($left -contains 'contoso.onelogin-app.60.json') | Should-BeTrue + @($result.Errors | Where-Object { $_ -like '*Could not list the account''s apps*' }) | Should-BeCollection -Count 1 + } + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/Remove-OneLoginEnvironment.Tests.ps1 b/Tests/Unit/Providers/OneLogin/Remove-OneLoginEnvironment.Tests.ps1 new file mode 100644 index 0000000..0f6c8d4 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/Remove-OneLoginEnvironment.Tests.ps1 @@ -0,0 +1,168 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Teardown safety for the OneLogin provider. + + The two failures every provider's teardown is pinned against, because neither produces an + error: -Force defeating -WhatIf, and a refused confirmation that does not stop anything. And the + ones that are OneLogin's own: every proof taken before the first deletion, because a role is + proved by the users and apps it holds, a policy by its groups, a mapping and a hook by the roles + they name; the deletions in the only order that keeps the later proofs standing; and -Keep + keeping everything the kept type is proved by, so nothing is left that no later teardown could + claim. + + Every call is mocked. This suite must never reach an account. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $script:ModuleRoot 'TestEnvironment.psd1') } +} + +Describe 'Remove-OneLoginEnvironment' -Tag 'Unit', 'Public', 'Destructive' { + + BeforeEach { + InModuleScope TestEnvironment { + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; ApiHost = 'contoso.onelogin.com'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } } + Mock Write-TestMessage { } + Mock Write-Host { } + # Never the real credential folder; saved app secrets have a suite of their own. + Mock Get-OneLoginAppSecretRecord { } + + $script:Events = [System.Collections.Generic.List[string]]::new() + Mock Get-OneLoginSeededObject -ParameterFilter { $Unproven } { + if ($Type -eq 'Roles') { [PSCustomObject]@{ Type = 'Roles'; Id = '99'; Name = 'ZZ-TEST-Someone else'; Reason = 'It holds 1 user(s) that are not seeded' } } + } + Mock Get-OneLoginSeededObject -ParameterFilter { -not $Unproven } { + $script:Events.Add("prove $Type") + switch ($Type) { + 'Users' { [PSCustomObject]@{ id = 10; username = 'zz-test-jnino' } } + 'Apps' { [PSCustomObject]@{ id = 50; name = 'ZZ-TEST-Expenses Web' } } + 'Roles' { [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-All Staff' } } + 'Groups' { [PSCustomObject]@{ id = 2; name = 'ZZ-TEST-Seattle HQ' } } + 'Policies' { [PSCustomObject]@{ id = 5; name = 'ZZ-TEST-Strict Office' } } + 'Mappings' { [PSCustomObject]@{ id = 3; name = 'ZZ-TEST-Finance' } } + 'AppRules' { [PSCustomObject]@{ id = 6; name = 'ZZ-TEST-Directory groups'; AppId = '50' } } + 'Hooks' { [PSCustomObject]@{ id = 'h1'; name = 'pre-authentication hook h1' } } + 'ApiAuthorizations' { [PSCustomObject]@{ id = 7; name = 'ZZ-TEST-Orders API' } } + 'SelfRegistration' { [PSCustomObject]@{ id = 8; name = 'ZZ-TEST-Partner Sign-up' } } + 'Attributes' { [PSCustomObject]@{ id = 4; shortname = 'zztest_seed_tag' } } + } + } + Mock Invoke-OneLoginRequest { throw "Escaped the mocks: $Method $Path" } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'DELETE' } { $script:Events.Add("delete $Path") } + } + } + + Context 'WhatIf wins over Force' { + + It 'deletes nothing with -Force -WhatIf, and reports nothing removed' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -WhatIf -PassThru + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'DELETE' } + $result.TotalRemoved | Should-Be 0 + } + } + + It 'previews without -Force rather than treating the preview as a refusal' { + InModuleScope TestEnvironment { + Mock Confirm-TestTeardown { throw 'A preview must not ask.' } + $result = Remove-OneLoginEnvironment -WhatIf -PassThru + $result.Cancelled | Should-BeFalse + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'DELETE' } + } + } + } + + Context 'A refusal actually stops the run' { + + It 'deletes and proves nothing when the question is refused or cannot be asked' { + InModuleScope TestEnvironment { + Mock Confirm-TestTeardown { $false } + $result = Remove-OneLoginEnvironment -PassThru + $result.Cancelled | Should-BeTrue + $result.TotalRemoved | Should-Be 0 + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'DELETE' } + Should-NotInvoke Get-OneLoginSeededObject + } + } + } + + Context 'Force removes, proving first' { + + It 'proves every type before deleting anything, then deletes in the order the proofs need' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -PassThru + # Ten, not eleven: the app rule goes with its app rather than on its own. + $result.TotalRemoved | Should-Be 10 + + $firstDelete = [array]::FindIndex($script:Events.ToArray(), [Predicate[string]] { param($e) $e.StartsWith('delete') }) + $lastProve = [array]::FindLastIndex($script:Events.ToArray(), [Predicate[string]] { param($e) $e.StartsWith('prove') }) + $lastProve | Should-BeLessThan $firstDelete + + @($script:Events | Where-Object { $_.StartsWith('delete') }) | Should-BeCollection @( + 'delete self_registration_profiles/8', 'delete hooks/h1', 'delete mappings/3', 'delete api_authorizations/7', + 'delete roles/1', 'delete groups/2', 'delete policies/5', 'delete apps/50', 'delete users/10', 'delete users/custom_attributes/4') + } + } + + It 'proves roles against the users and apps it found, and policies, mappings, rules and hooks against those' { + InModuleScope TestEnvironment { + $null = Remove-OneLoginEnvironment -Force + Should-Invoke Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Roles' -and -not $Unproven -and @($OwnedUserId) -contains '10' -and @($OwnedAppId) -contains '50' } + foreach ($type in 'Mappings', 'Hooks', 'AppRules') { + Should-Invoke Get-OneLoginSeededObject -ParameterFilter { $Type -eq $type -and -not $Unproven -and @($OwnedRoleId) -contains '1' } + } + Should-Invoke Get-OneLoginSeededObject -ParameterFilter { $Type -eq 'Policies' -and -not $Unproven -and @($OwnedGroupId) -contains '2' } + } + } + + It 'names what it left alone, with the reason' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -PassThru + @($result.LeftAlone).Count | Should-Be 1 + $result.LeftAlone[0].Name | Should-Be 'ZZ-TEST-Someone else' + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Path -eq 'roles/99' } + } + } + } + + Context 'Keep keeps what the kept type is proved by' { + + It 'keeps the fields with the users, because the tag in one is their only proof' { + InModuleScope TestEnvironment { + $result = Remove-OneLoginEnvironment -Force -Keep Users -PassThru + @($script:Events | Where-Object { $_ -like 'delete users*' }) | Should-BeCollection -Count 0 + @($result.Errors | Where-Object { $_ -like 'Also kept*Attributes*' }) | Should-BeCollection -Count 1 + @($script:Events | Where-Object { $_ -like 'delete roles/*' }) | Should-BeCollection -Count 1 + } + } + + It 'keeps the people, apps and fields with the roles, and the groups, people and fields with the policies' { + InModuleScope TestEnvironment { + $null = Remove-OneLoginEnvironment -Force -Keep Roles + @($script:Events | Where-Object { $_ -like 'delete users*' -or $_ -eq 'delete apps/50' -or $_ -like 'delete roles/*' }) | Should-BeCollection -Count 0 + @($script:Events | Where-Object { $_ -like 'delete groups/*' }) | Should-BeCollection -Count 1 + # The app stays, so its rule, which is not kept, is deleted on its own. + @($script:Events | Where-Object { $_ -eq 'delete apps/50/rules/6' }) | Should-BeCollection -Count 1 + + $script:Events.Clear() + $null = Remove-OneLoginEnvironment -Force -Keep Policies + @($script:Events | Where-Object { $_ -like 'delete policies/*' -or $_ -like 'delete groups/*' -or $_ -like 'delete users*' }) | Should-BeCollection -Count 0 + @($script:Events | Where-Object { $_ -like 'delete roles/*' }) | Should-BeCollection -Count 1 + } + } + + It 'keeps the roles with the mappings and the hook, and deletes an app rule on its own when its app stays' { + InModuleScope TestEnvironment { + $null = Remove-OneLoginEnvironment -Force -Keep Hooks + @($script:Events | Where-Object { $_ -like 'delete roles/*' -or $_ -like 'delete hooks/*' }) | Should-BeCollection -Count 0 + + $script:Events.Clear() + $null = Remove-OneLoginEnvironment -Force -Keep Apps + @($script:Events | Where-Object { $_ -eq 'delete apps/50/rules/6' }) | Should-BeCollection -Count 1 + @($script:Events | Where-Object { $_ -eq 'delete apps/50' }) | Should-BeCollection -Count 0 + } + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/SeedData.Tests.ps1 b/Tests/Unit/Providers/OneLogin/SeedData.Tests.ps1 new file mode 100644 index 0000000..7e75a05 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/SeedData.Tests.ps1 @@ -0,0 +1,291 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Contract tests for the OneLogin provider's seed data. + + A shifted CSV column is a data defect, not a logic one, and it is silent. More than that, most + of what is pinned here is something OneLogin itself does without an error, each verified against + a live trial account before it was written down: + + - it accepts a role grant for anyone and keeps it only for an approved person whose status is + Active, Suspended, Locked, PasswordExpired or AwaitingPasswordReset; + - it keeps a rejected person out of a group as it keeps her out of a role; + - it approves a person only while the account has a licence for them, and a trial has twelve; + - it turns Unactivated into PasswordPending and Unapproved into Approved, and it locks only a + licensed person; + - it allows a trial five roles, the Default role among them, and five apps. + + Data that asked for any of those would seed without an error and then fail verification, so the + data is held to them here, at commit time. So is the isolation the provider promises: nothing + in the data can name a real directory identity, a group outside the seed, or a real host. +#> + +BeforeAll { + $script:ModuleRoot = (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)))) + $script:DataPath = Join-Path $script:ModuleRoot 'Providers\OneLogin\Data' + + $read = { param($name) @(Import-Csv -LiteralPath (Join-Path $script:DataPath "$name.csv") -Encoding UTF8) } + $script:Attributes = @(& $read 'OneLoginCustomAttributes') + $script:Roles = @(& $read 'OneLoginRoles') + $script:Groups = @(& $read 'OneLoginGroups') + $script:Apps = @(& $read 'OneLoginApps') + $script:Mappings = @(& $read 'OneLoginMappings') + $script:Users = @(& $read 'OneLoginUsers') + $script:Policies = @(& $read 'OneLoginPolicies') + $script:ApiAuthorizations = @(& $read 'OneLoginApiAuthorizations') + $script:AppRules = @(& $read 'OneLoginAppRules') + $script:Hooks = @(& $read 'OneLoginHooks') + $script:SelfRegistrations = @(& $read 'OneLoginSelfRegistrations') + + $script:Split = { param($value) @(([string]$value -split ';') | Where-Object { $_ }) } + $script:CanHoldRole = { param($user) $user.State -eq 'Approved' -and $user.Status -in 'Active', 'Suspended', 'Locked', 'PasswordExpired', 'AwaitingPasswordReset' } +} + +Describe 'OneLogin seed data' -Tag 'Unit', 'Contract' { + + Context 'Shape' { + + It 'ships every file with rows, and a purpose and tier on every row' { + foreach ($set in $script:Attributes, $script:Roles, $script:Groups, $script:Apps, $script:Mappings, $script:Users, + $script:Policies, $script:ApiAuthorizations, $script:AppRules, $script:Hooks, $script:SelfRegistrations) { + @($set).Count | Should-BeGreaterThan 0 + @($set | Where-Object { -not $_.Purpose -or $_.Tier -notin 'Core', 'Bulk' }) | Should-BeCollection -Count 0 + } + } + + It 'has unique keys in every keyed file' { + foreach ($pair in @( + @{ Rows = $script:Users; Key = 'Key' }, @{ Rows = $script:Roles; Key = 'Key' }, @{ Rows = $script:Groups; Key = 'Key' } + @{ Rows = $script:Apps; Key = 'Key' }, @{ Rows = $script:Mappings; Key = 'Key' }, @{ Rows = $script:Attributes; Key = 'Shortname' } + @{ Rows = $script:Policies; Key = 'Key' }, @{ Rows = $script:ApiAuthorizations; Key = 'Key' }, @{ Rows = $script:AppRules; Key = 'Key' } + @{ Rows = $script:Hooks; Key = 'Key' }, @{ Rows = $script:SelfRegistrations; Key = 'Key' } + )) { + $keys = @($pair.Rows.($pair.Key)) + @($keys | Sort-Object -Unique).Count | Should-Be $keys.Count + } + } + + It 'keeps every username key plain ASCII, whatever the name' { + @($script:Users | Where-Object { $_.Key -notmatch '^[a-z0-9._-]+$' }) | Should-BeCollection -Count 0 + } + } + + Context 'Ownership and isolation' { + + It 'declares the field that carries the seed tag, and gives every field the attribute prefix' { + # A field has no description, so the shortname is the only thing about it teardown can + # check. Letters, digits and underscores only: OneLogin refuses a dash. + @($script:Attributes | Where-Object Shortname -ceq 'zztest_seed_tag') | Should-BeCollection -Count 1 + @($script:Attributes | Where-Object { $_.Shortname -cnotmatch '^zztest_[a-z0-9_]+$' }) | Should-BeCollection -Count 0 + } + + It 'gives every role and every group a Core member who can hold it, so each can be proved at teardown' { + $core = @($script:Users | Where-Object Tier -eq 'Core') + foreach ($role in $script:Roles) { + @($core | Where-Object { (& $script:Split $_.Roles) -contains $role.Key -and (& $script:CanHoldRole $_) }).Count | + Should-BeGreaterThan 0 -Because "role $($role.Key) would otherwise be empty, and an empty role cannot be proved" + } + foreach ($group in $script:Groups) { + @($core | Where-Object Group -eq $group.Key).Count | Should-BeGreaterThan 0 -Because "group $($group.Key) would otherwise be empty" + } + } + + It 'attaches every policy to seeded groups that have a Core member, so each policy can be proved' { + $core = @($script:Users | Where-Object Tier -eq 'Core' | ForEach-Object Group | Where-Object { $_ }) + foreach ($policy in $script:Policies) { + $groups = @(& $script:Split $policy.Groups) + $groups.Count | Should-BeGreaterThan 0 + @($groups | Where-Object { @($script:Groups.Key) -notcontains $_ }) | Should-BeCollection -Count 0 + @($groups | Where-Object { $core -contains $_ }).Count | Should-BeGreaterThan 0 + } + # One policy per group: a group holds one policy, and two claiming it would fight. + $claimed = @($script:Policies | ForEach-Object { & $script:Split $_.Groups }) + @($claimed | Sort-Object -Unique).Count | Should-Be $claimed.Count + } + + It 'has no column that could give a role an administrator, a policy default status, a hook an enabled flag or a sign-up profile a default role' { + # Each of those is a way out of the seed into the account, and none has a column. + $script:Roles[0].PSObject.Properties.Name | Should-NotContainCollection 'Admins' + $script:Policies[0].PSObject.Properties.Name | Should-NotContainCollection 'Default' + $script:Hooks[0].PSObject.Properties.Name | Should-NotContainCollection 'Enabled' + $script:SelfRegistrations[0].PSObject.Properties.Name | Should-NotContainCollection 'Enabled' + $script:SelfRegistrations[0].PSObject.Properties.Name | Should-NotContainCollection 'DefaultRole' + $script:SelfRegistrations[0].PSObject.Properties.Name | Should-NotContainCollection 'DefaultGroup' + } + + It 'gives every person a distinct AD user name within twenty characters and a distinct employee id' { + # Both are written behind the seed prefix, which keeps them from naming a real AD account + # or employee; they also have to stay distinct once cut to AD's twenty characters. + $sam = @($script:Users | ForEach-Object { $s = 'zz-test-' + $_.Key; if ($s.Length -gt 20) { $s.Substring(0, 20) } else { $s } }) + @($sam | Sort-Object -Unique).Count | Should-Be $sam.Count + @($script:Users | Where-Object { -not $_.EmployeeId }) | Should-BeCollection -Count 0 + @($script:Users.EmployeeId | Sort-Object -Unique).Count | Should-Be $script:Users.Count + } + + It 'uses only phone numbers from the range reserved for fiction, so none can reach a real phone' { + @($script:Users | Where-Object { -not $_.Phone -or $_.Phone -notmatch '\) 555-01\d\d$' }) | Should-BeCollection -Count 0 + } + } + + Context 'What OneLogin keeps' { + + It 'uses only statuses and states that stay put' { + @($script:Users | Where-Object { $_.Status -notin 'Active', 'Suspended', 'Locked', 'PasswordExpired', 'AwaitingPasswordReset', 'PasswordPending' }) | + Should-BeCollection -Count 0 + @($script:Users | Where-Object { $_.State -notin 'Approved', 'Rejected', 'Unlicensed' }) | Should-BeCollection -Count 0 + } + + It 'locks only licensed people, because OneLogin refuses to lock anybody else' { + @($script:Users | Where-Object Status -eq 'Locked').Count | Should-BeGreaterThan 0 + @($script:Users | Where-Object { $_.Status -eq 'Locked' -and $_.State -ne 'Approved' }) | Should-BeCollection -Count 0 + } + + It 'approves at most ten people, so a trial''s twelve licences cover them with the owner and one to spare' { + @($script:Users | Where-Object State -eq 'Approved').Count | Should-BeLessThanOrEqual 10 + } + + It 'gives roles only to people OneLogin lets hold one' { + @($script:Users | Where-Object { $_.Roles -and -not (& $script:CanHoldRole $_) } | ForEach-Object Key) | Should-BeCollection -Count 0 + } + + It 'puts a rejected person in no group' { + @($script:Users | Where-Object { $_.State -eq 'Rejected' -and $_.Group }) | Should-BeCollection -Count 0 + } + + It 'makes every Bulk person unlicensed and roleless, so a seed spends no licence' { + @($script:Users | Where-Object { $_.Tier -eq 'Bulk' -and ($_.State -ne 'Unlicensed' -or $_.Roles) }) | Should-BeCollection -Count 0 + @($script:Users | Where-Object Tier -eq 'Bulk').Count | Should-BeGreaterThan 250 + } + + It 'fits a trial: four roles beside Default, and five apps' { + $script:Roles.Count | Should-BeLessThanOrEqual 4 + $script:Apps.Count | Should-BeLessThanOrEqual 5 + } + + It 'asks for an MFA factor only on people who hold a licence, and only one OneLogin enrols pre-verified' { + @($script:Users | Where-Object { $_.Mfa -and $_.State -ne 'Approved' }) | Should-BeCollection -Count 0 + @($script:Users | Where-Object { $_.Mfa -and $_.Mfa -notin 'Email', 'SMS', 'Voice' }) | Should-BeCollection -Count 0 + } + + It 'asks only for locales OneLogin accepts' { + # Verified live: tr-TR, ja, de and en are accepted; tr_TR is refused. + @($script:Users | Where-Object { $_.Locale -and $_.Locale -notin 'tr-TR', 'ja', 'de', 'en' }) | Should-BeCollection -Count 0 + } + + It 'names only connectors and token methods the provider knows' { + @($script:Apps | Where-Object { $_.Connector -notin 'OIDC', 'SAML' }) | Should-BeCollection -Count 0 + @($script:Apps | Where-Object { $_.Connector -eq 'OIDC' -and ($_.TokenAuth -notin 'Basic', 'Post', 'None' -or $_.AppType -notin 'Web', 'Native' -or -not $_.RedirectUri) }) | + Should-BeCollection -Count 0 + @($script:Apps | Where-Object { $_.Connector -eq 'SAML' -and (-not $_.Audience -or -not $_.ConsumerUrl) }) | Should-BeCollection -Count 0 + } + + It 'writes every URL against the connection''s domain or a custom scheme, never a real host' { + $urls = @($script:Apps | ForEach-Object { $_.LoginUrl; $_.RedirectUri; $_.Audience; $_.ConsumerUrl } | Where-Object { $_ -like 'http*' }) + $urls.Count | Should-BeGreaterThan 0 + @($urls | Where-Object { $_ -notlike 'https://*.{domain}/*' }) | Should-BeCollection -Count 0 + # An API's identifier is a path the provider puts under the lab domain; a full URL here + # would name some other host. + @($script:ApiAuthorizations | Where-Object { $_.Path -match '[:/.]' }) | Should-BeCollection -Count 0 + } + } + + Context 'References resolve' { + + It 'names only roles and groups that exist' { + $roleKeys = @($script:Roles.Key); $groupKeys = @($script:Groups.Key) + $bad = foreach ($user in $script:Users) { + foreach ($role in (& $script:Split $user.Roles)) { if ($roleKeys -notcontains $role) { "$($user.Key)->$role" } } + if ($user.Group -and $groupKeys -notcontains $user.Group) { "$($user.Key)->$($user.Group)" } + } + foreach ($app in $script:Apps) { foreach ($role in (& $script:Split $app.Roles)) { if ($roleKeys -notcontains $role) { "$($app.Key)->$role" } } } + foreach ($row in @($script:Mappings) + @($script:AppRules) + @($script:Hooks)) { if ($roleKeys -notcontains $row.Role) { "$($row.Key)->$($row.Role)" } } + @($bad) | Should-BeCollection -Count 0 + } + + It 'puts app rules only on OIDC apps, with an operator OneLogin knows' { + # set_groups is the OIDC connector's groups claim; a SAML app has no such action. + $oidc = @($script:Apps | Where-Object Connector -eq 'OIDC' | ForEach-Object Key) + @($script:AppRules | Where-Object { $oidc -notcontains $_.App }) | Should-BeCollection -Count 0 + @($script:AppRules | Where-Object { $_.Operator -notin 'ri', 'rin' -or -not $_.Expression }) | Should-BeCollection -Count 0 + } + + It 'grants API clients only scopes the server declares, to apps that exist' { + $appKeys = @($script:Apps.Key) + $bad = foreach ($server in $script:ApiAuthorizations) { + $declared = @(([string]$server.Scopes -split '\|') | Where-Object { $_ } | ForEach-Object { ($_ -split '=', 2)[0] }) + foreach ($client in @(([string]$server.Clients -split '\|') | Where-Object { $_ })) { + $appKey, $scopes = $client -split '=', 2 + if ($appKeys -notcontains $appKey) { "$($server.Key)->$appKey" } + foreach ($scope in @(([string]$scopes -split ' ') | Where-Object { $_ })) { if ($declared -notcontains $scope) { '{0}:{1}:{2}' -f $server.Key, $appKey, $scope } } + } + } + @($bad) | Should-BeCollection -Count 0 + } + + It 'names only seeded managers, each listed before the people who report to them' { + $position = @{} + for ($i = 0; $i -lt $script:Users.Count; $i++) { $position[$script:Users[$i].Key] = $i } + @($script:Users | Where-Object { $_.Manager -and -not $position.ContainsKey($_.Manager) }) | Should-BeCollection -Count 0 + @($script:Users | Where-Object { $_.Manager -and $position[$_.Manager] -gt $position[$_.Key] } | ForEach-Object Key) | Should-BeCollection -Count 0 + } + + It 'gives Core people Core managers only, so a Core seed is complete in itself' { + $core = @{}; foreach ($user in ($script:Users | Where-Object Tier -eq 'Core')) { $core[$user.Key] = $true } + @($script:Users | Where-Object { $_.Tier -eq 'Core' -and $_.Manager -and -not $core.ContainsKey($_.Manager) }) | Should-BeCollection -Count 0 + } + + It 'writes every mapping condition as source|operator|value, and none that names the seed tag itself' { + # The provider adds the seed-tag condition to every mapping; the data never carries it, + # so there is no way to write a mapping whose gate differs from the one the code writes. + foreach ($mapping in $script:Mappings) { + foreach ($condition in (& $script:Split $mapping.Conditions)) { + @($condition -split '\|').Count | Should-Be 3 + $condition | Should-NotMatchString 'zztest_seed_tag' + } + } + } + } + + Context 'The awkward shapes the provider exists to seed' { + + It 'has a suspended manager with reports, a locked person holding a role, and one enabled mapping and one disabled' { + $suspended = @($script:Users | Where-Object Status -eq 'Suspended' | Where-Object Tier -eq 'Core').Key + @($script:Users | Where-Object { $suspended -contains $_.Manager }).Count | Should-BeGreaterThan 0 + @($script:Users | Where-Object { $_.Status -eq 'Locked' -and $_.Roles }).Count | Should-BeGreaterThan 0 + @($script:Mappings | Where-Object Enabled -eq 'TRUE').Count | Should-Be 1 + @($script:Mappings | Where-Object Enabled -eq 'FALSE').Count | Should-Be 1 + } + + It 'has a public client, a native client, a SAML app, an app granted to no role, and a dormant app rule' { + @($script:Apps | Where-Object TokenAuth -eq 'None').Count | Should-BeGreaterThan 0 + @($script:Apps | Where-Object AppType -eq 'Native').Count | Should-BeGreaterThan 0 + @($script:Apps | Where-Object Connector -eq 'SAML').Count | Should-BeGreaterThan 0 + @($script:Apps | Where-Object { -not $_.Roles }).Count | Should-BeGreaterThan 0 + @($script:AppRules | Where-Object Enabled -eq 'FALSE').Count | Should-BeGreaterThan 0 + } + + It 'has a group with no policy of its own, and an API scope granted to no client' { + $governed = @($script:Policies | ForEach-Object { & $script:Split $_.Groups }) + @($script:Groups | Where-Object { $governed -notcontains $_.Key }).Count | Should-BeGreaterThan 0 + $unused = foreach ($server in $script:ApiAuthorizations) { + $granted = @(([string]$server.Clients -split '\|') | Where-Object { $_ } | ForEach-Object { (($_ -split '=', 2)[1] -split ' ') }) + @(([string]$server.Scopes -split '\|') | Where-Object { $_ } | ForEach-Object { ($_ -split '=', 2)[0] } | Where-Object { $granted -notcontains $_ }) + } + @($unused).Count | Should-BeGreaterThan 0 + } + + It 'covers writing systems beyond Latin, in the names and in the directory display names' { + foreach ($block in '\p{IsCJKUnifiedIdeographs}', '\p{IsCyrillic}', '\p{IsGreekandCoptic}', '\p{IsArabic}', '\p{IsDevanagari}') { + @($script:Users | Where-Object { "$($_.GivenName) $($_.Surname)" -match $block }).Count | Should-BeGreaterThan 0 + } + # The ideographic space survives into the display name, which becomes a DN's common name. + ($script:Users | Where-Object Key -eq 'jjiang').DisplayName.IndexOf([char]0x3000) | Should-BeGreaterThan 0 + } + + It 'keeps the decomposed name decomposed' { + # Checked on the codepoint, not with -eq, which calls the two forms equal. + ($script:Users | Where-Object Key -eq 'jmarchetti').GivenName.IndexOf([char]0x0301) | Should-BeGreaterThan 0 + ($script:Users | Where-Object Key -eq 'jnino').GivenName.IndexOf([char]0x0301) | Should-Be (-1) + } + } +} diff --git a/Tests/Unit/Providers/OneLogin/Test-OneLoginEnvironment.Tests.ps1 b/Tests/Unit/Providers/OneLogin/Test-OneLoginEnvironment.Tests.ps1 new file mode 100644 index 0000000..fbb2ab3 --- /dev/null +++ b/Tests/Unit/Providers/OneLogin/Test-OneLoginEnvironment.Tests.ps1 @@ -0,0 +1,220 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.1.0' } + +<# + Verification is only worth having if it agrees with the seed about what a seeded object is + called and disagrees with the account when something came back wrong. So the account here is + built from the module's own seed files by the rules the seed applies - names through + Resolve-OneLoginSeedName, lifecycle through the provider's status and state tables, managers and + groups by id, roles holding their people and apps, directory fields through + Resolve-OneLoginDirectoryIdentity, policies on their groups, API servers answering with the + scopes, claims and clients the data lists - and the verifier must pass against it. Then one + thing at a time is broken, and each must be named: a missing person, a first name that came + back decomposed, a person OneLogin quietly left unlicensed, a manager pointing at the wrong + person, a group member gone, a role grant dropped, a policy setting changed, a policy moved off + its group, an API granted to an app the data never names, a directory field edited, a lock run + out. A person a mapping added to a role is not a fault, and is pinned as not one. + + Everything is mocked. The account is never reached. +#> + +BeforeAll { + $moduleRoot = (Split-Path -Path (Split-Path -Path (Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent) -Parent) -Parent) + if (-not (Get-Module TestEnvironment)) { Import-Module (Join-Path $moduleRoot 'TestEnvironment.psd1') } + $script:DataPath = Join-Path $moduleRoot 'Providers\OneLogin\Data' +} + +Describe 'Test-OneLoginEnvironment' -Tag 'Unit', 'Public' { + + BeforeEach { + InModuleScope TestEnvironment -Parameters @{ DataPath = $script:DataPath } { + param($DataPath) + Mock Write-TestMessage { } + Mock Get-OneLoginConnection { @{ Subdomain = 'contoso'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } } + Mock Resolve-OneLoginSeedName { + switch ($Kind) { + 'Username' { ('ZZ-TEST-{0}' -f $Key).ToLowerInvariant() } + 'Email' { ('ZZ-TEST-{0}@onelogin-lab.example.com' -f $Key).ToLowerInvariant() } + default { 'ZZ-TEST-{0}' -f $Key } + } + } + + $data = $DataPath + $read = { param($file) @(Import-Csv -LiteralPath (Join-Path $data $file) -Encoding UTF8) } + $userRows = @(& $read 'OneLoginUsers.csv') + $roleRows = @(& $read 'OneLoginRoles.csv') + $groupRows = @(& $read 'OneLoginGroups.csv') + $appRows = @(& $read 'OneLoginApps.csv') + + $idOf = @{} + $i = 1000 + foreach ($row in $userRows) { $i++; $idOf[$row.Key] = $i } + $groupId = @{}; $n = 0 + foreach ($row in $groupRows) { $n++; $groupId[$row.Key] = 500 + $n } + $appId = @{}; $n = 0 + foreach ($row in $appRows) { $n++; $appId[$row.Key] = 700 + $n } + + $roleDataName = @{}; foreach ($row in $roleRows) { $roleDataName[$row.Key] = $row.Name } + $groupDataName = @{}; foreach ($row in $groupRows) { $groupDataName[$row.Key] = $row.Name } + $connection = @{ Subdomain = 'contoso'; Prefix = 'ZZ-TEST-'; EmailDomain = 'onelogin-lab.example.com' } + + $users = [System.Collections.Generic.List[object]]::new() + foreach ($row in $userRows) { + $user = [PSCustomObject]@{ + id = $idOf[$row.Key] + username = ('zz-test-{0}' -f $row.Key) + firstname = $row.GivenName + lastname = $row.Surname + status = $script:OneLoginUserStatus[$row.Status] + state = $script:OneLoginUserState[$row.State] + group_id = $(if ($row.Group) { $groupId[$row.Group] } else { $null }) + manager_user_id = $(if ($row.Manager) { $idOf[$row.Manager] } else { $null }) + locked_until = $(if ($row.Status -eq 'Locked') { (Get-Date).AddDays(364).ToUniversalTime().ToString('o') } else { $null }) + } + $directory = Resolve-OneLoginDirectoryIdentity -Row $row -RoleNameByKey $roleDataName -GroupNameByKey $groupDataName -Connection $connection + foreach ($name in $directory.Keys) { $user | Add-Member -NotePropertyName $name -NotePropertyValue $directory[$name] } + $users.Add($user) + } + $roles = [System.Collections.Generic.List[object]]::new() + $n = 0 + foreach ($row in $roleRows) { + $n++ + $members = [System.Collections.Generic.List[object]]@($userRows | Where-Object { @($_.Roles -split ';') -contains $row.Key } | ForEach-Object { $idOf[$_.Key] }) + $grants = @($appRows | Where-Object { @($_.Roles -split ';') -contains $row.Key } | ForEach-Object { $appId[$_.Key] }) + $roles.Add([PSCustomObject]@{ id = 300 + $n; name = 'ZZ-TEST-{0}' -f $row.Name; users = $members; apps = $grants; Key = $row.Key }) + } + + # Policies, each on the groups the data gives it, with the data's settings as its detail. + $policyRows = @(& $read 'OneLoginPolicies.csv') + $policyOfGroup = @{} + $script:PolicyDetail = @{} + $n = 0 + $policies = foreach ($row in $policyRows) { + $n++ + foreach ($key in @($row.Groups -split ';' | Where-Object { $_ })) { $policyOfGroup[$key] = 800 + $n } + $script:PolicyDetail[[string](800 + $n)] = [PSCustomObject]@{ + id = 800 + $n; minimum_password_length = [int]$row.MinimumPasswordLength; password_expiration_days = [int]$row.PasswordExpirationDays + passwords_remembered = [int]$row.PasswordsRemembered; maximum_invalid_login_attempts = [int]$row.MaximumInvalidLoginAttempts + lock_effective_minutes = [int]$row.LockEffectiveMinutes + } + [PSCustomObject]@{ id = 800 + $n; name = 'ZZ-TEST-{0}' -f $row.Name } + } + + # API servers answering with exactly the scopes, claims and clients the data lists. + $script:ApiPart = @{} + $n = 0 + $apiServers = foreach ($row in (& $read 'OneLoginApiAuthorizations.csv')) { + $n++ + $id = 900 + $n + $script:ApiPart["$id/scopes"] = @(@($row.Scopes -split '\|' | Where-Object { $_ }) | ForEach-Object { [PSCustomObject]@{ value = ($_ -split '=', 2)[0] } }) + $script:ApiPart["$id/claims"] = @(@($row.Claims -split '\|' | Where-Object { $_ }) | ForEach-Object { [PSCustomObject]@{ name = ($_ -split '=', 2)[0] } }) + $script:ApiPart["$id/clients"] = @(@($row.Clients -split '\|' | Where-Object { $_ }) | ForEach-Object { + $appKey, $scopeList = $_ -split '=', 2 + [PSCustomObject]@{ app_id = $appId[$appKey]; scopes = @(@($scopeList -split ' ' | Where-Object { $_ }) | ForEach-Object { [PSCustomObject]@{ value = $_ } }) } + }) + [PSCustomObject]@{ id = $id; name = 'ZZ-TEST-{0}' -f $row.Name } + } + + $n = 0 + $script:Fixture = @{ + Attributes = @(& $read 'OneLoginCustomAttributes.csv' | ForEach-Object { [PSCustomObject]@{ id = 1; shortname = $_.Shortname } }) + Users = $users + Roles = $roles + Groups = @($groupRows | ForEach-Object { [PSCustomObject]@{ id = $groupId[$_.Key]; name = 'ZZ-TEST-{0}' -f $_.Name; policy_id = $policyOfGroup[$_.Key] } }) + Apps = @($appRows | ForEach-Object { [PSCustomObject]@{ id = $appId[$_.Key]; name = 'ZZ-TEST-{0}' -f $_.Name } }) + Mappings = @(& $read 'OneLoginMappings.csv' | ForEach-Object { [PSCustomObject]@{ id = 1; name = 'ZZ-TEST-{0}' -f $_.Name } }) + Policies = @($policies) + ApiAuthorizations = @($apiServers) + AppRules = @(& $read 'OneLoginAppRules.csv' | ForEach-Object { $n++; [PSCustomObject]@{ id = 1100 + $n; name = 'ZZ-TEST-{0}' -f $_.Name; AppId = $appId[$_.App] } }) + Hooks = @(& $read 'OneLoginHooks.csv' | ForEach-Object { [PSCustomObject]@{ id = "hook-$($_.Key)"; type = $_.Type; disabled = $true } }) + SelfRegistration = @(& $read 'OneLoginSelfRegistrations.csv' | ForEach-Object { [PSCustomObject]@{ id = 1200; name = 'ZZ-TEST-{0}' -f $_.Name } }) + } + $script:IdOf = $idOf + $script:AppId = $appId + + Mock Get-OneLoginSeededObject { @($script:Fixture[$Type]) } + Mock Invoke-OneLoginRequest { throw "Unexpected request $Method $Path" } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -match '^policies/\d+$' } { $script:PolicyDetail[($Path -split '/')[1]] } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -match '^api_authorizations/\d+/(scopes|claims|clients)$' } { $script:ApiPart[$Path.Substring('api_authorizations/'.Length)] } + Mock Invoke-OneLoginRequest -ParameterFilter { $Method -eq 'GET' -and $Path -match '^mfa/users/\d+/devices$' } { } + } + } + + It 'passes against an account that holds exactly what the data describes, and only reads' { + InModuleScope TestEnvironment { + $result = Test-OneLoginEnvironment -Quiet + $result.Provider | Should-Be 'OneLogin' + $result.Target | Should-Be 'contoso' + @($result.Checks | Where-Object { $_.Passed -eq $false } | ForEach-Object Name) | Should-BeCollection -Count 0 + $result.Passed | Should-BeTrue + ($result.Checks | Where-Object Name -eq 'Managers').Expected | Should-BeGreaterThan 250 + foreach ($name in 'Policies', 'API authorizations', 'App rules', 'Smart hooks', 'Self-registration', 'Group policies', 'Policy settings', 'API scopes', 'API clients', 'Directory fields') { + @($result.Checks | Where-Object Name -eq $name) | Should-BeCollection -Count 1 -Because "the verifier judges $name" + } + Should-NotInvoke Invoke-OneLoginRequest -ParameterFilter { $Method -ne 'GET' } + } + } + + It 'names a changed policy setting, a policy moved off its group, a stray API client, an edited directory field and a lock run out' { + InModuleScope TestEnvironment { + $strict = $script:Fixture.Policies | Where-Object name -eq 'ZZ-TEST-Strict Office' + $script:PolicyDetail[[string]$strict.id].minimum_password_length = 8 + ($script:Fixture.Groups | Where-Object name -eq 'ZZ-TEST-New York').policy_id = $null + $orders = $script:Fixture.ApiAuthorizations | Where-Object name -eq 'ZZ-TEST-Orders API' + $script:ApiPart["$($orders.id)/clients"] = @($script:ApiPart["$($orders.id)/clients"]) + [PSCustomObject]@{ app_id = $script:AppId['wiki-saml']; scopes = @([PSCustomObject]@{ value = 'orders:admin' }) } + ($script:Fixture.Users | Where-Object username -eq 'zz-test-jnino').samaccountname = 'jnino' + ($script:Fixture.Users | Where-Object username -eq 'zz-test-ofitzgerald').locked_until = (Get-Date).AddHours(2).ToUniversalTime().ToString('o') + + $checks = (Test-OneLoginEnvironment -SkipMembership -Quiet).Checks + @(($checks | Where-Object Name -eq 'Policy settings').Missing) | Should-BeCollection @('strict-office: minimum_password_length is 8, should be 14') + @(($checks | Where-Object Name -eq 'Group policies').Missing) | Should-BeCollection @('ZZ-TEST-New York <- ZZ-TEST-Strict Office') + @(($checks | Where-Object Name -eq 'API clients').Unexpected) | Should-BeCollection @('ZZ-TEST-Orders API <- ZZ-TEST-Wiki SAML : orders:admin') + @(($checks | Where-Object Name -eq 'Directory fields').Missing) | Should-BeCollection @("jnino: samaccountname 'jnino' should be 'zz-test-jnino'") + @(($checks | Where-Object Name -eq 'User lifecycle').Missing)[0] | Should-MatchString '^ofitzgerald: locked until .*less than a day away$' + } + } + + It 'names a missing person, a decomposed first name, an unlicensed one and a wrong manager' { + InModuleScope TestEnvironment { + $users = $script:Fixture.Users + $null = $users.Remove(($users | Where-Object username -eq 'zz-test-svcreporting')) + $jose = $users | Where-Object username -eq 'zz-test-jnino' + $decomposed = 'Jose' + [string][char]0x0301 + ($decomposed -eq $jose.firstname) | Should-BeTrue + $jose.firstname = $decomposed + ($users | Where-Object username -eq 'zz-test-zmueller').state = 3 + ($users | Where-Object username -eq 'zz-test-jweiss').manager_user_id = $script:IdOf['awhitfield'] + + $checks = (Test-OneLoginEnvironment -Quiet).Checks + @(($checks | Where-Object Name -eq 'Users').Missing) | Should-BeCollection @('zz-test-svcreporting') + @(($checks | Where-Object Name -eq 'User names').Missing) | Should-BeCollection @("jnino: first name 'Jose$([char]0x0301)' should be 'Jos$([char]0xE9)'") + @(($checks | Where-Object Name -eq 'User lifecycle').Missing) | Should-BeCollection @('zmueller: state 3 should be 1 (Approved)') + @(($checks | Where-Object Name -eq 'Managers').Missing) | Should-BeCollection @('jweiss: manager is zz-test-awhitfield, should be zz-test-mbell') + } + } + + It 'names a group member gone and a role grant dropped, and forgives a person a mapping added' { + InModuleScope TestEnvironment { + ($script:Fixture.Users | Where-Object username -eq 'zz-test-iisik').group_id = $null + $allStaff = $script:Fixture.Roles | Where-Object Key -eq 'all-staff' + $null = $allStaff.users.Remove($script:IdOf['nsorensen']) + $finance = $script:Fixture.Roles | Where-Object Key -eq 'finance' + $finance.users.Add($script:IdOf['gpapadopoulos']) + + $checks = (Test-OneLoginEnvironment -Quiet).Checks + @(($checks | Where-Object Name -eq 'Group membership').Missing) | Should-BeCollection @('ZZ-TEST-London <- zz-test-iisik') + $roleCheck = $checks | Where-Object Name -eq 'Role memberships' + @($roleCheck.Missing) | Should-BeCollection @('ZZ-TEST-All Staff <- zz-test-nsorensen') + @($roleCheck.Unexpected) | Should-BeCollection -Count 0 + } + } + + It 'names a group the data never describes, and reads no role or app grants under -SkipMembership' { + InModuleScope TestEnvironment { + $script:Fixture.Groups = @($script:Fixture.Groups) + [PSCustomObject]@{ id = 999; name = 'ZZ-TEST-Stray' } + $result = Test-OneLoginEnvironment -SkipMembership -Quiet + @(($result.Checks | Where-Object Name -eq 'Groups').Unexpected) | Should-BeCollection @('ZZ-TEST-Stray') + @($result.Checks | Where-Object { $_.Name -in 'Role memberships', 'App assignments' }) | Should-BeCollection -Count 0 + Should-NotInvoke Write-TestMessage + } + } +} diff --git a/Verify/Invoke-LiveCycle.ps1 b/Verify/Invoke-LiveCycle.ps1 index db01dea..c959d9d 100644 --- a/Verify/Invoke-LiveCycle.ps1 +++ b/Verify/Invoke-LiveCycle.ps1 @@ -37,7 +37,7 @@ [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory = $true)] - [ValidateSet('Entra', 'AD', 'Okta', 'Authentik', 'FreeIPA', 'PingOne')] + [ValidateSet('Entra', 'AD', 'Okta', 'Authentik', 'FreeIPA', 'PingOne', 'OneLogin')] [string]$Provider, [Parameter()] diff --git a/Verify/README.md b/Verify/README.md index b09392a..dc241b2 100644 --- a/Verify/README.md +++ b/Verify/README.md @@ -31,6 +31,7 @@ the working tree beside it, so what runs is the branch being verified. ./Verify/Invoke-LiveCycle.ps1 -Provider Authentik -ConnectParameter @{ BaseUrl = 'https://auth.example.com'; ServiceAccount = $true } ./Verify/Invoke-LiveCycle.ps1 -Provider FreeIPA -ConnectParameter @{ BaseUrl = 'https://ipa.example.com'; ServiceAccount = $true } ./Verify/Invoke-LiveCycle.ps1 -Provider PingOne -ConnectParameter @{ EnvironmentId = $env; ClientId = $client; UseStoredSecret = $true } +./Verify/Invoke-LiveCycle.ps1 -Provider OneLogin -ConnectParameter @{ Subdomain = 'contoso'; UseStoredCredential = $true } ./Verify/Invoke-LiveCycle.ps1 -Provider Entra -ConnectParameter @{ TenantId = $tenant; UseSecretStore = $true } ./Verify/Invoke-LiveCycle.ps1 -Provider AD -ConnectParameter @{} ``` diff --git a/docs/Architecture.md b/docs/Architecture.md index 86a607f..a0b2395 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -14,7 +14,9 @@ TestEnvironment/ │ ├── Entra/ README.md Private/ Public/ Data/ Tools/ │ ├── Okta/ README.md Private/ Public/ Data/ + Initialize.ps1 │ ├── Authentik/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 -│ └── FreeIPA/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ ├── FreeIPA/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ ├── PingOne/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 +│ └── OneLogin/ README.md Private/ Public/ Data/ Tools/ + Initialize.ps1 ├── Public/ the provider-agnostic surface, which dispatches └── Tests/Unit/ Core/, Providers//, and the module-wide contract ``` @@ -32,6 +34,8 @@ differs, because each directory offers a different native place to put it: | `Okta` | `labSeedTag` profile attribute, plus the tag appended to descriptions | a custom profile attribute the module defines | | `Authentik` | `labSeedTag` in the free-form attributes of users and groups; the bracketed tag in an application's description | users additionally sit under a path of their own, which is what a listing can filter on | | `FreeIPA` | `userclass` on users and hosts, which `user-find --class` and `host-find --class` filter on; the bracketed tag in the description of everything else that has one | logins carry no prefix, so the class is the whole proof for a user; a sudo command is named by its path and its description is the whole proof for it | +| `PingOne` | `zzTestSeedTag` custom user attribute; the description of populations, groups, resources and applications | a user has no description, so the seed creates the attribute; users are proved by their seeded population first | +| `OneLogin` | `zztest_seed_tag` custom user field; an app's and an API server's description and a sign-up profile's help text, inside Core's sentence; a marker line first in a Smart Hook's code | a role, group, policy, mapping and app rule have nothing but a name, so each is proved by what it holds: seeded people and apps and nothing else, a policy by its seeded groups, a mapping by the seed-tag condition it always carries, a rule by its seeded app | **Three modules became one because of what they duplicated.** SecretStore handling, certificate persistence and password generation had three implementations that were converging on the same diff --git a/docs/TestEnvironment/Get-OneLoginAppCredential.md b/docs/TestEnvironment/Get-OneLoginAppCredential.md new file mode 100644 index 0000000..ada37c2 --- /dev/null +++ b/docs/TestEnvironment/Get-OneLoginAppCredential.md @@ -0,0 +1,165 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Get-OneLoginAppCredential.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 29 2026 +PlatyPS schema version: 2024-05-01 +title: Get-OneLoginAppCredential +--- + +# Get-OneLoginAppCredential + +## SYNOPSIS + +Returns the saved client id and secret of seeded OneLogin apps as credentials + +## SYNTAX + +### __AllParameterSets + +``` +Get-OneLoginAppCredential [[-Key] ] [[-Subdomain] ] + [[-VaultPassword] ] +``` + +## DESCRIPTION + +Reads back the client secrets New-OneLoginApp -SaveAppSecret saved, one credential per app, so a relying party can be configured to sign in through a seeded app. The client id is the credential's user name and the secret its password; the secret is never written to the pipeline as text. + +Only the apps created with -SaveAppSecret have a record, and only the ones that authenticate with a secret: Expenses Web, which sends it with HTTP Basic, and Payroll Console, which posts it in the body. The public and native clients and the SAML app have no secret to save. OneLogin shows an app's secret once, when the app is created, so an app that already existed when the seed ran has no record; delete it and seed again, or regenerate its secret in the portal. + +The seeded apps' redirect URLs are under the lab email domain, example.com by default, so a test client has to be reachable there - a hosts entry, or the connection's -EmailDomain set to a domain you control - or have its own redirect URL added to the app in the portal. The client credentials grant is not enabled on the seeded apps; a token request made with it is refused. + +A secret saved in the SecretStore is read from the vault, which may need its password. + +Remove-TestEnvironment deletes each record with its app, and any record whose app is no longer in the account. + +## EXAMPLES + +### Example 1: List every saved app credential + +```powershell +Get-OneLoginAppCredential +``` + +DESCRIPTION: Lists every saved app credential for the connected account +OUTPUT: One object per app, the secret held in a PSCredential +USE CASE: Seeing which seeded apps have a saved secret + +### Example 2: Copy one app's secret + +```powershell +$app = Get-OneLoginAppCredential -Key expenses +$app.Credential.GetNetworkCredential().Password | Set-Clipboard +``` + +DESCRIPTION: Copies the Expenses Web client secret to the clipboard +OUTPUT: None +USE CASE: Pasting the secret into a test client that signs in to the seeded app + +### Example 3: See which apps have a saved secret, and how it is protected + +```powershell +Get-OneLoginAppCredential | Select-Object Key, Name, AppId, Protection +``` + +DESCRIPTION: Lists the saved apps without touching the secrets +OUTPUT: One row per app - expenses and payroll after a seed with -SaveAppSecret - with DPAPI or SecretStore +USE CASE: Checking what a seed left on this machine before handing it to somebody else + +## PARAMETERS + +### -Key + +Return only the apps with these keys from the seed data, such as expenses. Every saved app by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Subdomain + +The account whose saved secrets to read. The connected account by default. + +```yaml +Type: System.String +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -VaultPassword + +The SecretStore vault's password, when a secret is kept there and the vault is not the module default. + +```yaml +Type: System.Security.SecureString +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 2 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +One object per saved app with Key, Name, AppId, Subdomain, Protection and Credential, a PSCredential whose user name is the client id and whose password is the client secret. The secret is never on the pipeline as text. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + +## RELATED LINKS + +- [New-OneLoginApp]() diff --git a/docs/TestEnvironment/New-OneLoginApiAuthorization.md b/docs/TestEnvironment/New-OneLoginApiAuthorization.md new file mode 100644 index 0000000..05124c5 --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginApiAuthorization.md @@ -0,0 +1,186 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginApiAuthorization.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginApiAuthorization +--- + +# New-OneLoginApiAuthorization + +## SYNOPSIS + +Creates the seeded API authorization servers with their scopes and claims, and lets seeded apps ask for them + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginApiAuthorization [[-Key] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +An API authorization server is what an OpenID Connect app asks for an access token to call an API, so it is where a review of which app may do what to which API looks. Two are seeded: + +- Orders API, with a sixty-minute token and three scopes - read, write and admin - of which admin is granted to no app, and a claim read from the zztest_cost_center custom field. Expenses Web may ask for read; Contractor Portal SPA, a public client, may ask for read and write. +- Reports API, with a ten-minute token and one scope, which only Payroll Console may ask for. + +Each server's audience is https://api./, under example.com by default, never a real host. Its description carries the seed tag inside a sentence, and teardown requires that tag and the prefix on the name together. + +Only a seeded app is ever made a client of a seeded server, and only a seeded server is ever given one, so no app outside the seed can ask for a seeded API's tokens and no seeded API issues tokens to one. There is no parameter to name another app. Scopes, claims and clients already on a reused server are kept, and only what the data adds is sent. + +## EXAMPLES + +### Example 1: Create both seeded servers + +```powershell +New-OneLoginApiAuthorization +``` + +DESCRIPTION: Creates Orders API and Reports API with their scopes, claims and clients +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the apps exist + +### Example 2: Create one server and see what it holds + +```powershell +New-OneLoginApiAuthorization -Key orders-api -PassThru +``` + +DESCRIPTION: Creates or reuses Orders API +OUTPUT: A result object with ScopesAdded, ClaimsAdded and ClientsLinked +USE CASE: Testing an access review against an API with a scope nobody holds + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginApiAuthorization -WhatIf +``` + +DESCRIPTION: Shows every server, scope, claim and client link that would be made, making none +OUTPUT: A What if: line per object +USE CASE: Checking every client named is a seeded app + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalApiAuthorizations, CreatedApiAuthorizations, ReusedApiAuthorizations, ScopesAdded, ClaimsAdded, ClientsLinked, ApiAuthorizations and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginApp]() +- [Test-TestEnvironment]() + diff --git a/docs/TestEnvironment/New-OneLoginApp.md b/docs/TestEnvironment/New-OneLoginApp.md new file mode 100644 index 0000000..e2928de --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginApp.md @@ -0,0 +1,286 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginApp.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 29 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginApp +--- + +# New-OneLoginApp + +## SYNOPSIS + +Creates the seeded OIDC and SAML apps and grants them to their roles + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginApp [[-Key] ] [[-Tier] ] [[-VaultPassword] ] + [-SaveAppSecret] [-UseSecretStore] [-PassThru] [-WhatIf] [-Confirm] +``` + +## ALIASES + +## DESCRIPTION + +Five apps, because a OneLogin trial allows five, across the two protocols and every OpenID Connect client shape the generic connector offers: + +- Expenses Web, a confidential web client authenticating with HTTP Basic, granted to All Staff. +- Payroll Console, a confidential web client that posts its secret in the body, granted to Finance, whose audience a mapping widens. +- Contractor Portal SPA, a public client. On OneLogin a public client is chosen by token endpoint authentication None, and that is PKCE: the connector offers no public client without it. +- Field App, 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, a SAML app built on the SAML Custom Connector (Advanced), so anything that assumes every app has a client id has one that does not. It is granted to two roles. + +The description carries the seed tag inside a sentence - "Seeded by TestEnvironment. Safe to delete." - so an administrator who finds one in a production portal knows what made it. Teardown requires the tag in the description and the prefix on the name together. A prefixed app that already exists without the tag is left alone and granted to nothing. + +OneLogin returns a new app's client secret in the response to its creation, and no later read of the app returns it. By default only the id is kept and the secret is dropped: nothing in the module needs it. With -SaveAppSecret, the secrets of the two confidential clients created in this run - Expenses Web and Payroll Console - are kept, DPAPI-protected in a record under ~/.testenvironment or in a SecretStore vault with -UseSecretStore, and Get-OneLoginAppCredential reads them back as credentials. Remove-TestEnvironment deletes each saved secret with its app, and any whose app is no longer in the account, so they do not build up. + +Every URL is written against the connection's email domain, under example.com by default, never a real host. + +Apps are granted to roles here, one request per role, and only to roles the seed may use: its own, or an empty one of its names. What a role already holds is read and kept, because the endpoint sets a role's apps rather than adding to them. + +## EXAMPLES + +### Example 1: Create every seeded app + +```powershell +New-OneLoginApp +``` + +DESCRIPTION: Creates the five apps and grants them to their roles +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the roles exist + +### Example 2: Create the SAML app alone + +```powershell +New-OneLoginApp -Key wiki-saml -PassThru +``` + +DESCRIPTION: Creates Wiki SAML and grants it to All Staff and Contractors +OUTPUT: A result object with its id and the grants applied - never a secret +USE CASE: Testing how a tool handles an app with no client id + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginApp -WhatIf +``` + +DESCRIPTION: Shows every app and grant that would be made, making none +OUTPUT: A What if: line per app and per role granted to +USE CASE: Checking the seed fits under the account's app limit before it runs + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. +Create only the rows with these keys, from the seed data file. +All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -SaveAppSecret + +Keep the client secret of each confidential app this run creates - Expenses Web and Payroll Console - protected, so a sign-in can be tested against it with Get-OneLoginAppCredential. Off by default: nothing in the module needs the secrets. + +OneLogin shows an app's secret only in the answer to its creation, so an app that already existed has none to save; it is named in SecretsUnavailable and a warning. The public and native clients and the SAML app have no secret. Remove-TestEnvironment deletes each saved secret with its app. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Grant apps only to the roles the chosen tiers create. Both tiers by default. With -Tier Bulk alone no role is created, so the apps are created and granted to nothing. +Grant apps only to the roles the chosen tiers create. +Both tiers by default. +With -Tier Bulk alone no role is created, so the apps are created and granted to nothing. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -UseSecretStore + +With -SaveAppSecret, keep the secrets in a SecretStore vault rather than DPAPI-protected in their records under ~/.testenvironment. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -VaultPassword + +With -UseSecretStore, the vault's password when it is not the module default. + +```yaml +Type: System.Security.SecureString +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 2 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalApps, CreatedApps, ReusedApps, GrantsApplied, Apps and Errors. Apps carries each app's key, name, id and connector, and never a client secret. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginRole]() +- [New-OneLoginUser]() diff --git a/docs/TestEnvironment/New-OneLoginAppRule.md b/docs/TestEnvironment/New-OneLoginAppRule.md new file mode 100644 index 0000000..dfcc1cd --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginAppRule.md @@ -0,0 +1,207 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginAppRule.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginAppRule +--- + +# New-OneLoginAppRule + +## SYNOPSIS + +Creates the seeded app rules, which hand a seeded app's groups claim out by seeded role + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginAppRule [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +An app rule decides what an app hands a person when they sign in, by condition, so what a token carries depends on rules as well as on grants. Two are seeded: + +- Directory groups for staff, on Expenses Web, enabled: anybody in All Staff gets their directory group names in the groups claim, so the claim changes when group membership does. +- Groups for everyone outside Finance, on Payroll Console, disabled: it would hand the payroll app every group of everyone who is not in Finance if anybody enabled it, the dormant rule an access review has to find. + +A rule goes only on a proved seeded app and names only a role the seed may use. A rule naming somebody else's role would give the people holding it a claim on a seeded app; a rule on somebody else's app would change what that app gives its real users. The action is fixed by the provider, not taken from the data: set the groups claim from member_of, which on a seeded person names seeded groups in the lab domain and nothing else. There is no parameter for either. + +A rule whose app or role the tiers being seeded do not create is skipped rather than created against nothing. Teardown proves a rule by the seeded app it sits on and the prefix on its name, and removes it with the app unless the app is kept. + +## EXAMPLES + +### Example 1: Create both seeded rules + +```powershell +New-OneLoginAppRule +``` + +DESCRIPTION: Creates the enabled rule on Expenses Web and the disabled one on Payroll Console +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the apps and roles exist + +### Example 2: Create the dormant rule alone + +```powershell +New-OneLoginAppRule -Key payroll-dormant -PassThru +``` + +DESCRIPTION: Creates the disabled rule on Payroll Console +OUTPUT: A result object naming it +USE CASE: Testing whether a review of app rules reports a disabled one + +### Example 3: Read the rules back + +```powershell +Get-TestEnvironmentReport -PassThru | Select-Object -ExpandProperty AppRules +``` + +DESCRIPTION: Lists the seeded rules with their app and whether each is enabled +OUTPUT: One row per rule +USE CASE: Confirming each rule sits on a seeded app + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Create only the rules whose role the chosen tiers create. Both tiers by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalAppRules, CreatedAppRules, ReusedAppRules, SkippedAppRules, AppRules and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginApp]() +- [New-OneLoginRole]() + diff --git a/docs/TestEnvironment/New-OneLoginCustomAttribute.md b/docs/TestEnvironment/New-OneLoginCustomAttribute.md new file mode 100644 index 0000000..75c14c4 --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginCustomAttribute.md @@ -0,0 +1,189 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginCustomAttribute.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginCustomAttribute +--- + +# New-OneLoginCustomAttribute + +## SYNOPSIS + +Creates the custom user fields the seed needs, including its ownership marker + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginCustomAttribute [[-Shortname] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A OneLogin user has no description field and nothing else that is free text nobody writes: comment, company, department and title are all things an administrator fills in. So the seed creates a custom user field of its own, zztest_seed_tag, writes ZZ-TEST-seed into it on every person it makes, and teardown proves a person is seeded by that value together with the prefix on the username. The field is removed last at teardown, after every person who carries it. + +The other fields are there because they are awkward in the ways scripts get wrong: + +- zztest_badge_id is empty on some people, so a report has to cope with a field that is simply absent. +- zztest_contractor is a boolean stored as text, because OneLogin custom fields are text. The string false is not falsy in PowerShell, so anything casting it rather than comparing it reads every person as a contractor. +- zztest_cost_center is shared by whole departments, which a mapping or a report can group on. + +Every shortname starts with zztest_. A field has no description, so the shortname is the only part of it the seed controls, and teardown removes a field only when the seed data declares it and its shortname carries that prefix. Shortnames take letters, digits and underscores only; OneLogin refuses a dash as invalid. + +Re-running is safe: a field that already exists is reused and never replaced, because replacing it would drop the value every seeded person holds in it. + +## EXAMPLES + +### Example 1: Create every seeded field + +```powershell +New-OneLoginCustomAttribute +``` + +DESCRIPTION: Creates the seed tag field and the three lab fields +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before anybody is created + +### Example 2: Create the ownership marker alone + +```powershell +New-OneLoginCustomAttribute -Shortname zztest_seed_tag -PassThru +``` + +DESCRIPTION: Creates only the field every seeded person is tagged in +OUTPUT: A result object naming it and its id +USE CASE: Preparing an account before seeding people by hand + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginCustomAttribute -WhatIf +``` + +DESCRIPTION: Shows every field that would be created, creating none +OUTPUT: A What if: line per field +USE CASE: Checking what the seed adds to a production account's schema + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Shortname + +Create only the fields with these shortnames. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalAttributes, CreatedAttributes, ReusedAttributes, Attributes and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginUser]() +- [Connect-TestEnvironment]() + diff --git a/docs/TestEnvironment/New-OneLoginGroup.md b/docs/TestEnvironment/New-OneLoginGroup.md new file mode 100644 index 0000000..c696b15 --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginGroup.md @@ -0,0 +1,208 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginGroup.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginGroup +--- + +# New-OneLoginGroup + +## SYNOPSIS + +Creates the seeded groups that the people being seeded will be placed in + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginGroup [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A OneLogin person is in one group at most, and a group is where a security policy applies, so the seeded groups follow office location the way a real account's do: Seattle HQ, London, New York, US Regional Offices and Remote Workers. Seattle HQ holds most of the people, which makes it the group a per-group report is dominated by. + +A group is created with a name and nothing else. New-OneLoginPolicy attaches the seeded policies afterwards, and only ever a seeded policy to a seeded group: a policy that already exists is never attached, because it would change how the group's real members sign in. + +A group, like a role, has nothing but its name to say who made it, so teardown proves one by its members: the prefix, no administrators, at least one member, every member a seeded person, and no policy unless it is a prefixed one that is not the account's default. A group nobody in the tiers being seeded will be placed in is therefore not created. A prefixed group that already exists and holds somebody the seed did not make, or has another policy or an administrator, is left alone and reported, and nobody is put in it. + +People are placed in their groups by New-OneLoginUser, as each is created. + +## EXAMPLES + +### Example 1: Create every seeded group + +```powershell +New-OneLoginGroup +``` + +DESCRIPTION: Creates the five office groups +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before the people who belong in them + +### Example 2: Create two groups + +```powershell +New-OneLoginGroup -Key london, new-york -PassThru +``` + +DESCRIPTION: Creates the London and New York groups +OUTPUT: A result object naming them and their ids +USE CASE: Testing how a report treats groups outside headquarters + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginGroup -WhatIf +``` + +DESCRIPTION: Shows every group that would be created, creating none +OUTPUT: A What if: line per group +USE CASE: Checking the seed will not collide with an existing group of the same name + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Seed for the Core people only (the hand-designed edge cases) or the Bulk people only (the generated volume). Both by default. + +A role or group that nobody in the chosen tiers would hold is not created, because OneLogin gives a role nothing but its name and teardown proves one by the seeded people it holds: an empty one could never be claimed and would be left behind. Bulk people are unlicensed and hold no roles, so -Tier Bulk alone creates no role at all. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalGroups, CreatedGroups, ReusedGroups, SkippedGroups, Groups and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginUser]() +- [New-OneLoginRole]() + diff --git a/docs/TestEnvironment/New-OneLoginMapping.md b/docs/TestEnvironment/New-OneLoginMapping.md new file mode 100644 index 0000000..cb8a3db --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginMapping.md @@ -0,0 +1,209 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginMapping.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginMapping +--- + +# New-OneLoginMapping + +## SYNOPSIS + +Creates the seeded user mappings, each gated so it can only ever act on seeded people + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginMapping [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A OneLogin mapping is a rule that changes people who match its conditions, and an enabled one acts on everybody in the account who matches. That is why every mapping this creates carries one more condition than the seed data gives it: the zztest_seed_tag field must equal ZZ-TEST-seed, with match all. Nobody but this module writes that field, so an enabled seeded mapping can act on seeded people and nobody else, which is what makes it safe to seed one into an account real people sign in to. The condition is added by the code, not the data, and there is no parameter to leave it out; a test asserts that there is none. + +Every seeded mapping does one thing, add a seeded role. Two are seeded: + +- Finance department gets Finance, enabled. It puts a person into Finance whom the data never lists there, so the payroll app's audience is wider than any list of explicit grants says, and verification has to judge role membership on what is missing only. +- Contractors get Engineering, disabled. It would put every contractor into Engineering if anybody enabled it: the dormant rule an access review has to find. + +Mappings are created before people, because OneLogin runs an enabled mapping as each person is created; that was verified against a live account. + +Teardown proves a mapping by its prefix, the gate, match all, and actions that add only proved seeded roles. A prefixed mapping that already exists without the gate is left alone and reported. + +## EXAMPLES + +### Example 1: Create both seeded mappings + +```powershell +New-OneLoginMapping +``` + +DESCRIPTION: Creates the enabled Finance mapping and the disabled contractor one, each gated on the seed tag +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, after the roles and before the people + +### Example 2: Create the dormant rule alone + +```powershell +New-OneLoginMapping -Key contractor-eng -PassThru +``` + +DESCRIPTION: Creates the disabled mapping that would widen Engineering +OUTPUT: A result object naming it, with Enabled False +USE CASE: Testing whether an access review finds a disabled rule + +### Example 3: Read what a seeded mapping really does + +```powershell +Get-TestEnvironmentReport -PassThru | Select-Object -ExpandProperty Mappings +``` + +DESCRIPTION: Lists the seeded mappings with their condition and action counts +OUTPUT: One row per mapping; each has one more condition than the data, the seed-tag gate +USE CASE: Confirming every seeded mapping is gated + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Create only the mappings whose role the chosen tiers create. Both tiers by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalMappings, CreatedMappings, ReusedMappings, SkippedMappings, Mappings and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginRole]() +- [New-OneLoginUser]() + diff --git a/docs/TestEnvironment/New-OneLoginMfaFactor.md b/docs/TestEnvironment/New-OneLoginMfaFactor.md new file mode 100644 index 0000000..73c7790 --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginMfaFactor.md @@ -0,0 +1,206 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginMfaFactor.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginMfaFactor +--- + +# New-OneLoginMfaFactor + +## SYNOPSIS + +Pre-enrols the seeded people's MFA factors, where the account already offers the factor + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginMfaFactor [[-Username] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +Three Core people - awhitfield, praghunathan and talvarez - are given an email factor in the seed data, so a report of who has MFA has somebody to find and a larger number of people without it. + +A factor is enrolled only where the account already offers it to that person. Whether a factor is offered is an account-wide setting, and turning one on would change what every real person is asked for at sign-in, so the seed never does; in an account that offers none, as a trial does, every person is reported as skipped and nothing is sent. + +A factor is enrolled on proved seeded people only, and only ever as already verified, so no code or message is sent to anybody - and every seeded address is at the lab domain, which cannot receive one. A person who already holds a factor of that name keeps it. + +Teardown removes factors with the people who hold them. + +## EXAMPLES + +### Example 1: Enrol every seeded factor + +```powershell +New-OneLoginMfaFactor +``` + +DESCRIPTION: Enrols the email factor on the three people the data gives one, where the account offers it +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, as the last step + +### Example 2: See whether the account offers the factor + +```powershell +New-OneLoginMfaFactor -PassThru | Select-Object EnrolledFactors, Skipped +``` + +DESCRIPTION: Attempts the enrolments and shows what happened +OUTPUT: The number enrolled, and the people skipped because the account offers no such factor +USE CASE: Deciding whether to turn a factor on in a lab account before seeding again + +### Example 3: Enrol one person + +```powershell +New-OneLoginMfaFactor -Username talvarez -PassThru +``` + +DESCRIPTION: Enrols talvarez alone +OUTPUT: A result object naming the factor, or the reason it was skipped +USE CASE: Testing a report against a single person with MFA + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Enrol only the Core people or only the Bulk people. Both by default; only Core people are given a factor. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Username + +Enrol only the people with these keys from the seed data - the username without the prefix, such as talvarez. Everybody the data gives a factor by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalFactors, EnrolledFactors, ExistingFactors, Skipped, Factors and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginUser]() +- [Get-TestEnvironmentReport]() + diff --git a/docs/TestEnvironment/New-OneLoginPolicy.md b/docs/TestEnvironment/New-OneLoginPolicy.md new file mode 100644 index 0000000..a71f2b5 --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginPolicy.md @@ -0,0 +1,213 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginPolicy.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginPolicy +--- + +# New-OneLoginPolicy + +## SYNOPSIS + +Creates the seeded user security policies and attaches each to seeded groups only + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginPolicy [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A OneLogin user policy decides how the people in a group sign in: password length and lifetime, how many passwords are remembered, how many failed attempts lock an account and for how long. A group has one policy at most, and a group without one falls back to the account's default. Two are seeded, so that people in the same account are under different rules and a report that assumes one password policy is wrong: + +- Strict Office, fourteen characters, sixty days, twenty-four remembered, five attempts and a thirty-minute lock, on Seattle HQ and New York, which hold most of the seeded people. +- Contractor Access, twelve characters, thirty days, six remembered, three attempts and an hour's lock, on Remote Workers. + +London and US Regional Offices are given none and fall back to the default, which is a difference a review has to be able to explain. + +What keeps this from reaching anybody real, none of which has a parameter: + +- A policy is never made the account's default; is_default is never sent. The default decides how everybody without a group policy signs in. +- A policy is attached only to a group the seed may use: a seeded group, or an empty one of the seed's names. A group that holds anybody else is reported and left, because the policy would then govern how they sign in. +- A policy that already exists under a seeded name is reused only when it is not the default and no group outside the seed uses it. Otherwise it is left alone, attached to nothing and reported, because it governs somebody the seed did not make. + +A policy has no description, so teardown proves one by the seeded groups using it, and a policy no group in the tiers being seeded would use is not created. Its settings are put back as the data describes on every run. + +## EXAMPLES + +### Example 1: Create both seeded policies + +```powershell +New-OneLoginPolicy +``` + +DESCRIPTION: Creates Strict Office and Contractor Access and attaches them to their groups +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the groups exist + +### Example 2: Put one policy's settings back + +```powershell +New-OneLoginPolicy -Key strict-office -PassThru +``` + +DESCRIPTION: Reuses Strict Office and rewrites any setting that no longer matches the data +OUTPUT: A result object; SettingsUpdated counts the policies that were changed back +USE CASE: Repairing a seed after somebody loosened a password rule in the portal + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginPolicy -WhatIf +``` + +DESCRIPTION: Shows every policy and every group attachment that would be made, making none +OUTPUT: A What if: line per policy and per group +USE CASE: Confirming no attachment would land on a group that is not the seed's + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Create only the policies whose groups the chosen tiers fill, and attach them to those groups only. Both tiers by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalPolicies, CreatedPolicies, ReusedPolicies, SkippedPolicies, SettingsUpdated, GroupsAttached, Policies and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginGroup]() +- [Test-TestEnvironment]() + diff --git a/docs/TestEnvironment/New-OneLoginRole.md b/docs/TestEnvironment/New-OneLoginRole.md new file mode 100644 index 0000000..b4f2e2e --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginRole.md @@ -0,0 +1,212 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginRole.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginRole +--- + +# New-OneLoginRole + +## SYNOPSIS + +Creates the seeded roles that the people being seeded will hold + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginRole [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +OneLogin grants app access through roles, so roles are where access lives in a seeded account. There are four, because a OneLogin trial allows five roles and the account's Default role is one of them: + +- All Staff, the broad role nearly every licensed employee holds and most apps are granted through. It holds a suspended manager and people whose passwords have lapsed. +- Engineering, a department role, and the one the disabled mapping would hand to every contractor if anybody enabled it. +- Finance, which the data gives one member and the enabled mapping gives a second, so who can reach the payroll app depends on whether mappings have run. +- Contractors, held by one contractor while another is licensed, still waiting for a password, and holds nothing. + +A role has nothing but its name to say who made it, so teardown proves one by what it holds: the prefix, no administrators, at least one member, and every member a seeded person and every app a seeded app. A role that nobody in the tiers being seeded will hold is therefore not created. A prefixed role that already exists and holds somebody the seed did not make, or has an administrator, is left alone and reported, and nothing is ever added to it. + +People are not added here. A role has to exist before anybody can be put in it, so New-OneLoginUser adds each person to their roles once they exist. + +## EXAMPLES + +### Example 1: Create every seeded role + +```powershell +New-OneLoginRole +``` + +DESCRIPTION: Creates the four roles +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before apps are granted to them + +### Example 2: Create one role and see what happened + +```powershell +New-OneLoginRole -Key finance -PassThru +``` + +DESCRIPTION: Creates Finance alone +OUTPUT: A result object naming it, or an error naming the plan limit if the account allows no more roles +USE CASE: Rebuilding one role after it was deleted by hand + +### Example 3: Create only what a Bulk seed would fill + +```powershell +New-OneLoginRole -Tier Bulk -PassThru +``` + +DESCRIPTION: Creates nothing, and lists every role as skipped, because Bulk people are unlicensed and hold no roles +OUTPUT: A result object whose SkippedRoles names all four +USE CASE: Seeing why a Bulk-only seed has no roles + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Seed for the Core people only (the hand-designed edge cases) or the Bulk people only (the generated volume). Both by default. + +A role or group that nobody in the chosen tiers would hold is not created, because OneLogin gives a role nothing but its name and teardown proves one by the seeded people it holds: an empty one could never be claimed and would be left behind. Bulk people are unlicensed and hold no roles, so -Tier Bulk alone creates no role at all. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalRoles, CreatedRoles, ReusedRoles, SkippedRoles, Roles and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginUser]() +- [New-OneLoginApp]() +- [New-OneLoginMapping]() + diff --git a/docs/TestEnvironment/New-OneLoginSelfRegistration.md b/docs/TestEnvironment/New-OneLoginSelfRegistration.md new file mode 100644 index 0000000..a00cf8e --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginSelfRegistration.md @@ -0,0 +1,188 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginSelfRegistration.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginSelfRegistration +--- + +# New-OneLoginSelfRegistration + +## SYNOPSIS + +Creates the seeded self-registration profile, always disabled, moderated and open to the lab domain only + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginSelfRegistration [[-Key] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A self-registration profile is a public page where anybody can create themselves an account, so enabling one is a single click that changes who can get in. One is seeded, Partner Sign-up, for an access review to find. + +It is created in the one shape that cannot admit anybody, and none of it has a parameter: + +- Disabled, so there is no public page. +- Moderated, so even enabled it admits nobody until an administrator approves them. +- Open only to the lab email domain, under example.com by default, which RFC 2606 reserves, so no real address can register. +- With no default role and no default group, so a registrant could never land in anything. + +A re-run puts that shape back if anybody loosened it. The help text carries the seed tag inside a sentence, and teardown requires that tag and the prefix on the name together; a prefixed profile without the tag is left alone. + +## EXAMPLES + +### Example 1: Create the seeded profile + +```powershell +New-OneLoginSelfRegistration +``` + +DESCRIPTION: Creates Partner Sign-up, disabled and moderated +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment + +### Example 2: Put the safe shape back + +```powershell +New-OneLoginSelfRegistration -PassThru +``` + +DESCRIPTION: Reuses the profile and disables and restricts it again if it was loosened +OUTPUT: A result object; Restored is 1 when it had been changed +USE CASE: Repairing the seed after somebody enabled the page + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginSelfRegistration -WhatIf +``` + +DESCRIPTION: Shows the profile that would be created, creating none +OUTPUT: A What if: line +USE CASE: Checking the name does not collide with a real profile + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalSelfRegistrations, CreatedSelfRegistrations, ReusedSelfRegistrations, Restored, SelfRegistrations and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginUser]() +- [Test-TestEnvironment]() + diff --git a/docs/TestEnvironment/New-OneLoginSmartHook.md b/docs/TestEnvironment/New-OneLoginSmartHook.md new file mode 100644 index 0000000..e571f3f --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginSmartHook.md @@ -0,0 +1,208 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginSmartHook.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginSmartHook +--- + +# New-OneLoginSmartHook + +## SYNOPSIS + +Creates the seeded Smart Hook, always disabled and gated on a seeded role + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginSmartHook [[-Key] ] [[-Tier] ] [-PassThru] [-WhatIf] [-Confirm] +``` + +## DESCRIPTION + +A Smart Hook is code OneLogin runs during a sign-in, so it belongs in any review of what can change how people sign in. One is seeded: a pre-authentication hook, whose handler changes nothing - it hands back the policy the person already has. + +What keeps it from reaching anybody real, none of which has a parameter: + +- It is created disabled, and a re-run disables it again if somebody enabled it. +- It carries one condition, membership of the seeded Contractors role, so even enabled it would run for seeded people and nobody else. +- The first line of its code is the marker "// Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]". A hook has no name or description, so that line is what teardown proves it by, together with conditions that name only seeded roles. + +OneLogin allows one hook of each type in an account. An account that already has its own pre-authentication hook keeps it untouched: OneLogin refuses the seed's create, and the step reports the refusal and suggests -Skip Hooks. A hook whose role the tiers being seeded do not create is skipped. The code runs on the nodejs22.x runtime. + +## EXAMPLES + +### Example 1: Create the seeded hook + +```powershell +New-OneLoginSmartHook +``` + +DESCRIPTION: Creates the disabled pre-authentication hook gated on Contractors +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the roles exist + +### Example 2: Make sure it is off + +```powershell +New-OneLoginSmartHook -PassThru +``` + +DESCRIPTION: Reuses the seeded hook and disables it again if it was enabled +OUTPUT: A result object; DisabledAgain is 1 when it had been turned on +USE CASE: Putting the seed back after somebody experimented in the portal + +### Example 3: Preview in an account you care about + +```powershell +New-OneLoginSmartHook -WhatIf +``` + +DESCRIPTION: Shows the hook that would be created, creating none +OUTPUT: A What if: line +USE CASE: Checking whether the account already has a hook of that type + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Key + +Create only the rows with these keys, from the seed data file. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object describing what was created, reused and refused. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Create the hook only when the chosen tiers create the role it is gated on. Both tiers by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalHooks, CreatedHooks, ReusedHooks, SkippedHooks, DisabledAgain, Hooks and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginRole]() +- [Remove-TestEnvironment]() + diff --git a/docs/TestEnvironment/New-OneLoginUser.md b/docs/TestEnvironment/New-OneLoginUser.md new file mode 100644 index 0000000..035647c --- /dev/null +++ b/docs/TestEnvironment/New-OneLoginUser.md @@ -0,0 +1,239 @@ +--- +document type: cmdlet +external help file: TestEnvironment-Help.xml +HelpUri: https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginUser.md +Locale: en-US +Module Name: TestEnvironment +ms.date: 09 28 2026 +PlatyPS schema version: 2024-05-01 +title: New-OneLoginUser +--- + +# New-OneLoginUser + +## SYNOPSIS + +Creates the seeded people with their lifecycle, group, manager and roles, and puts a reused one back as the data describes + +## SYNTAX + +### __AllParameterSets + +``` +New-OneLoginUser [[-Username] ] [[-Tier] ] [-ShowProgress] [-PassThru] [-WhatIf] + [-Confirm] +``` + +## DESCRIPTION + +Creates every seeded person with their lifecycle status and state, their group, their manager and their custom fields in one request, in the order the seed data lists them, which puts every manager before the people who report to them. Then adds each person to their roles, one request per role. + +The people are built around what OneLogin does without saying so, each verified against a live trial account: + +- A person is approved only while the account has a user licence for them. Beyond that OneLogin makes them Unlicensed, and answers the create as if it had not. A trial has twelve licences, the owner among them, so ten Core people are approved. The writing-system cohort is unlicensed, because what those people test is their names; and every Bulk person is unlicensed on purpose, so a seed spends no licence on a trial or a paid account. This step reads the people back once and reports anybody OneLogin left unlicensed. +- A role grant is accepted for anyone and kept only for an approved person whose status is Active, Suspended, Locked, PasswordExpired or AwaitingPasswordReset. The data gives roles to those people only. +- A rejected person is kept out of any group as well as any role. +- Unactivated and Unapproved do not stay put, so the data does not ask for them. +- A status of Locked sent on a create or an update does not hold either. A person the data gives as Locked is created Active and then locked for a year through OneLogin's lock call, which holds on a licensed person only; a re-run locks them again when less than a month of the lock is left. + +Every person also carries the directory identifiers a synchronised account would: a sAMAccountName, a user principal name, a distinguished name, the distinguished names of their groups and roles as member_of, an external id, a phone number and a comment saying what made them. Each is built from the prefix and the connection's email domain - the sAMAccountName and external id start with the prefix, the names sit under OU=Users and OU=Groups of a domain made from the lab email domain, and every phone number is in the 555-0100 to 555-0199 range reserved for fiction - so no identifier can match an account a real directory synchronises, and a tool that joins on one of them finds the seed and nothing else. + +Nobody who is not seeded is ever touched. The people this step adds to roles and names as managers are the ones it created and the ones it proved seeded - by the tag in zztest_seed_tag and the prefix on the username - and the roles and groups it uses are empty or hold seeded people alone. A create refused because the username already belongs to somebody else is reported and left. + +Re-running is safe. A person already seeded is reused and put back as the data describes: names, title, department, company, status, state, group, manager, custom fields and directory identifiers, sending only what differs. Names are compared by codepoint, so a decomposed name that came back precomposed is corrected. + +OneLogin settles role membership after it is written. A role can read as empty for a short while after its grants were sent. + +## EXAMPLES + +### Example 1: Create every seeded person + +```powershell +New-OneLoginUser -ShowProgress +``` + +DESCRIPTION: Creates the 321 seeded people, sets their managers and adds the licensed ones to their roles +OUTPUT: A progress bar, and nothing on the pipeline +USE CASE: Run for you by New-TestEnvironment, as the last step + +### Example 2: Create the designed people only + +```powershell +New-OneLoginUser -Tier Core -PassThru +``` + +DESCRIPTION: Creates the twenty-one hand-designed people +OUTPUT: A result object with the counts, and an error naming anybody OneLogin left unlicensed +USE CASE: The fast loop, when the test is behaviour rather than scale + +### Example 3: Put two people back + +```powershell +New-OneLoginUser -Username mbell, jmarchetti -PassThru +``` + +DESCRIPTION: Recreates or restores the suspended manager and the person who reports to him +OUTPUT: A result object; UpdatedUsers counts the people who were changed back +USE CASE: Repairing a seed after somebody edited those people in the portal + +## PARAMETERS + +### -Confirm + +Prompts you for confirmation before running the cmdlet. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- cf +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -PassThru + +Return a result object with TotalUsers, CreatedUsers, ReusedUsers, UpdatedUsers, ManagersSet, UsersLocked, MembershipsApplied, GrantsNotYetShown, Users and Errors. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -ShowProgress + +Show a progress bar, one step per person. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Tier + +Seed only the Core people (the hand-designed edge cases) or only the Bulk people (the generated volume). Both by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 1 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -Username + +Seed only the people with these keys from the seed data - the username without the prefix, such as jnino. All of them by default. + +```yaml +Type: System.String[] +DefaultValue: '' +SupportsWildcards: false +Aliases: [] +ParameterSets: +- Name: (All) + Position: 0 + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### -WhatIf + +Runs the command in a mode that only reports what would happen without performing the actions. + +```yaml +Type: System.Management.Automation.SwitchParameter +DefaultValue: '' +SupportsWildcards: false +Aliases: +- wi +ParameterSets: +- Name: (All) + Position: Named + IsRequired: false + ValueFromPipeline: false + ValueFromPipelineByPropertyName: false + ValueFromRemainingArguments: false +DontShow: false +AcceptedValues: [] +HelpMessage: '' +``` + +### CommonParameters + +This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, +-InformationAction, -InformationVariable, -OutBuffer, -OutVariable, -PipelineVariable, +-ProgressAction, -Verbose, -WarningAction, and -WarningVariable. For more information, see +[about_CommonParameters](https://go.microsoft.com/fwlink/?LinkID=113216). + +## INPUTS + +### None + +This command does not accept pipeline input. + +## OUTPUTS + +### System.Management.Automation.PSObject + +Only when -PassThru is supplied: a summary with TotalUsers, CreatedUsers, ReusedUsers, UpdatedUsers, ManagersSet, UsersLocked, MembershipsApplied, GrantsNotYetShown, Users and Errors. Nothing is written to the pipeline otherwise. + +## NOTES + +Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + +## RELATED LINKS + +- [New-OneLoginCustomAttribute]() +- [New-OneLoginRole]() +- [New-OneLoginGroup]() + diff --git a/docs/TestEnvironment/TestEnvironment.md b/docs/TestEnvironment/TestEnvironment.md index dfb0868..11d7a5d 100644 --- a/docs/TestEnvironment/TestEnvironment.md +++ b/docs/TestEnvironment/TestEnvironment.md @@ -314,6 +314,82 @@ Creates the seeded Okta users from Data\OktaUsers.csv Creates the second Okta user type and extends its schema +### [Get-OneLoginAppCredential](Get-OneLoginAppCredential.md) + +Returns the saved client id and secret of seeded OneLogin apps as credentials + +### [New-OneLoginApiAuthorization](New-OneLoginApiAuthorization.md) + +Creates the seeded API authorization servers with their scopes and claims, and lets seeded apps ask for them + +### [New-OneLoginApp](New-OneLoginApp.md) + +Creates the seeded OIDC and SAML apps and grants them to their roles + +### [New-OneLoginAppRule](New-OneLoginAppRule.md) + +Creates the seeded app rules, which hand a seeded app's groups claim out by seeded role + +### [New-OneLoginCustomAttribute](New-OneLoginCustomAttribute.md) + +Creates the custom user fields the seed needs, including its ownership marker + +### [New-OneLoginGroup](New-OneLoginGroup.md) + +Creates the seeded groups that the people being seeded will be placed in + +### [New-OneLoginMapping](New-OneLoginMapping.md) + +Creates the seeded user mappings, each gated so it can only ever act on seeded people + +### [New-OneLoginMfaFactor](New-OneLoginMfaFactor.md) + +Pre-enrols the seeded people's MFA factors, where the account already offers the factor + +### [New-OneLoginPolicy](New-OneLoginPolicy.md) + +Creates the seeded user security policies and attaches each to seeded groups only + +### [New-OneLoginRole](New-OneLoginRole.md) + +Creates the seeded roles that the people being seeded will hold + +### [New-OneLoginSelfRegistration](New-OneLoginSelfRegistration.md) + +Creates the seeded self-registration profile, always disabled, moderated and open to the lab domain only + +### [New-OneLoginSmartHook](New-OneLoginSmartHook.md) + +Creates the seeded Smart Hook, always disabled and gated on a seeded role + +### [New-OneLoginUser](New-OneLoginUser.md) + +Creates the seeded people with their lifecycle, group, manager and roles, and puts a reused one back as the data describes + +### [New-PingOneApplication](New-PingOneApplication.md) + +Creates the seeded applications across every protocol and grants them their resource scopes + +### [New-PingOneGroup](New-PingOneGroup.md) + +Creates the seeded groups, nests them, and gives the dynamic ones their filters + +### [New-PingOnePopulation](New-PingOnePopulation.md) + +Creates the seeded populations, which are what teardown asks rather than guessing from names + +### [New-PingOneProfileAttribute](New-PingOneProfileAttribute.md) + +Creates the custom user schema attributes the seed needs, including its ownership marker + +### [New-PingOneResource](New-PingOneResource.md) + +Creates the seeded custom resources and the scopes on them + +### [New-PingOneUser](New-PingOneUser.md) + +Creates the seeded people in their populations, tags them, and applies their group memberships + ### [New-TestEnvironment](New-TestEnvironment.md) Seeds the complete test environment through the active provider diff --git a/en-US/TestEnvironment-Help.xml b/en-US/TestEnvironment-Help.xml index 8454f91..982a7bf 100644 --- a/en-US/TestEnvironment-Help.xml +++ b/en-US/TestEnvironment-Help.xml @@ -816,6 +816,170 @@ Get-ADTestPasswordFromVault -ListSecrets -IncludeExpired + + + Get-OneLoginAppCredential + + Returns the saved client id and secret of seeded OneLogin apps as credentials + + Get + OneLoginAppCredential + + + Reads back the client secrets New-OneLoginApp -SaveAppSecret saved, one credential per app, so a relying party can be configured to sign in through a seeded app. The client id is the credential's user name and the secret its password; the secret is never written to the pipeline as text. + +Only the apps created with -SaveAppSecret have a record, and only the ones that authenticate with a secret: Expenses Web, which sends it with HTTP Basic, and Payroll Console, which posts it in the body. The public and native clients and the SAML app have no secret to save. OneLogin shows an app's secret once, when the app is created, so an app that already existed when the seed ran has no record; delete it and seed again, or regenerate its secret in the portal. + +The seeded apps' redirect URLs are under the lab email domain, example.com by default, so a test client has to be reachable there - a hosts entry, or the connection's -EmailDomain set to a domain you control - or have its own redirect URL added to the app in the portal. The client credentials grant is not enabled on the seeded apps; a token request made with it is refused. + +A secret saved in the SecretStore is read from the vault, which may need its password. + +Remove-TestEnvironment deletes each record with its app, and any record whose app is no longer in the account. + + + + Get-OneLoginAppCredential + + Key + + string[] + + System.String[] + + + + Subdomain + + string + + System.String + + + + VaultPassword + + securestring + + System.Security.SecureString + + + + + + + Key + + Return only the apps with these keys from the seed data, such as expenses. Every saved app by default. + + System.String[] + + System.String[] + + + + Subdomain + + The account whose saved secrets to read. The connected account by default. + + System.String + + System.String + + + + VaultPassword + + The SecretStore vault's password, when a secret is kept there and the vault is not the module default. + + System.Security.SecureString + + System.Security.SecureString + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + One object per saved app with Key, Name, AppId, Subdomain, Protection and Credential, a PSCredential whose user name is the client id and whose password is the client secret. The secret is never on the pipeline as text. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: List every saved app credential --------- + + ```powershell +Get-OneLoginAppCredential +``` + € + DESCRIPTION: Lists every saved app credential for the connected account +OUTPUT: One object per app, the secret held in a PSCredential +USE CASE: Seeing which seeded apps have a saved secret + + + + + + --------- Example 2: Copy one app's secret --------- + + ```powershell +$app = Get-OneLoginAppCredential -Key expenses +$app.Credential.GetNetworkCredential().Password | Set-Clipboard +``` + € + DESCRIPTION: Copies the Expenses Web client secret to the clipboard +OUTPUT: None +USE CASE: Pasting the secret into a test client that signs in to the seeded app + + + + + + --------- Example 3: See which apps have a saved secret, and how it is protected --------- + + ```powershell +Get-OneLoginAppCredential | Select-Object Key, Name, AppId, Protection +``` + € + DESCRIPTION: Lists the saved apps without touching the secrets +OUTPUT: One row per app - expenses and payroll after a seed with -SaveAppSecret - with DPAPI or SecretStore +USE CASE: Checking what a seed left on this machine before handing it to somebody else + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/Get-OneLoginAppCredential.md + + + New-OneLoginApp + + + + Get-TestAccessToken @@ -15176,6 +15340,2416 @@ New-OktaUserType + + + New-OneLoginApiAuthorization + + Creates the seeded API authorization servers with their scopes and claims, and lets seeded apps ask for them + + New + OneLoginApiAuthorization + + + An API authorization server is what an OpenID Connect app asks for an access token to call an API, so it is where a review of which app may do what to which API looks. Two are seeded: + +- Orders API, with a sixty-minute token and three scopes - read, write and admin - of which admin is granted to no app, and a claim read from the zztest_cost_center custom field. Expenses Web may ask for read; Contractor Portal SPA, a public client, may ask for read and write. +- Reports API, with a ten-minute token and one scope, which only Payroll Console may ask for. + +Each server's audience is https://api.<lab email domain>/<path>, under example.com by default, never a real host. Its description carries the seed tag inside a sentence, and teardown requires that tag and the prefix on the name together. + +Only a seeded app is ever made a client of a seeded server, and only a seeded server is ever given one, so no app outside the seed can ask for a seeded API's tokens and no seeded API issues tokens to one. There is no parameter to name another app. Scopes, claims and clients already on a reused server are kept, and only what the data adds is sent. + + + + New-OneLoginApiAuthorization + + Key + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalApiAuthorizations, CreatedApiAuthorizations, ReusedApiAuthorizations, ScopesAdded, ClaimsAdded, ClientsLinked, ApiAuthorizations and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create both seeded servers --------- + + ```powershell +New-OneLoginApiAuthorization +``` + € + DESCRIPTION: Creates Orders API and Reports API with their scopes, claims and clients +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the apps exist + + + + + + --------- Example 2: Create one server and see what it holds --------- + + ```powershell +New-OneLoginApiAuthorization -Key orders-api -PassThru +``` + € + DESCRIPTION: Creates or reuses Orders API +OUTPUT: A result object with ScopesAdded, ClaimsAdded and ClientsLinked +USE CASE: Testing an access review against an API with a scope nobody holds + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginApiAuthorization -WhatIf +``` + € + DESCRIPTION: Shows every server, scope, claim and client link that would be made, making none +OUTPUT: A What if: line per object +USE CASE: Checking every client named is a seeded app + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginApiAuthorization.md + + + New-OneLoginApp + + + + Test-TestEnvironment + + + + + + + New-OneLoginApp + + Creates the seeded OIDC and SAML apps and grants them to their roles + + New + OneLoginApp + + + Five apps, because a OneLogin trial allows five, across the two protocols and every OpenID Connect client shape the generic connector offers: + +- Expenses Web, a confidential web client authenticating with HTTP Basic, granted to All Staff. +- Payroll Console, a confidential web client that posts its secret in the body, granted to Finance, whose audience a mapping widens. +- Contractor Portal SPA, a public client. On OneLogin a public client is chosen by token endpoint authentication None, and that is PKCE: the connector offers no public client without it. +- Field App, 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, a SAML app built on the SAML Custom Connector (Advanced), so anything that assumes every app has a client id has one that does not. It is granted to two roles. + +The description carries the seed tag inside a sentence - "Seeded by TestEnvironment. Safe to delete." - so an administrator who finds one in a production portal knows what made it. Teardown requires the tag in the description and the prefix on the name together. A prefixed app that already exists without the tag is left alone and granted to nothing. + +OneLogin returns a new app's client secret in the response to its creation, and no later read of the app returns it. By default only the id is kept and the secret is dropped: nothing in the module needs it. With -SaveAppSecret, the secrets of the two confidential clients created in this run - Expenses Web and Payroll Console - are kept, DPAPI-protected in a record under ~/.testenvironment or in a SecretStore vault with -UseSecretStore, and Get-OneLoginAppCredential reads them back as credentials. Remove-TestEnvironment deletes each saved secret with its app, and any whose app is no longer in the account, so they do not build up. + +Every URL is written against the connection's email domain, under example.com by default, never a real host. + +Apps are granted to roles here, one request per role, and only to roles the seed may use: its own, or an empty one of its names. What a role already holds is read and kept, because the endpoint sets a role's apps rather than adding to them. + + + + New-OneLoginApp + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + VaultPassword + + securestring + + System.Security.SecureString + + + + SaveAppSecret + + + System.Management.Automation.SwitchParameter + + + + UseSecretStore + + + System.Management.Automation.SwitchParameter + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. +Create only the rows with these keys, from the seed data file. +All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + SaveAppSecret + + Keep the client secret of each confidential app this run creates - Expenses Web and Payroll Console - protected, so a sign-in can be tested against it with Get-OneLoginAppCredential. Off by default: nothing in the module needs the secrets. + OneLogin shows an app's secret only in the answer to its creation, so an app that already existed has none to save; it is named in SecretsUnavailable and a warning. The public and native clients and the SAML app have no secret. Remove-TestEnvironment deletes each saved secret with its app. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Grant apps only to the roles the chosen tiers create. Both tiers by default. With -Tier Bulk alone no role is created, so the apps are created and granted to nothing. +Grant apps only to the roles the chosen tiers create. +Both tiers by default. +With -Tier Bulk alone no role is created, so the apps are created and granted to nothing. + + System.String[] + + System.String[] + + + + UseSecretStore + + With -SaveAppSecret, keep the secrets in a SecretStore vault rather than DPAPI-protected in their records under ~/.testenvironment. + + + System.Management.Automation.SwitchParameter + + + + VaultPassword + + With -UseSecretStore, the vault's password when it is not the module default. + + System.Security.SecureString + + System.Security.SecureString + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalApps, CreatedApps, ReusedApps, GrantsApplied, Apps and Errors. Apps carries each app's key, name, id and connector, and never a client secret. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create every seeded app --------- + + ```powershell +New-OneLoginApp +``` + € + DESCRIPTION: Creates the five apps and grants them to their roles +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the roles exist + + + + + + --------- Example 2: Create the SAML app alone --------- + + ```powershell +New-OneLoginApp -Key wiki-saml -PassThru +``` + € + DESCRIPTION: Creates Wiki SAML and grants it to All Staff and Contractors +OUTPUT: A result object with its id and the grants applied - never a secret +USE CASE: Testing how a tool handles an app with no client id + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginApp -WhatIf +``` + € + DESCRIPTION: Shows every app and grant that would be made, making none +OUTPUT: A What if: line per app and per role granted to +USE CASE: Checking the seed fits under the account's app limit before it runs + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginApp.md + + + New-OneLoginRole + + + + New-OneLoginUser + + + + + + + New-OneLoginAppRule + + Creates the seeded app rules, which hand a seeded app's groups claim out by seeded role + + New + OneLoginAppRule + + + An app rule decides what an app hands a person when they sign in, by condition, so what a token carries depends on rules as well as on grants. Two are seeded: + +- Directory groups for staff, on Expenses Web, enabled: anybody in All Staff gets their directory group names in the groups claim, so the claim changes when group membership does. +- Groups for everyone outside Finance, on Payroll Console, disabled: it would hand the payroll app every group of everyone who is not in Finance if anybody enabled it, the dormant rule an access review has to find. + +A rule goes only on a proved seeded app and names only a role the seed may use. A rule naming somebody else's role would give the people holding it a claim on a seeded app; a rule on somebody else's app would change what that app gives its real users. The action is fixed by the provider, not taken from the data: set the groups claim from member_of, which on a seeded person names seeded groups in the lab domain and nothing else. There is no parameter for either. + +A rule whose app or role the tiers being seeded do not create is skipped rather than created against nothing. Teardown proves a rule by the seeded app it sits on and the prefix on its name, and removes it with the app unless the app is kept. + + + + New-OneLoginAppRule + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Create only the rules whose role the chosen tiers create. Both tiers by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalAppRules, CreatedAppRules, ReusedAppRules, SkippedAppRules, AppRules and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create both seeded rules --------- + + ```powershell +New-OneLoginAppRule +``` + € + DESCRIPTION: Creates the enabled rule on Expenses Web and the disabled one on Payroll Console +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the apps and roles exist + + + + + + --------- Example 2: Create the dormant rule alone --------- + + ```powershell +New-OneLoginAppRule -Key payroll-dormant -PassThru +``` + € + DESCRIPTION: Creates the disabled rule on Payroll Console +OUTPUT: A result object naming it +USE CASE: Testing whether a review of app rules reports a disabled one + + + + + + --------- Example 3: Read the rules back --------- + + ```powershell +Get-TestEnvironmentReport -PassThru | Select-Object -ExpandProperty AppRules +``` + € + DESCRIPTION: Lists the seeded rules with their app and whether each is enabled +OUTPUT: One row per rule +USE CASE: Confirming each rule sits on a seeded app + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginAppRule.md + + + New-OneLoginApp + + + + New-OneLoginRole + + + + + + + New-OneLoginCustomAttribute + + Creates the custom user fields the seed needs, including its ownership marker + + New + OneLoginCustomAttribute + + + A OneLogin user has no description field and nothing else that is free text nobody writes: comment, company, department and title are all things an administrator fills in. So the seed creates a custom user field of its own, zztest_seed_tag, writes ZZ-TEST-seed into it on every person it makes, and teardown proves a person is seeded by that value together with the prefix on the username. The field is removed last at teardown, after every person who carries it. + +The other fields are there because they are awkward in the ways scripts get wrong: + +- zztest_badge_id is empty on some people, so a report has to cope with a field that is simply absent. +- zztest_contractor is a boolean stored as text, because OneLogin custom fields are text. The string false is not falsy in PowerShell, so anything casting it rather than comparing it reads every person as a contractor. +- zztest_cost_center is shared by whole departments, which a mapping or a report can group on. + +Every shortname starts with zztest_. A field has no description, so the shortname is the only part of it the seed controls, and teardown removes a field only when the seed data declares it and its shortname carries that prefix. Shortnames take letters, digits and underscores only; OneLogin refuses a dash as invalid. + +Re-running is safe: a field that already exists is reused and never replaced, because replacing it would drop the value every seeded person holds in it. + + + + New-OneLoginCustomAttribute + + Shortname + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Shortname + + Create only the fields with these shortnames. All of them by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalAttributes, CreatedAttributes, ReusedAttributes, Attributes and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create every seeded field --------- + + ```powershell +New-OneLoginCustomAttribute +``` + € + DESCRIPTION: Creates the seed tag field and the three lab fields +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before anybody is created + + + + + + --------- Example 2: Create the ownership marker alone --------- + + ```powershell +New-OneLoginCustomAttribute -Shortname zztest_seed_tag -PassThru +``` + € + DESCRIPTION: Creates only the field every seeded person is tagged in +OUTPUT: A result object naming it and its id +USE CASE: Preparing an account before seeding people by hand + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginCustomAttribute -WhatIf +``` + € + DESCRIPTION: Shows every field that would be created, creating none +OUTPUT: A What if: line per field +USE CASE: Checking what the seed adds to a production account's schema + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginCustomAttribute.md + + + New-OneLoginUser + + + + Connect-TestEnvironment + + + + + + + New-OneLoginGroup + + Creates the seeded groups that the people being seeded will be placed in + + New + OneLoginGroup + + + A OneLogin person is in one group at most, and a group is where a security policy applies, so the seeded groups follow office location the way a real account's do: Seattle HQ, London, New York, US Regional Offices and Remote Workers. Seattle HQ holds most of the people, which makes it the group a per-group report is dominated by. + +A group is created with a name and nothing else. New-OneLoginPolicy attaches the seeded policies afterwards, and only ever a seeded policy to a seeded group: a policy that already exists is never attached, because it would change how the group's real members sign in. + +A group, like a role, has nothing but its name to say who made it, so teardown proves one by its members: the prefix, no administrators, at least one member, every member a seeded person, and no policy unless it is a prefixed one that is not the account's default. A group nobody in the tiers being seeded will be placed in is therefore not created. A prefixed group that already exists and holds somebody the seed did not make, or has another policy or an administrator, is left alone and reported, and nobody is put in it. + +People are placed in their groups by New-OneLoginUser, as each is created. + + + + New-OneLoginGroup + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Seed for the Core people only (the hand-designed edge cases) or the Bulk people only (the generated volume). Both by default. + A role or group that nobody in the chosen tiers would hold is not created, because OneLogin gives a role nothing but its name and teardown proves one by the seeded people it holds: an empty one could never be claimed and would be left behind. Bulk people are unlicensed and hold no roles, so -Tier Bulk alone creates no role at all. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalGroups, CreatedGroups, ReusedGroups, SkippedGroups, Groups and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create every seeded group --------- + + ```powershell +New-OneLoginGroup +``` + € + DESCRIPTION: Creates the five office groups +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before the people who belong in them + + + + + + --------- Example 2: Create two groups --------- + + ```powershell +New-OneLoginGroup -Key london, new-york -PassThru +``` + € + DESCRIPTION: Creates the London and New York groups +OUTPUT: A result object naming them and their ids +USE CASE: Testing how a report treats groups outside headquarters + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginGroup -WhatIf +``` + € + DESCRIPTION: Shows every group that would be created, creating none +OUTPUT: A What if: line per group +USE CASE: Checking the seed will not collide with an existing group of the same name + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginGroup.md + + + New-OneLoginUser + + + + New-OneLoginRole + + + + + + + New-OneLoginMapping + + Creates the seeded user mappings, each gated so it can only ever act on seeded people + + New + OneLoginMapping + + + A OneLogin mapping is a rule that changes people who match its conditions, and an enabled one acts on everybody in the account who matches. That is why every mapping this creates carries one more condition than the seed data gives it: the zztest_seed_tag field must equal ZZ-TEST-seed, with match all. Nobody but this module writes that field, so an enabled seeded mapping can act on seeded people and nobody else, which is what makes it safe to seed one into an account real people sign in to. The condition is added by the code, not the data, and there is no parameter to leave it out; a test asserts that there is none. + +Every seeded mapping does one thing, add a seeded role. Two are seeded: + +- Finance department gets Finance, enabled. It puts a person into Finance whom the data never lists there, so the payroll app's audience is wider than any list of explicit grants says, and verification has to judge role membership on what is missing only. +- Contractors get Engineering, disabled. It would put every contractor into Engineering if anybody enabled it: the dormant rule an access review has to find. + +Mappings are created before people, because OneLogin runs an enabled mapping as each person is created; that was verified against a live account. + +Teardown proves a mapping by its prefix, the gate, match all, and actions that add only proved seeded roles. A prefixed mapping that already exists without the gate is left alone and reported. + + + + New-OneLoginMapping + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Create only the mappings whose role the chosen tiers create. Both tiers by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalMappings, CreatedMappings, ReusedMappings, SkippedMappings, Mappings and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create both seeded mappings --------- + + ```powershell +New-OneLoginMapping +``` + € + DESCRIPTION: Creates the enabled Finance mapping and the disabled contractor one, each gated on the seed tag +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, after the roles and before the people + + + + + + --------- Example 2: Create the dormant rule alone --------- + + ```powershell +New-OneLoginMapping -Key contractor-eng -PassThru +``` + € + DESCRIPTION: Creates the disabled mapping that would widen Engineering +OUTPUT: A result object naming it, with Enabled False +USE CASE: Testing whether an access review finds a disabled rule + + + + + + --------- Example 3: Read what a seeded mapping really does --------- + + ```powershell +Get-TestEnvironmentReport -PassThru | Select-Object -ExpandProperty Mappings +``` + € + DESCRIPTION: Lists the seeded mappings with their condition and action counts +OUTPUT: One row per mapping; each has one more condition than the data, the seed-tag gate +USE CASE: Confirming every seeded mapping is gated + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginMapping.md + + + New-OneLoginRole + + + + New-OneLoginUser + + + + + + + New-OneLoginMfaFactor + + Pre-enrols the seeded people's MFA factors, where the account already offers the factor + + New + OneLoginMfaFactor + + + Three Core people - awhitfield, praghunathan and talvarez - are given an email factor in the seed data, so a report of who has MFA has somebody to find and a larger number of people without it. + +A factor is enrolled only where the account already offers it to that person. Whether a factor is offered is an account-wide setting, and turning one on would change what every real person is asked for at sign-in, so the seed never does; in an account that offers none, as a trial does, every person is reported as skipped and nothing is sent. + +A factor is enrolled on proved seeded people only, and only ever as already verified, so no code or message is sent to anybody - and every seeded address is at the lab domain, which cannot receive one. A person who already holds a factor of that name keeps it. + +Teardown removes factors with the people who hold them. + + + + New-OneLoginMfaFactor + + Username + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Enrol only the Core people or only the Bulk people. Both by default; only Core people are given a factor. + + System.String[] + + System.String[] + + + + Username + + Enrol only the people with these keys from the seed data - the username without the prefix, such as talvarez. Everybody the data gives a factor by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalFactors, EnrolledFactors, ExistingFactors, Skipped, Factors and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Enrol every seeded factor --------- + + ```powershell +New-OneLoginMfaFactor +``` + € + DESCRIPTION: Enrols the email factor on the three people the data gives one, where the account offers it +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, as the last step + + + + + + --------- Example 2: See whether the account offers the factor --------- + + ```powershell +New-OneLoginMfaFactor -PassThru | Select-Object EnrolledFactors, Skipped +``` + € + DESCRIPTION: Attempts the enrolments and shows what happened +OUTPUT: The number enrolled, and the people skipped because the account offers no such factor +USE CASE: Deciding whether to turn a factor on in a lab account before seeding again + + + + + + --------- Example 3: Enrol one person --------- + + ```powershell +New-OneLoginMfaFactor -Username talvarez -PassThru +``` + € + DESCRIPTION: Enrols talvarez alone +OUTPUT: A result object naming the factor, or the reason it was skipped +USE CASE: Testing a report against a single person with MFA + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginMfaFactor.md + + + New-OneLoginUser + + + + Get-TestEnvironmentReport + + + + + + + New-OneLoginPolicy + + Creates the seeded user security policies and attaches each to seeded groups only + + New + OneLoginPolicy + + + A OneLogin user policy decides how the people in a group sign in: password length and lifetime, how many passwords are remembered, how many failed attempts lock an account and for how long. A group has one policy at most, and a group without one falls back to the account's default. Two are seeded, so that people in the same account are under different rules and a report that assumes one password policy is wrong: + +- Strict Office, fourteen characters, sixty days, twenty-four remembered, five attempts and a thirty-minute lock, on Seattle HQ and New York, which hold most of the seeded people. +- Contractor Access, twelve characters, thirty days, six remembered, three attempts and an hour's lock, on Remote Workers. + +London and US Regional Offices are given none and fall back to the default, which is a difference a review has to be able to explain. + +What keeps this from reaching anybody real, none of which has a parameter: + +- A policy is never made the account's default; is_default is never sent. The default decides how everybody without a group policy signs in. +- A policy is attached only to a group the seed may use: a seeded group, or an empty one of the seed's names. A group that holds anybody else is reported and left, because the policy would then govern how they sign in. +- A policy that already exists under a seeded name is reused only when it is not the default and no group outside the seed uses it. Otherwise it is left alone, attached to nothing and reported, because it governs somebody the seed did not make. + +A policy has no description, so teardown proves one by the seeded groups using it, and a policy no group in the tiers being seeded would use is not created. Its settings are put back as the data describes on every run. + + + + New-OneLoginPolicy + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Create only the policies whose groups the chosen tiers fill, and attach them to those groups only. Both tiers by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalPolicies, CreatedPolicies, ReusedPolicies, SkippedPolicies, SettingsUpdated, GroupsAttached, Policies and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create both seeded policies --------- + + ```powershell +New-OneLoginPolicy +``` + € + DESCRIPTION: Creates Strict Office and Contractor Access and attaches them to their groups +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the groups exist + + + + + + --------- Example 2: Put one policy's settings back --------- + + ```powershell +New-OneLoginPolicy -Key strict-office -PassThru +``` + € + DESCRIPTION: Reuses Strict Office and rewrites any setting that no longer matches the data +OUTPUT: A result object; SettingsUpdated counts the policies that were changed back +USE CASE: Repairing a seed after somebody loosened a password rule in the portal + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginPolicy -WhatIf +``` + € + DESCRIPTION: Shows every policy and every group attachment that would be made, making none +OUTPUT: A What if: line per policy and per group +USE CASE: Confirming no attachment would land on a group that is not the seed's + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginPolicy.md + + + New-OneLoginGroup + + + + Test-TestEnvironment + + + + + + + New-OneLoginRole + + Creates the seeded roles that the people being seeded will hold + + New + OneLoginRole + + + OneLogin grants app access through roles, so roles are where access lives in a seeded account. There are four, because a OneLogin trial allows five roles and the account's Default role is one of them: + +- All Staff, the broad role nearly every licensed employee holds and most apps are granted through. It holds a suspended manager and people whose passwords have lapsed. +- Engineering, a department role, and the one the disabled mapping would hand to every contractor if anybody enabled it. +- Finance, which the data gives one member and the enabled mapping gives a second, so who can reach the payroll app depends on whether mappings have run. +- Contractors, held by one contractor while another is licensed, still waiting for a password, and holds nothing. + +A role has nothing but its name to say who made it, so teardown proves one by what it holds: the prefix, no administrators, at least one member, and every member a seeded person and every app a seeded app. A role that nobody in the tiers being seeded will hold is therefore not created. A prefixed role that already exists and holds somebody the seed did not make, or has an administrator, is left alone and reported, and nothing is ever added to it. + +People are not added here. A role has to exist before anybody can be put in it, so New-OneLoginUser adds each person to their roles once they exist. + + + + New-OneLoginRole + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Seed for the Core people only (the hand-designed edge cases) or the Bulk people only (the generated volume). Both by default. + A role or group that nobody in the chosen tiers would hold is not created, because OneLogin gives a role nothing but its name and teardown proves one by the seeded people it holds: an empty one could never be claimed and would be left behind. Bulk people are unlicensed and hold no roles, so -Tier Bulk alone creates no role at all. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalRoles, CreatedRoles, ReusedRoles, SkippedRoles, Roles and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create every seeded role --------- + + ```powershell +New-OneLoginRole +``` + € + DESCRIPTION: Creates the four roles +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, before apps are granted to them + + + + + + --------- Example 2: Create one role and see what happened --------- + + ```powershell +New-OneLoginRole -Key finance -PassThru +``` + € + DESCRIPTION: Creates Finance alone +OUTPUT: A result object naming it, or an error naming the plan limit if the account allows no more roles +USE CASE: Rebuilding one role after it was deleted by hand + + + + + + --------- Example 3: Create only what a Bulk seed would fill --------- + + ```powershell +New-OneLoginRole -Tier Bulk -PassThru +``` + € + DESCRIPTION: Creates nothing, and lists every role as skipped, because Bulk people are unlicensed and hold no roles +OUTPUT: A result object whose SkippedRoles names all four +USE CASE: Seeing why a Bulk-only seed has no roles + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginRole.md + + + New-OneLoginUser + + + + New-OneLoginApp + + + + New-OneLoginMapping + + + + + + + New-OneLoginSelfRegistration + + Creates the seeded self-registration profile, always disabled, moderated and open to the lab domain only + + New + OneLoginSelfRegistration + + + A self-registration profile is a public page where anybody can create themselves an account, so enabling one is a single click that changes who can get in. One is seeded, Partner Sign-up, for an access review to find. + +It is created in the one shape that cannot admit anybody, and none of it has a parameter: + +- Disabled, so there is no public page. +- Moderated, so even enabled it admits nobody until an administrator approves them. +- Open only to the lab email domain, under example.com by default, which RFC 2606 reserves, so no real address can register. +- With no default role and no default group, so a registrant could never land in anything. + +A re-run puts that shape back if anybody loosened it. The help text carries the seed tag inside a sentence, and teardown requires that tag and the prefix on the name together; a prefixed profile without the tag is left alone. + + + + New-OneLoginSelfRegistration + + Key + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalSelfRegistrations, CreatedSelfRegistrations, ReusedSelfRegistrations, Restored, SelfRegistrations and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create the seeded profile --------- + + ```powershell +New-OneLoginSelfRegistration +``` + € + DESCRIPTION: Creates Partner Sign-up, disabled and moderated +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment + + + + + + --------- Example 2: Put the safe shape back --------- + + ```powershell +New-OneLoginSelfRegistration -PassThru +``` + € + DESCRIPTION: Reuses the profile and disables and restricts it again if it was loosened +OUTPUT: A result object; Restored is 1 when it had been changed +USE CASE: Repairing the seed after somebody enabled the page + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginSelfRegistration -WhatIf +``` + € + DESCRIPTION: Shows the profile that would be created, creating none +OUTPUT: A What if: line +USE CASE: Checking the name does not collide with a real profile + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginSelfRegistration.md + + + New-OneLoginUser + + + + Test-TestEnvironment + + + + + + + New-OneLoginSmartHook + + Creates the seeded Smart Hook, always disabled and gated on a seeded role + + New + OneLoginSmartHook + + + A Smart Hook is code OneLogin runs during a sign-in, so it belongs in any review of what can change how people sign in. One is seeded: a pre-authentication hook, whose handler changes nothing - it hands back the policy the person already has. + +What keeps it from reaching anybody real, none of which has a parameter: + +- It is created disabled, and a re-run disables it again if somebody enabled it. +- It carries one condition, membership of the seeded Contractors role, so even enabled it would run for seeded people and nobody else. +- The first line of its code is the marker "// Seeded by TestEnvironment. Safe to delete. [ZZ-TEST-seed]". A hook has no name or description, so that line is what teardown proves it by, together with conditions that name only seeded roles. + +OneLogin allows one hook of each type in an account. An account that already has its own pre-authentication hook keeps it untouched: OneLogin refuses the seed's create, and the step reports the refusal and suggests -Skip Hooks. A hook whose role the tiers being seeded do not create is skipped. The code runs on the nodejs22.x runtime. + + + + New-OneLoginSmartHook + + Key + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + Key + + Create only the rows with these keys, from the seed data file. All of them by default. + + System.String[] + + System.String[] + + + + PassThru + + Return a result object describing what was created, reused and refused. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Create the hook only when the chosen tiers create the role it is gated on. Both tiers by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalHooks, CreatedHooks, ReusedHooks, SkippedHooks, DisabledAgain, Hooks and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create the seeded hook --------- + + ```powershell +New-OneLoginSmartHook +``` + € + DESCRIPTION: Creates the disabled pre-authentication hook gated on Contractors +OUTPUT: None +USE CASE: Run for you by New-TestEnvironment, once the roles exist + + + + + + --------- Example 2: Make sure it is off --------- + + ```powershell +New-OneLoginSmartHook -PassThru +``` + € + DESCRIPTION: Reuses the seeded hook and disables it again if it was enabled +OUTPUT: A result object; DisabledAgain is 1 when it had been turned on +USE CASE: Putting the seed back after somebody experimented in the portal + + + + + + --------- Example 3: Preview in an account you care about --------- + + ```powershell +New-OneLoginSmartHook -WhatIf +``` + € + DESCRIPTION: Shows the hook that would be created, creating none +OUTPUT: A What if: line +USE CASE: Checking whether the account already has a hook of that type + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginSmartHook.md + + + New-OneLoginRole + + + + Remove-TestEnvironment + + + + + + + New-OneLoginUser + + Creates the seeded people with their lifecycle, group, manager and roles, and puts a reused one back as the data describes + + New + OneLoginUser + + + Creates every seeded person with their lifecycle status and state, their group, their manager and their custom fields in one request, in the order the seed data lists them, which puts every manager before the people who report to them. Then adds each person to their roles, one request per role. + +The people are built around what OneLogin does without saying so, each verified against a live trial account: + +- A person is approved only while the account has a user licence for them. Beyond that OneLogin makes them Unlicensed, and answers the create as if it had not. A trial has twelve licences, the owner among them, so ten Core people are approved. The writing-system cohort is unlicensed, because what those people test is their names; and every Bulk person is unlicensed on purpose, so a seed spends no licence on a trial or a paid account. This step reads the people back once and reports anybody OneLogin left unlicensed. +- A role grant is accepted for anyone and kept only for an approved person whose status is Active, Suspended, Locked, PasswordExpired or AwaitingPasswordReset. The data gives roles to those people only. +- A rejected person is kept out of any group as well as any role. +- Unactivated and Unapproved do not stay put, so the data does not ask for them. +- A status of Locked sent on a create or an update does not hold either. A person the data gives as Locked is created Active and then locked for a year through OneLogin's lock call, which holds on a licensed person only; a re-run locks them again when less than a month of the lock is left. + +Every person also carries the directory identifiers a synchronised account would: a sAMAccountName, a user principal name, a distinguished name, the distinguished names of their groups and roles as member_of, an external id, a phone number and a comment saying what made them. Each is built from the prefix and the connection's email domain - the sAMAccountName and external id start with the prefix, the names sit under OU=<prefix>Users and OU=<prefix>Groups of a domain made from the lab email domain, and every phone number is in the 555-0100 to 555-0199 range reserved for fiction - so no identifier can match an account a real directory synchronises, and a tool that joins on one of them finds the seed and nothing else. + +Nobody who is not seeded is ever touched. The people this step adds to roles and names as managers are the ones it created and the ones it proved seeded - by the tag in zztest_seed_tag and the prefix on the username - and the roles and groups it uses are empty or hold seeded people alone. A create refused because the username already belongs to somebody else is reported and left. + +Re-running is safe. A person already seeded is reused and put back as the data describes: names, title, department, company, status, state, group, manager, custom fields and directory identifiers, sending only what differs. Names are compared by codepoint, so a decomposed name that came back precomposed is corrected. + +OneLogin settles role membership after it is written. A role can read as empty for a short while after its grants were sent. + + + + New-OneLoginUser + + Username + + string[] + + System.String[] + + + + Tier + + string[] + + System.String[] + + + + ShowProgress + + + System.Management.Automation.SwitchParameter + + + + PassThru + + + System.Management.Automation.SwitchParameter + + + + WhatIf + + + System.Management.Automation.SwitchParameter + + + + Confirm + + + System.Management.Automation.SwitchParameter + + + + + + + Confirm + + Prompts you for confirmation before running the cmdlet. + + + System.Management.Automation.SwitchParameter + + + + PassThru + + Return a result object with TotalUsers, CreatedUsers, ReusedUsers, UpdatedUsers, ManagersSet, UsersLocked, MembershipsApplied, GrantsNotYetShown, Users and Errors. + + + System.Management.Automation.SwitchParameter + + + + ShowProgress + + Show a progress bar, one step per person. + + + System.Management.Automation.SwitchParameter + + + + Tier + + Seed only the Core people (the hand-designed edge cases) or only the Bulk people (the generated volume). Both by default. + + System.String[] + + System.String[] + + + + Username + + Seed only the people with these keys from the seed data - the username without the prefix, such as jnino. All of them by default. + + System.String[] + + System.String[] + + + + WhatIf + + Runs the command in a mode that only reports what would happen without performing the actions. + + + System.Management.Automation.SwitchParameter + + + + + + + None + + + This command does not accept pipeline input. + + + + + + + System.Management.Automation.PSObject + + + Only when -PassThru is supplied: a summary with TotalUsers, CreatedUsers, ReusedUsers, UpdatedUsers, ManagersSet, UsersLocked, MembershipsApplied, GrantsNotYetShown, Users and Errors. Nothing is written to the pipeline otherwise. + + + + + + Author: Jeffrey Stuhr +Blog: https://www.techbyjeff.net +LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ + + + + + --------- Example 1: Create every seeded person --------- + + ```powershell +New-OneLoginUser -ShowProgress +``` + € + DESCRIPTION: Creates the 321 seeded people, sets their managers and adds the licensed ones to their roles +OUTPUT: A progress bar, and nothing on the pipeline +USE CASE: Run for you by New-TestEnvironment, as the last step + + + + + + --------- Example 2: Create the designed people only --------- + + ```powershell +New-OneLoginUser -Tier Core -PassThru +``` + € + DESCRIPTION: Creates the twenty-one hand-designed people +OUTPUT: A result object with the counts, and an error naming anybody OneLogin left unlicensed +USE CASE: The fast loop, when the test is behaviour rather than scale + + + + + + --------- Example 3: Put two people back --------- + + ```powershell +New-OneLoginUser -Username mbell, jmarchetti -PassThru +``` + € + DESCRIPTION: Recreates or restores the suspended manager and the person who reports to him +OUTPUT: A result object; UpdatedUsers counts the people who were changed back +USE CASE: Repairing a seed after somebody edited those people in the portal + + + + + + + + Online Version + https://github.com/fadwen/TestEnvironment/blob/main/docs/TestEnvironment/New-OneLoginUser.md + + + New-OneLoginCustomAttribute + + + + New-OneLoginRole + + + + New-OneLoginGroup + + + + New-PingOneApplication diff --git a/en-US/about_TestEnvironment.help.txt b/en-US/about_TestEnvironment.help.txt index e1769d1..fe0a8f1 100644 --- a/en-US/about_TestEnvironment.help.txt +++ b/en-US/about_TestEnvironment.help.txt @@ -3,7 +3,7 @@ TOPIC SHORT DESCRIPTION Seeds a realistic identity test environment - Entra ID, Active Directory, - Okta, Authentik, FreeIPA or PingOne SSO - and tears it down again cleanly, + Okta, Authentik, FreeIPA, PingOne SSO or OneLogin - and tears it down again cleanly, proving ownership before deleting anything. LONG DESCRIPTION @@ -15,7 +15,7 @@ LONG DESCRIPTION This module creates those shapes deliberately, at a volume large enough to make performance visible, and removes them again without touching anything - it did not create. One surface covers six providers: + it did not create. One surface covers seven providers: Provider Reaches Needs Entra Microsoft Graph A tenant and a service app @@ -24,6 +24,7 @@ LONG DESCRIPTION Authentik The Authentik API An instance and a service account FreeIPA The FreeIPA JSON-RPC API A realm and a service account PingOne The PingOne platform API An environment and a worker app + OneLogin The OneLogin API An account and an API credential Every session follows the same four steps, and the provider is named once: @@ -121,6 +122,18 @@ WHAT EACH PROVIDER CREATES applications restricted to seeded groups and granted seeded scopes. New-PingOne*. + OneLogin + Custom user fields, one of them carrying the seed tag; people in every + lifecycle status and state OneLogin keeps, one of them locked, with + managers, office groups, directory identifiers and names beyond Latin, + ten of them licensed and the rest unlicensed on purpose; roles granted + only to people who can hold them; security policies on some of the + groups; OIDC web, public, native and SAML apps granted to those roles, + with app rules; API authorization servers with scopes, claims and + seeded clients; user mappings, one enabled and one disabled; a disabled + Smart Hook; a disabled self-registration profile; and MFA factors where + the account offers them. New-OneLogin*. + SOME SAFETY PROPERTIES HAVE NO PARAMETER A seeded Conditional Access policy is report-only or disabled, and the module cannot create an enforcing one. A seeded PIM role eligibility is eligible and @@ -129,7 +142,12 @@ SOME SAFETY PROPERTIES HAVE NO PARAMETER FreeIPA rule the realm shipped with - allow_all, the global password policy, a stock privilege - is never touched. A seeded PingOne population is never made the environment's default, and a public client is never - created without PKCE. None of these behaviours can be switched off, because + created without PKCE. A seeded OneLogin mapping always requires the seed + tag, with match all, so an enabled one can act on seeded people alone; its + Smart Hook and self-registration profile are always disabled, its policies + are never the default, and nothing it creates names a real person, role, + group or app. None + of these behaviours can be switched off, because a convenience switch is exactly how a test tool ends up locking real people out of a tenant. Each is pinned by tests, so that adding a switch is caught as the regression it would be. @@ -141,7 +159,8 @@ THE PREFIX AND THE SEED TAG description in Entra, a custom profile attribute in Okta, the free-form attributes of a user or group in Authentik, the userclass of a user or host and the description of everything else in FreeIPA, and a custom user - attribute and the description of everything else in PingOne. The prefix is the + attribute and the description of everything else in PingOne, and a custom + user field and an app's description in OneLogin. The prefix is the same across providers so that the module's objects can be found across a hybrid estate with one filter, and ZZ- sorts them to the bottom of a console listing, out of the way of real work. @@ -156,7 +175,10 @@ TEARDOWN PROVES OWNERSHIP created; the AD provider enumerates OU=TestData; the Okta provider reads the seed tag; the FreeIPA provider filters on the tag in userclass and requires the marker in a description; the PingOne provider enumerates the populations - it created and requires the tag and the prefix on everything else. When a container is gone, the fallback paths + it created and requires the tag and the prefix on everything else; the + OneLogin provider proves a role, group, policy, mapping, app rule or hook by + holding or naming seeded objects and nothing else, and leaves a prefixed one + it cannot prove alone. When a container is gone, the fallback paths still refuse objects that do not carry the module's tag. -WhatIf wins over -Force on every destructive command. Run @@ -183,6 +205,12 @@ CREDENTIALS AND THE SECRETSTORE console. Its secret is passed to Connect-TestEnvironment, and -SaveSecret keeps it under ~/.testenvironment for -UseStoredSecret to read later. + OneLogin authenticates as an API credential created by hand in the admin + portal with the scope Manage All. Its client id and secret are passed to + Connect-TestEnvironment, and -SaveSecret writes them under ~/.testenvironment, + or into the SecretStore with -UseSecretStore, for -UseStoredCredential to read + later. Disconnect-TestEnvironment revokes the token. + Active Directory uses the caller's own Windows identity and needs no credential, but New-ADTestServiceAccount can store the passwords it generates in the SecretStore for Get-ADTestPasswordFromVault to read later. @@ -196,7 +224,7 @@ CREDENTIALS AND THE SECRETSTORE REQUIREMENTS Windows PowerShell 5.1 or PowerShell 7. The module declares no required - modules: the Entra, Okta, Authentik, FreeIPA and PingOne providers run from any host, + modules: the Entra, Okta, Authentik, FreeIPA, PingOne and OneLogin providers run from any host, including a Linux container, and the AD provider imports RSAT's ActiveDirectory and GroupPolicy modules at connect time and says so clearly when they are absent.