Esmeralda C. Cabrera Ventura ← Articles Dossiers Project details Portfolio LinkedIn
Engineering Dossier Identity & Access · PowerShell AD path validated in lab

Employee Identity Lifecycle

A desired-state engine that turns HR records into correct, audited directory access for every hire, role change, transfer and termination.

Design & Implementation
Esmeralda C. Cabrera Ventura
Identity & Access Management · PowerShell Engineering · Active Directory Automation · Microsoft Cloud Identity
Scope of this document
A technical account of the engine I designed and built: the access model, the set-based reconciliation core, the single audited write path, the four-event test campaign against a live Active Directory domain, and an explicit separation of what was validated from what is implemented but not yet run.
Engineering Facts
Lab domain corp.ventura.local · run with -SkipCloud

Lifecycle scenarios passed4 / 4

Engine sizesingle PowerShell script983 lines

Purpose-built functionsacross seven functional layers30

Target platforms modeledAD, Entra ID, Intune, Azure Virtual Desktop4

Expected outcomes verifiedPowerShell and ADUC, independently14 / 14

Directory changes applied17
Logged with a Success result17
Manual console edits0

Group writes when access was already correct0

Unmanaged groups touched0

Every write in the engine passes through one function that honours -WhatIf, supports a plan-only audit mode, captures failures, and appends a JSONL audit record. There is no code path that changes access off the record.
01
Thesis

Make HR data the only thing a person gets right

Everything downstream should be computed, consistent, previewable and provable.

Every organization repeats the same identity work: a person is hired, changes roles, moves departments and eventually leaves. Identity teams call it Joiner-Mover-Leaver. It is one of the highest-volume and highest-risk workflows in IT, because access is the front door to every system a company owns — and in most departments it is still a ticket and a checklist.

A typical joiner starts as an email from HR. A technician opens Active Directory Users and Computers, types the details, picks an OU, and often copies group memberships from "someone in the same team." Cloud apps, device policies and virtual desktops follow in other consoles. Every hand-off relies on someone reading the ticket correctly and remembering every step.

  • Joiners wait for access, or inherit someone else's permissions through "copy user"
  • Movers receive new access, but old access is rarely removed, so privileges accumulate over years
  • Leavers depend on a ticket reaching IT on time; a missed step leaves a working credential behind a former employee
New access is requested loudly. Removal of old access has no requester, so it is quietly skipped.

That asymmetry is why privilege creep survives diligent teams: nothing in a manual process asks for removal. The engine replaces the checklist with a model in which removal is not a separate step anyone has to remember. It is simply what happens when current access exceeds what HR data justifies.

02
System

Desired state, not scripted clicks

The same idea behind infrastructure-as-code tools, applied to identity.

Instead of scripting individual actions per event, the engine declares what access should exist for each person and reconciles reality to match. A transfer removes old access as naturally as it grants new access, because both fall out of the same comparison. The engine is Invoke-EmployeeIdentityLifecycle.ps1; the same entry point handles all four events, and the event type comes from the HR record, not from the operator.

ComponentFormResponsibility
HR source of truthWorkday-*.csvEmployee ID, name, UPN, department, job title, manager, status and lifecycle event, exported from a Workday-style system
Access policyAccessMappings.jsonDepartment baselines, role overlays, OU paths, the managed group prefix and the quarantine OU
Run-time switches-WhatIf · -AuditOnly · -SkipCloud · -SkipActiveDirectoryHow far a run may go: simulate, plan only, or restrict to one side of the hybrid estate
Active DirectoryActiveDirectory moduleUsers, attributes, manager links, OU placement and scoped group membership — validated in the lab
Microsoft Entra IDMicrosoft Graph SDKGroup membership, sign-in blocking and session revocation — implemented, not yet run
Intune targetingEntra security groupsMembership in the groups device policies are assigned to — implemented, not yet run
Azure Virtual DesktopAz modules · RBACDesktop Virtualization User role on application groups — implemented, not yet run
Audit trailEmployeeIdentityAudit.jsonlOne append-only JSON record per action, ready to forward to a SIEM

What happens on every run

01

Import and validate

Required columns, supported lifecycle events and configuration sections are checked. A missing column, an empty file or an unknown event stops the run before any change is attempted.

02

Compute desired state

Department plus role resolves to a target OU and target groups for Active Directory, Entra ID, Intune and AVD.

03

Snapshot and diff

Current access is read and compared with desired access, split into Add, Remove and Keep, and printed per system before anything changes.

04

Dispatch the lifecycle event

Hire routes to onboarding; RoleChange and DepartmentTransfer to change; Termination to offboarding; Reconcile corrects drift on demand.

05

Apply through one door

Every write passes through Invoke-ManagedChange: ShouldProcess, try/catch, and an audit record for each action.

03
Policy

Access composed from two layers

Department baseline plus role overlay, both declared in one JSON file.

Automation is only as good as the structure it acts on, so the directory was designed so that where an account lives and which groups it holds both follow directly from HR data. Department OUs mirror HR departments under an Employees OU, a Disabled Users OU acts as quarantine, and all OUs are protected from accidental deletion. The department decides the OU and the core groups every member needs. The job title adds role-specific entitlements on top. Changing policy is a configuration change, not a code change.

Department baselineActive DirectoryEntra IDAVD
FinanceIAM-Finance-Users
IAM-Finance-SharedDrive
IAM-Finance-M365Finance-Desktop
EngineeringIAM-Engineering-Users
IAM-Engineering-SharedDrive
IAM-Engineering-M365Engineering-Desktop
Human ResourcesIAM-HR-Users
IAM-HR-SharedDrive
IAM-HR-M365HR-Desktop
Role overlayEntra IDIntune
Financial AnalystIAM-Finance-PowerBInone
Cloud EngineerIAM-Azure-DevelopersIAM-Intune-Engineering
HR SpecialistIAM-HR-Applicationsnone

Every department also receives IAM-Intune-Corporate-Standard. AVD application groups are addressed as resource group and application group pairs in the policy file.

A naming prefix as a security control

Every automation-owned group starts with IAM-, and the engine only reads and changes memberships in groups with that prefix. Break-glass, built-in and hand-managed project groups stay outside its reach. That keeps automation predictable, makes ownership obvious to anyone browsing the directory, and bounds the damage any bug could do.

Managers as real directory objects

The HR record carries the manager's UPN, which the engine resolves to a distinguished name and writes to the manager attribute. That attribute is not decoration: it powers org charts, approval routing and access reviews downstream.

04
Engine

The reconciliation core

A small, pure, testable function that treats access as sets.

Compare-AccessSet takes current and desired group lists, normalizes and de-duplicates both, and returns Add, Remove and Keep. It has no side effects, the same function serves all four target systems, and it is the natural first target for unit tests. Every access decision the engine makes can be explained by pointing at two lists and their difference.

There is no special case for "moved from Finance to Engineering," so there is no combination anyone forgot to write.

Scoping happens before the diff

Group sync reads the account's current memberships, filters them to the managed prefix, and only then computes the difference. Because the filter precedes the comparison, an unmanaged group such as Domain Users can never appear in the Remove list at all — it is not merely skipped at write time, it never becomes a candidate.

Seven layers, one job each

The 30 functions are grouped so that connectors can be added or swapped without touching lifecycle logic.

Layer 01
Orchestration
Main loop, per-employee try/catch, and dispatch to onboarding, change, offboarding or reconciliation.
Layer 02
Policy & desired state
Configuration and HR import, schema validation, UPN normalization, department and role merge.
Layer 03
Reconciliation
Set comparison, access snapshots, and the printed Add, Remove and Keep plan per system.
Layer 04
Active Directory
Create, attributes, OU moves, scoped group sync, disable.
Validated
Layer 05
Entra ID & Intune
Graph connection, group lookup and sync, sign-in block and session revocation.
Not yet run
Layer 06
Azure Virtual Desktop
Azure connection, current assignments and RBAC sync on application groups.
Not yet run
Layer 07
Safety & audit
The single managed-change path and the JSONL audit writer.
In total
30 functions
Seven layers in one 983-line script, so the lifecycle logic never depends on a specific connector.
Lifecycle eventActive Directory actions, in order
HireCreate the user in the department OU with a must-change-at-logon password, set title and department, link the manager, add baseline and role groups
RoleChange · TransferUpdate attributes and manager, move the OU if the department changed, add missing groups, remove groups no longer justified
TerminationDisable the account, remove every managed membership, move to the Disabled Users OU; in the cloud, also block sign-in and revoke sessions
05
Safety

Every write goes through one door

Identity automation can do a lot of damage very quickly. These guardrails are what make it safe to point at a directory.

No connector writes to a directory directly. Each passes its change to Invoke-ManagedChange as a script block. In audit-only mode the function records the change as Planned and returns. Otherwise it asks ShouldProcess, runs the change inside a try/catch, and records Success, Skipped, or Failed with the exception message — re-throwing so the failure is not swallowed. Because there is exactly one path to a write, there is no way to change access off the record.

GuardrailImplementation
Preview before applySupportsShouldProcess with ConfirmImpact = 'High' gives native -WhatIf and confirmation prompts; -AuditOnly logs the full plan without touching anything
Idempotent re-runsOnly the difference is applied; running the same input again makes no duplicate group changes
Immutable matchingAccounts are matched on the employeeID attribute, never on display name
Input contractRequired columns and supported events enforced before any change; UPNs lower-cased; SamAccountName derived from UPN when blank
Fail safe, fail loudlySet-StrictMode and ErrorActionPreference = 'Stop' turn silent errors into exceptions
Per-employee isolationEach record runs in its own try/catch, so one bad row is logged without halting the batch
Limited blast radiusOnly IAM- groups are ever read for removal or changed
Structured evidenceAppend-only JSONL: UTC timestamp, employee ID, UPN, event, system, action, result, details, run-by account and computer

Honest drift reporting

Under -SkipCloud the engine still computes and prints the Entra and AVD plan. Pending cloud additions keep appearing on subsequent runs until a cloud run reconciles them. The engine never reports a state it did not produce.

06
Proof

One employee, four lifecycle events

A single identity taken from hire to termination against a live domain, verified twice after every run.

4/4
Scenarios passed
14/14
Outcomes verified
17
Changes applied
0
Unmanaged groups touched

Every run targeted corp.ventura.local from its domain controller using the ActiveDirectory module, with -SkipCloud set. OUs, managed groups and managers from the access model existed beforehand. All four HR records shared employee ID 10001, Sarah Johnson, exactly as one real employee's records would. After each run the account was checked from PowerShell with Get-ADUser and a group membership query, and visually in Active Directory Users and Computers — two independent views of every result.

EventHR recordChangesVerified outcome
HireFinance · Financial Analyst · Jennifer Dovorany5Created in the Finance OU; title, department and manager set; both Finance groups added; enabled; audit folder created on first run Pass
Role changeFinance · Cloud Engineer · Jennifer Dovorany1Title updated; still in Finance; both groups under Keep with no group writes; no manager write issued Pass
Department transferEngineering · Cloud Engineer · Robert Smith7Attributes and manager updated; moved to the Engineering OU; two Engineering groups added and both Finance groups removed in the same run Pass
TerminationStatus Terminated4Disabled; both Engineering groups removed; moved to Disabled Users; only Domain Users remains, untouched Pass

What the role change proves

Idempotency. A naive script would remove and re-add every group on each run, adding log noise and brief access gaps. The engine saw the Finance groups were already correct and made no group writes; only the changed attribute was updated.

What the transfer proves

No creep. Old department access was removed in the same run the new access was granted, without anyone deciding to remove it. Nothing in the desired state justified keeping it.

Removal that depends on memory eventually fails. Removal that falls out of a comparison cannot be forgotten.

Between full campaign runs, Reset-TestEmployee.ps1 deletes only the test account and verifies the deletion, leaving managers, groups and OUs in place, so the four tests can be repeated from a known clean state.

07
Leavers

Quarantine, not deletion

The highest-risk event gets the most defensive design.

For a leaver, Get-DesiredAccess returns an empty target on every system and the Disabled Users OU as the destination. The desired state is not "remove these groups"; it is "hold nothing," and reconciliation does the rest.

Trigger
Event or status
A Termination event, or a Status of Terminated or Inactive, forces the empty state — even if the event column is wrong.
Defense in depth
Account
Disabled
The credential stops working immediately, while the object and its SID survive.
Access
Stripped
Every managed membership removed. Unmanaged groups are left exactly as they were.
Location
Quarantined
Moved to Disabled Users, so leavers are visibly separated from active staff.

Deleting an account destroys its SID, group history and ownership links. Disabling and quarantining keeps evidence for audits and legal holds, supports rehires, and gives the business time to hand over files. In the cloud path the same event also blocks Entra sign-in, revokes active sessions and removes AVD role assignments, because a disabled on-premises account does not by itself end a live cloud session.

08
Governance

Lab shortcuts, named

A lab is allowed shortcuts. A production identity system is not. This is what I would harden first.

Lab shortcutRiskProduction hardening
Initial password in plaintext in the policy fileAnyone with file access learns every new hire's first passwordA unique random password per user at run time, or an Entra Temporary Access Pass; secrets in Azure Key Vault through SecretManagement
Runs as the domain AdministratorA bug or compromise has full domain controlA group managed service account with rights delegated only on the Employees and Disabled Users OUs and the managed groups
Interactive Graph sign-in with broad directory writeOver-privileged, and cannot run unattendedApp registration with certificate authentication, scoped to user and group-membership write permissions
CSV values placed into AD filter stringsMalformed input could change query meaningValidate employee IDs as digits and UPNs against a pattern; escape quotes before building filters
Audit log on the domain controller's diskCan be altered or lost with the serverForward to Microsoft Sentinel or another SIEM with immutable retention; alert on Failed results
Manually triggered with a CSV fileDepends on an operator running it on timeScheduled Azure Automation runbook on a Hybrid Worker fed by the Workday API, plus a nightly Reconcile run for drift

The published report and this dossier were sanitized before release: the lab password, the Windows product ID, internal IP addresses and a profile photo were removed. The domain controller name, host name and lab domain remain visible, deliberately, as evidence the captures are from a real environment.

09
Build

The engine at work

Captured in the lab. Diagrams from the technical report; console and directory screenshots cropped to the relevant region.

Architecture diagram of the identity lifecycle engine
ArchitectureInputs, the five-stage engine and four target systems. Solid paths ran in the lab; dashed cloud connectors are gated behind -SkipCloud.
Test employee state after each of the four lifecycle events
CampaignThe account after each run, with the number of changes applied at each step.
Venn diagrams of role change and transfer reconciliation
ReconciliationThe role change kept access and wrote nothing; the transfer swapped it in one run.
AccessMappings.json in a code editor with the password redacted
Access policyOrganization settings and department baselines; the initial password is redacted.
PowerShell output of the hire run
HireThe reconciliation plan for AD, Entra and AVD, five successful AD changes, and the verification query.
PowerShell output of the department transfer run
TransferSeven changes, including the OU move, two additions and both Finance removals.
ADUC Member Of tab after transfer
Independent checkMember Of after the transfer: Domain Users and the two Engineering groups. No Finance access remains.
PowerShell output of the termination run
TerminationDisable, both group removals and the quarantine move; the account reports Enabled False.
ADUC showing the Engineering OU empty after termination
QuarantineThe Engineering OU is empty; the account now lives only in Disabled Users.
10
Status

Validated, implemented, next

What ran, what is written but has not run, and what reviewing my own output surfaced.

CapabilityStateNote
AD access modelValidatedDepartment OUs, quarantine OU, prefixed groups, managers as directory objects
Desired-state reconciliationValidatedAdd, Remove and Keep across all four lifecycle events
Audited write pathValidated-WhatIf, -AuditOnly and JSONL records; audit folder created on first run
Test harnessValidatedFour CSV scenarios and a reset script for repeatable campaigns
Entra ID connectorImplementedGroups, sign-in block and session revocation; awaiting a tenant
Intune targetingImplementedSecurity-group membership for device policy assignment; awaiting a tenant
AVD accessImplementedRBAC on application groups; awaiting a tenant
Hybrid identityNextEntra tenant with Cloud Sync; repeat the campaign with cloud verification
Automated tests & lintingNextPester tests for the reconciliation core; PSScriptAnalyzer in CI
Secure operationsThenKey Vault, random first passwords, gMSA, certificate-based Graph app, Sentinel forwarding, input hardening
Event-driven governanceLaterWorkday API or webhook triggers, approvals for privileged roles, access reviews, exit handover of licenses, mailbox and OneDrive

Engineering review: refinements from the test output

  • Attribute writes run on every event, even when title and department are unchanged. Compare values before writing so the audit log records only real changes
  • A terminated account keeps its manager and title. Clear the manager link, stamp a termination date, and hide the account from address lists
  • The Cloud Engineer role adds cloud entitlements only. Role-scoped AD groups are already designed in the project README and need only a policy entry and two groups
  • Tests are verified by reading output. Codify expected results as Pester assertions so the campaign becomes one automated regression suite
New capabilities should arrive as connectors or policy entries, not rewrites.
What this build demonstrates

Access that follows people, with proof


The engineering value in this system sits in two small, deliberately unremarkable pieces: a pure function that turns access into set arithmetic, and a single function that every write must pass through. The first is why a transfer cannot leave old access behind. The second is why nothing can change without a record.

Around them sits an approach to scope I would defend as hard as the code: the Active Directory path is described as validated because it was executed and independently verified, and the cloud connectors are described as implemented because they have not yet run.


PowerShell · Active Directory · Microsoft Graph SDK · Entra ID · Intune · Azure Virtual Desktop · JSON · JSONL

Read the article →  ·  All project details  ·  Portfolio & resume  ·  LinkedIn