Skip to main content

Azure Innovators

Retro comic illustration of a golden crown-topped master key retired under a glass dome, with a ring of five small brass keys in front of it
Five small keys, each cut for one job, instead of one key to everything.

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.

StepRan asReplaced with
Create the AD user, set the passwordDomain AdminOU delegation to a security group
Trigger the Entra Connect delta syncAdmin remoting into a Tier 0 serverA JEA endpoint exposing one command
Issue a Temporary Access PassGlobal AdministratorAuthentication Administrator, eligible through PIM
Add the user to two application groupsGlobal AdministratorGroup ownership, no directory role
Send the welcome emailAn SMTP secret a human typed inKey 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 setupWhere it runsWho runs it
OU delegationA privileged access workstation (PAW) with the RSAT AD moduleDomain Admin
JEA endpointOn the Entra Connect server itself, elevated Windows PowerShell 5.1Local administrator on that server
PIM-eligible role and role-assignable groupEntra admin centerPrivileged Role Administrator
Group ownershipEntra admin center or Graph PowerShellGroups Administrator, or an existing group owner
Key Vault secret and accessAzure portal or Az PowerShellVault 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:

  1. Create user objects in the target OU
  2. Reset Password on user objects below it
  3. Read and write the attributes the script sets: display name, office, mail, description, and userAccountControl to 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.
  • GenericWrite is broader than it looks. On a user object, it includes attributes like servicePrincipalName and msDS-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, replace GenericWrite with 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.

A locked steel door with one small hatch open, through which a gloved hand reaches a single red lever
A JEA endpoint: one opening, one command, and no way into the rest of the server.

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:

  • RestrictedRemoteServer gives the session no file system access, no arbitrary script blocks, and nothing beyond the two visible commands.
  • RunAsVirtualAccount runs 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-ADSyncScheduler as @{ Name = 'Get-ADSyncScheduler' }, and the role capability wouldn't load. A hashtable entry needs both Name and Parameters. 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 view
  • accessDenied ("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.

A brass key hovering at a glowing keyhole while sand still runs through an hourglass beside the door
Right key, right door, wrong moment. Graph replication needs a few more seconds.

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:

A superhero's gloved hand turning a small brass key in a glowing blue time-lock dial on a steel vault
Eligible, not permanent: the role exists only while it's activated.

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 Initial through the endpoint: rejected by the ValidateSet.
  • 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

A superhero with arms crossed beside an open key cabinet holding small brass keys and an empty space where a large key once hung
Everything the help desk needs, and an empty hook where the master key used to be.
AccessTypeUsed for
Their own standard accountAD userEverything, including Azure and Graph sign-in
On-prem provisioning groupAD security groupCreating users and resetting passwords in delegated OUs; the JEA sync endpoint
Help desk onboarding groupRole-assignable Entra groupPIM-eligible Authentication Administrator; reading the one Key Vault secret
Owner of two application groupsGroup ownershipAdding 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.