Where We Left Off
In Part 1, a routine least-privilege project turned into a privileged-access audit. We found a disabled account holding Global Administrator, a sync scope that handed out cloud identities by luck, and a synced admin account with Owner on production.
This post is the project itself: the build that started it all.
The script onboards a new employee end to end. It creates the on-prem AD account, triggers an Entra Connect sync, waits for the user to appear in Entra ID, issues a Temporary Access Pass, adds the user to two application groups, and sends a welcome email through Azure Communication Services. It worked fine. It just ran as an account that was both a Domain Admin and a Global Administrator, because that was the easiest way to make every step succeed.
The goal was simple to state: a help desk tech runs the whole thing with their own standard account. Nobody types a privileged password, and nobody needs one.
The Map: Every Step, Its Old Privilege, Its Replacement
The trick to least privilege is refusing to think about "the script's permissions." A script doesn't need permissions. Each step needs something specific, and each step gets its own fix.
| Step | Ran as | Replaced with |
|---|---|---|
| Create the AD user, set the password | Domain Admin | OU delegation to a security group |
| Trigger the Entra Connect delta sync | Admin remoting into a Tier 0 server | A JEA endpoint exposing one command |
| Issue a Temporary Access Pass | Global Administrator | Authentication Administrator, eligible through PIM |
| Add the user to two application groups | Global Administrator | Group ownership, no directory role |
| Send the welcome email | An SMTP secret a human typed in | Key Vault, read with the tech's own identity |
None of these are exotic, and you can adopt each one independently. Let's take them in order.
About the Code in This Post
This post doesn't show the onboarding script itself. Most of the code below is the one-time setup that lets the script run as a standard user. An administrator runs each piece once, on a specific machine, with the elevated rights that particular change requires:
| One-time setup | Where it runs | Who runs it |
|---|---|---|
| OU delegation | A privileged access workstation (PAW) with the RSAT AD module | Domain Admin |
| JEA endpoint | On the Entra Connect server itself, elevated Windows PowerShell 5.1 | Local administrator on that server |
| PIM-eligible role and role-assignable group | Entra admin center | Privileged Role Administrator |
| Group ownership | Entra admin center or Graph PowerShell | Groups Administrator, or an existing group owner |
| Key Vault secret and access | Azure portal or Az PowerShell | Vault Owner or User Access Administrator |
Do all of it from a PAW: run the PowerShell there, sign in to the Entra admin center and Azure portal from there, and connect to the Entra Connect server from there. Privileged setup deserves a privileged workstation, even when it only happens once.
That's the trade at the heart of this approach. The privilege doesn't disappear. It gets used once, deliberately, by the right admin, to build narrow paths the help desk can use every day without it.
A few short snippets are different: the JEA call, the TAP retry loop, and the Key Vault read show (simplified) logic from the onboarding script. Those run every time, as the help desk tech's standard account, and they're labeled that way.
The complete setup scripts are on GitHub: least-privilege-onboarding-setup. They add -WhatIf, a read-only audit mode, pre-flight checks, and an uninstall option to what's shown here, and they're MIT-licensed. Test them in a lab first.
Step 1: Replace Domain Admin With OU Delegation
Creating a user and setting their password needs three rights. It doesn't need Domain Admin:
- Create user objects in the target OU
- Reset Password on user objects below it
- Read and write the attributes the script sets: display name, office, mail, description, and
userAccountControlto enable the account
Grant those to a security group, put the help desk techs in the group, and you're done. Here's the core of the setup. Run it once, as a Domain Admin, from a PAW with the RSAT Active Directory module:
Import-Module ActiveDirectory
$group = Get-ADGroup 'Onboarding-Provisioners'
$sid = [System.Security.Principal.SecurityIdentifier]$group.SID
$ou = 'OU=Users,OU=Staff,DC=somedomainsomewhere,DC=com'
$userClass = [Guid]'bf967aba-0de6-11d0-a285-00aa003049e2' # user class
$resetPassword = [Guid]'00299570-246d-11d0-a768-00aa006e0529' # Reset Password
$R = [System.DirectoryServices.ActiveDirectoryRights]
$allow = [System.Security.AccessControl.AccessControlType]::Allow
$all = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
$below = [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents
$rules = @(
# 1. Create user objects in this OU and its sub-OUs
[System.DirectoryServices.ActiveDirectoryAccessRule]::new(
$sid, $R::CreateChild, $allow, $userClass, $all)
# 2. Reset Password on user objects below this OU
[System.DirectoryServices.ActiveDirectoryAccessRule]::new(
$sid, $R::ExtendedRight, $allow, $resetPassword, $below, $userClass)
# 3. Read and write attributes on user objects below this OU
[System.DirectoryServices.ActiveDirectoryAccessRule]::new(
$sid, ($R::GenericRead -bor $R::GenericWrite), $allow, $below, $userClass)
)
$acl = Get-Acl -Path "AD:\$ou"
$rules | ForEach-Object { $acl.AddAccessRule($_) }
Set-Acl -Path "AD:\$ou" -AclObject $acl
The group can't delete objects, change group memberships, touch computers or GPOs, or affect anything outside the delegated OUs. The full version in the repository, Set-OnboardingDelegation.ps1, also has a read-only -Verify switch that prints the group's ACEs on each OU. You'll want that for the next audit. It also refuses to delegate on any OU that contains privileged accounts (adminCount=1), for the reason explained below.
Two things to know before you copy this:
- Members have to log off and back on (or wait for a Kerberos ticket refresh) before new group rights apply. A tech who's added to the group and tries immediately gets "access denied" and assumes the delegation is broken.
GenericWriteis broader than it looks. On a user object, it includes attributes likeservicePrincipalNameandmsDS-KeyCredentialLink, which are the raw material for targeted Kerberoasting and shadow-credential attacks. That's acceptable on an OU of ordinary staff accounts. It is not acceptable on an OU holding admin or service accounts. Never delegate on OUs containing privileged accounts. If your environment needs tighter control, replaceGenericWritewith property-specific write rights for just the attributes the script sets.
Step 2: Replace Admin Remoting With a JEA Endpoint
New accounts don't appear in Entra ID until Entra Connect syncs them. The scheduled cycle runs every 30 minutes, so the script triggers a delta sync to avoid the wait. That's one command, Start-ADSyncSyncCycle -PolicyType Delta, on the Entra Connect server.
The Entra Connect server is Tier 0. Whoever controls it can control identity sync for the whole tenant. "Give the help desk admin remoting so they can run one command" is not a trade anyone should make.
Just Enough Administration (JEA) solves this precisely. A JEA endpoint is a constrained PowerShell remoting endpoint: users who connect can run only the commands you make visible, with only the parameter values you allow.
The endpoint is set up once, on the Entra Connect server itself, by a local administrator in an elevated Windows PowerShell 5.1 session. First, the role capability defines what's visible. Here it's two commands, with PolicyType locked to Delta:
$module = "$env:ProgramFiles\WindowsPowerShell\Modules\SyncOps"
New-Item -ItemType Directory -Path "$module\RoleCapabilities" -Force | Out-Null
New-ModuleManifest -Path "$module\SyncOps.psd1"
New-PSRoleCapabilityFile -Path "$module\RoleCapabilities\SyncOps.psrc" `
-ModulesToImport 'ADSync' `
-VisibleCmdlets @(
@{ Name = 'Start-ADSyncSyncCycle'
Parameters = @( @{ Name = 'PolicyType'; ValidateSet = 'Delta' } ) },
'Get-ADSyncScheduler'
)
The session configuration says who may connect and what identity the session runs as:
$pssc = "$env:ProgramData\JEAConfiguration\SyncOps.pssc"
New-Item -ItemType Directory -Path (Split-Path $pssc) -Force | Out-Null
$group = 'SOMEDOMAINSOMEWHERE\Onboarding-Provisioners'
$roles = @{ $group = @{ RoleCapabilities = 'SyncOps' } }
New-PSSessionConfigurationFile -Path $pssc `
-SessionType RestrictedRemoteServer `
-RunAsVirtualAccount `
-TranscriptDirectory 'C:\ProgramData\JEAConfiguration\Transcripts' `
-RoleDefinitions $roles
Register-PSSessionConfiguration -Name 'SomeDomainSomewhere.SyncOps' `
-Path $pssc -Force
The repository's Register-SyncJeaEndpoint.ps1 wraps these steps with -WhatIf, -Uninstall, and pre-flight checks, including one that stops you from registering from PowerShell 7 by mistake.
That's the end of the setup. The next snippet is different: it's from the onboarding script, and it runs from the tech's workstation, as the tech, every time. The only change to the script's sync call is one parameter:
Invoke-Command -ComputerName 'entcon01.somedomainsomewhere.com' `
-ConfigurationName 'SomeDomainSomewhere.SyncOps' `
-ScriptBlock { Start-ADSyncSyncCycle -PolicyType Delta }
Here's what that buys you:
RestrictedRemoteServergives the session no file system access, no arbitrary script blocks, and nothing beyond the two visible commands.RunAsVirtualAccountruns the session as a temporary local administrator that exists only for that session. Members of the allowed group need no standing rights on the server at all.- Transcripts record every session and every command, so you have an audit trail for free.
The JEA gotchas we hit
- A command with no parameter limits must be a plain string. Our first version listed
Get-ADSyncScheduleras@{ Name = 'Get-ADSyncScheduler' }, and the role capability wouldn't load. A hashtable entry needs bothNameandParameters. If you don't constrain parameters, list the command name as a string. - Register it in Windows PowerShell 5.1, not PowerShell 7. Remote clients connect to the default WinRM endpoint, which is Windows PowerShell. If you register from
pwsh, the endpoint lands in the wrong place. - Registering restarts WinRM. Existing remote sessions on the server drop briefly, so schedule it.
- JEA constrains what runs, not how WinRM authenticates. A workstation that isn't domain-joined still needs TrustedHosts or an HTTPS listener to connect. JEA doesn't change that.
The test we didn't plan
The best proof that this worked was an accident. During verification, I ran the sync command without -ConfigurationName, which meant a normal admin remoting session. The answer was "Access is denied." The old path into the Tier 0 server was closed, and the only way in was the endpoint that can do exactly one thing.
Write that negative test on purpose. It's the one that proves you actually removed something.
Step 3: Replace Global Administrator for the Temporary Access Pass
New hires get a Temporary Access Pass so they can sign in and register MFA on day one. Creating a TAP needs Authentication Administrator, not Global Administrator. Authentication Administrator can manage authentication methods for non-admin users, which is exactly the population the help desk onboards.
We made the role eligible through PIM, not permanent. The eligible assignment goes to a role-assignable Entra group for the help desk. A Privileged Role Administrator sets that up once, in the Entra admin center. That also means changing the group's membership requires a privileged role, so nobody can quietly add themselves. Each tech activates the role for their own session, with MFA and a justification, and it expires on its own.
One sequencing rule saves a lot of confusion: activate PIM before signing in to Microsoft Graph. The Graph token reflects your roles when it was issued. If you activate the role after Connect-MgGraph, the token doesn't have it. Run Disconnect-MgGraph, sign in again, and it works.
The replication race Graph reports two different ways
A freshly synced user shows up in Get-MgUser a little before the authentication-methods endpoint catches up. Try to create a TAP in that window and Graph fails. Here's the catch: it fails in two different ways, depending on how far replication has gotten:
resourceNotFound: the user object isn't there yet, from that endpoint's point of viewaccessDenied("Request Authorization failed"): the object is there, but its authorization context hasn't replicated yet, even with the PIM role active
The first version of our retry logic only caught resourceNotFound. When the second error showed up, the script gave up, and the error message sent the tech off to check their PIM activation and the TAP policy. Both were fine. The real fix was to treat both errors as transient. Here's that logic from the onboarding script, simplified. It runs as the tech, with the PIM role active:
$transient = 'resourceNotFound|could not be found|' +
'accessDenied|Request Authorization failed'
$tapBody = @{ lifetimeInMinutes = 1440; isUsableOnce = $false }
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
$tap = New-MgUserAuthenticationTemporaryAccessPassMethod -UserId $userId `
-BodyParameter $tapBody -ErrorAction Stop
break
}
catch {
if ($attempt -lt 5 -and $_.Exception.Message -match $transient) {
Start-Sleep -Seconds 15 # replication lag, not a policy problem
}
else { throw }
}
}
There's also a diagnostic trick that's worth stealing. If any TAP already succeeded in the same run, the policy and the tech's role are fine. A later failure is replication, not permissions. The script now says so, instead of sending the tech to check a PIM role that's already active.
Only mint the credential you're going to deliver
Two smaller hardening changes came out of this step. Both follow from treating a TAP as what it is: a live credential.
- The TAP is created only when the email template actually includes one. Password resets and admin-account notifications don't need a TAP, so the script no longer creates an unused 24-hour credential just because the step was in the pipeline.
- If a TAP fails, the email is held back, not sent broken. An early version sent the welcome email anyway, with a "TAP creation failed" placeholder where the credential should be. Now the email waits, and the tech finishes the user later with a resume option.
Step 4: Replace a Directory Role With Group Ownership
The script adds each new hire to two application groups. Using User Administrator or Groups Administrator for that would grant control over every user or group in the tenant, just to manage two.
Group ownership is the right size. An owner can manage that group's membership and nothing else. Adding the techs as owners is one-time setup, done by a Groups Administrator or an existing owner of each group:
$appGroup = Get-MgGroup -Filter "displayName eq 'App-Finance-Users'"
$tech = Get-MgUser -UserId 'jd**@*****************re.com'
New-MgGroupOwnerByRef -GroupId $appGroup.Id -BodyParameter @{
'@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$($tech.Id)"
}
The script requests the delegated Graph scope Group.ReadWrite.All, which sounds alarming. But delegated permissions are capped by what the signed-in user can do. For a tech who owns two groups and holds no directory role, that scope reaches exactly those two groups.
There's an operational cost: ownership is per person, so it belongs on the help desk joiner/leaver checklist.
Step 5: Take the Secret Out of Human Hands
The welcome email goes out through Azure Communication Services over SMTP, which authenticates with an Entra app's client secret. Before, a tech typed that secret in at a prompt. That meant it lived in someone's password manager, or on a sticky note, or in a chat message from three years ago.
Now it lives in Key Vault:
- A dedicated client secret on the existing ACS app, created just for onboarding. You can rotate it, or revoke it, without touching anything else that uses the app.
- Key Vault Secrets User, scoped to that one secret, not the whole vault, and assigned to the help desk group. This needs the vault to use the Azure RBAC permission model.
Both are one-time setup: an Application Administrator (or the app's owner) creates the client secret, and a vault Owner or User Access Administrator stores it and grants the role. After that, the onboarding script reads it with the tech's own Azure sign-in, so nobody types it and nobody sees it:
$secret = Get-AzKeyVaultSecret -VaultName 'kv-somedomainsomewhere' `
-Name 'smtp-onboarding-secret' -ErrorAction Stop
$smtpCredential = [pscredential]::new($smtpUserName, $secret.SecretValue)
SecretValue comes back as a SecureString, so it goes straight into a credential object without ever being shown as plain text. If retrieval fails, for example because the tech isn't in the group, the script falls back to the old prompt so the run can still finish. The failure is also logged, so you'll notice.
When the Part 1 Fix Broke the Script
In Part 1, one of the fixes was pulling the admin OUs out of sync scope, so on-prem admin accounts stop getting cloud identities. It was the right change. It also broke onboarding for server-admin accounts.
The script creates those too, and it treated every account the same way: trigger sync, wait for the user in Entra ID, then send the notification. Admin accounts never synced anymore, so each one burned the full sync timeout and then suppressed its notification email, because the "wait for Entra" step had failed.
The fix was to make the script aware of the new design:
- Admin accounts skip the Entra phase entirely. No sync wait, no TAP, no cloud groups. They go straight to the notification.
- The sync trigger is skipped when nothing in the run needs it, such as runs made up only of admin accounts, password resets, or resumes of users already in Entra ID.
That last point uncovered a misconception worth clearing up: password changes don't travel by the delta sync cycle. They reach Entra ID through password hash sync, a separate channel that runs about every two minutes. A failed or skipped delta sync doesn't delay a password reset at all. Before we fixed the wording, a sync failure during a password reset sent techs chasing a problem that wasn't affecting them.
The broader lesson: a security fix in one place changes assumptions somewhere else. Test the workflows downstream of every change, not just the change itself.
Proving It
Least privilege isn't finished when everything works. It's finished when you can show that the things that shouldn't work don't. Here's the verification pass:
Positive tests, as a standard help desk account with nothing else:
- Onboard a standard user end to end: AD account, sync, TAP, groups, and email.
- Onboard a server-admin account: AD account and notification, with no Entra steps.
- Reset a password: no sync trigger, no TAP.
Negative tests, each of which must fail:
- Admin remoting to the Entra Connect server without the JEA endpoint: access denied.
Start-ADSyncSyncCycle -PolicyType Initialthrough the endpoint: rejected by theValidateSet.- Any other command through the endpoint, such as
Get-Process: not recognized. - Creating a user in an OU outside the delegation: access denied.
- Creating a TAP without activating PIM first: authorization refused.
- Reading any other secret in the vault: forbidden.
Then remove the old privileged accounts from the process. That's where Part 1 began, and where it found a disabled Global Administrator nobody was looking for.
What the Help Desk Needs Now
| Access | Type | Used for |
|---|---|---|
| Their own standard account | AD user | Everything, including Azure and Graph sign-in |
| On-prem provisioning group | AD security group | Creating users and resetting passwords in delegated OUs; the JEA sync endpoint |
| Help desk onboarding group | Role-assignable Entra group | PIM-eligible Authentication Administrator; reading the one Key Vault secret |
| Owner of two application groups | Group ownership | Adding new hires to those groups |
That's the whole list. There's no Domain Admin, no Global Administrator, no admin rights on a Tier 0 server, and no secret to remember.
Wrapping Up
None of the five replacements is new. OU delegation has been around since Windows 2000, and JEA, PIM, group ownership, and Key Vault are all well documented. What made this work was taking it step by step: asking what each operation needs, granting exactly that, and then proving the old path was closed.
Notice where the privilege went. Domain Admin, a local admin on the Entra Connect server, and a couple of Entra and Azure admin roles were each used once, during setup. Every onboarding after that runs as a standard account.
Do the same for your own scripts. Start with the table: every step, what it runs as today, and the smallest thing that would let it succeed. You may find, like we did, that the verification pass turns up more than the build.
Running onboarding, or any other automation, under accounts with more privilege than it needs? Get in touch. I'd like to hear how you're handling it.