← Back to list

What It Takes to Build a Custom JML Engine That Most IAM Job Descriptions Assume You Already Know

Most IAM job descriptions mention Joiner-Mover-Leaver processes.

Henry · 2026-06-10 12:33 · 0 claps · 8.0 min read
#iam-roles #ami #jml #identity-and-access #identity-lifecycle
Open on Medium ↗

What It Takes to Build a Custom JML Engine That Most IAM Job Descriptions Assume You Already Know

Most IAM job descriptions mention Joiner-Mover-Leaver processes.

Build JML workflows. Automate JML lifecycle management. Implement JML controls.

The assumption is that everyone already understands how a JML process works. In reality, most people understand the outcome. A user gets created. Access gets assigned. Access gets removed. What is rarely discussed is the sequence of decisions that must be made correctly before any of those actions can happen. Building a custom JML engine from scratch forced me to think about those decisions. I discovered that JML is not primarily a provisioning problem.

It is a decision-making problem.

JML Is a Decision-Making Problem

Most people think of JML as a provisioning problem. Create the account. Assign the groups. Remove access when the person leaves. That framing puts all the attention on the execution step at the end. It misses the part that actually determines whether the system is trustworthy: every decision that runs before provisioning begins.

JML Engine Pipeline

JML Engine Pipeline

A production JML engine should process every identity event through a sequence of seven decisions. Provisioning only runs if all seven are answered correctly. Get any one wrong and the engine either blocks the event or produces incorrect access, and in a regulated environment, both outcomes have consequences. Each decision corresponds to a gate. Fail the gate and the event stops. Pass it and the next gate opens. The decisions are not optional and they are not run in parallel. Sequence matters because each one depends on the output of the one before it.

Decision 1: What Lifecycle Event Is This?

The first question sounds obvious. It is not.

HR systems fire events. Those events carry labels: new hire, role change, termination. The naive approach is to trust the label. The correct approach is to verify it against the directory, because the label and the reality frequently disagree.

Lifecycle Determination Logic

Lifecycle Determination Logic

A “role change” event arrives because someone’s title moved from Analyst I to Analyst II. The label says Mover. The entitlement model maps both titles to the same groups. Nothing in the directory needs to change. It executes the delta anyway and the engine writes audit entries for an access change that did not happen. No error fires. No alert triggers. The audit trail just gets quieter noise that is indistinguishable from real changes. When an investigator later tries to reconstruct why someone’s access changed on a specific date, they are reading through ghost events alongside real ones.

The engine does not trust the label. It derives the action by comparing the HR record against live Entra ID state. Same input, same state, same output every time.

Decision 2: Is the HR Data Trustworthy?

Even when the lifecycle action is correctly identified, the data driving it may not be reliable.

HR systems are not clean by default. The same department arrives as “sales dept,” “SALES,” and “Sales Dept” depending on who entered it. The same job title comes in multiple spellings across teams. Employment type shows up as “FTE,” “full-time,” and “permanent” for the same category of worker. If those raw values feed directly into access policy decisions, the policy decisions inherit the inconsistency. An unrecognised department means entitlement resolution has nothing to work with. An unrecognised employment type means the engine cannot determine whether this person belongs in certain groups.

Before any downstream logic runs, every field is resolved against a canonical lookup table. Known variants map to a controlled value. Unknown values do not error and they do not silently pass through. They route to a hold queue with a structured reason, where a human can review and correct the source record before provisioning is attempted. The lookup table is a configuration json file, not code. A reorganisation, a title standardisation, a new HR system: none of these require a code change.

HR data is the source. Canonical data is the truth.

Decision 3: What Access Should This Person Receive?

Once the identity and the data are confirmed, the engine has to determine what access this person should actually receive.

In most manual systems, access is selected. Someone looks at what the previous person in that role had, or picks groups from a list, a case where access policy lives in a spreadsheet, a shared drive, or an IT admin’s memory. When that person leaves, the institutional knowledge about why certain groups were assigned goes with them. Two people with identical job titles in different departments end up with different access because different admins processed their requests.

In this engine, access is derived. Job title, department, and employment type are evaluated at runtime against a versioned rules file. Multiple rules can contribute entitlements to the same identity. A senior finance manager might match a general finance rule, a management-tier rule, and a location-specific rule simultaneously. The combined result is the definitive entitlement set for that identity, and every assignment is traced to the specific rule that produced it. The same inputs always produce the same access. Changing a mapping is a Json file edit, not a deployment.

Access is a policy output, not a manual selection.

Decision 4: Does This Access Violate Policy?

Knowing what access a person should receive is not the same as knowing whether that access creates a conflict. Separation of Duties evaluation runs before any provisioning step to answer one question: does the proposed access, combined with what the person already holds, put two Job roles together that should never coexist on one Identity?

The problem surfaces at role transitions. A Mover event adds new entitlements, but the person still holds everything from their previous role at the moment of evaluation. A system that only checks the incoming access misses the conflict entirely. This engine evaluates

effective_access = current_groups + requested_groups.

That is the full picture, regardless of what the delta plans to remove later.

A Payment Processor moving into a Payment Approver role carries both entitlements at the moment of the transition. One person able to both initiate and approve the same payment. The SoD check catches it before a single group assignment executes, stops the event, and routes it to a dedicated hold record. Nothing touches the directory until a human resolves it.

Access that satisfies a role is not automatically access that satisfies policy.

Decision 5: Is This Identity Eligible for That Access?

Once the access set has cleared SoD evaluation, the identity itself is evaluated. The question here is different: not whether the combination of access is problematic, but whether this particular identity is eligible to receive it at all.

The normalized identity record is submitted to the governance validation engine, which evaluates it against 27 rules before the user object exists in Entra ID. Zero Graph API calls. No side effects. Missing manager association. Duplicate UPN. A Contractor being provisioned into a management-tier group. Each of these is a block, not a warning. The event goes to a hold queue with the specific rule IDs that failed. Provisioning does not run.

The governance engine runs as a separately deployed PowerShell Azure Function. The Python pipeline calls it over HTTP and receives a structured pass or fail response. The two systems are decoupled at the HTTP boundary and can be updated, tested, and deployed independently.

Eligibility is evaluated before identity creation, not after.

Decision 6: Can the Event Be Processed Safely?

In Distributed systems you do not get to assume clean conditions. An HR system polling on a schedule fires the same record twice. A function instance crashes mid-run and gets retried. Two triggers fire simultaneously for the same employee. Without explicit controls, the same event processes twice, produces two accounts, and writes two audit records that are impossible to reconcile.

The engine handles this at two points in the pipeline, not one.

The first control runs immediately after normalization. Before any policy evaluation, entitlement resolution, or Graph API call, the engine attempts an atomic insert into Azure Table Storage. The EventId is a SHA-256 hash of employee ID + action + start date. The same input always produces the same ID. If the row already exists, the insert fails and the pipeline exits immediately with no side effects. A resubmitted record, a polling duplicate, a function retry: all resolved before a single downstream component runs.

The second control runs later, immediately before Graph API writes begin. The engine writes a processing lock to the event row. Two function instances that both pass the initial claim compete for this lock. Only one acquires it. The other exits cleanly. The lock expires automatically after ten minutes, so a crashed instance does not block the next run.

A retry, a resubmission, and two concurrent instances all resolve to the same outcome: one provisioning action, one audit record, one state change in the directory.

Duplicate provisioning is prevented by architecture, not procedure.

Decision 7: Did the Directory End Up in the Expected State?

Provisioning success is not evidence that the directory reflects the intended state.

API calls are not atomic operations. A sequence of Graph API calls, create user, add to group A, add to group B, assign RBAC role, can partially succeed. The user object gets created. Group A assignment goes through. Group B assignment fails silently because of a transient error. The provisioning step reports overall success because it did not throw a fatal error. The directory is now in a state that nobody designed and that does not match any policy. The audit record written at that moment says something different from what is actually true in Entra ID.

This is not a theoretical edge case. It is a real operational condition in any system that makes multiple sequential API calls. Without an independent verification step, partial provisioning states are invisible until an access review surfaces them, often weeks or months later.

After provisioning completes, the validation engine runs again against the real Entra ID object, using live Graph API calls against the actual tenant. It fetches the user’s current group memberships and compares them against the entitlement set resolved in Decision 3. Any discrepancy, a missing group, an unexpected group, a misconfigured RBAC assignment, is recorded in the audit report. The post-provision check closes the gap between what the engine intended and what the directory actually contains.

Every identity event produces a structured audit record regardless of outcome. Every decision, every rule that fired, every block with the specific failure reason. SoD violations carry the conflicting group names and the policy that detected them. Governance failures carry the rule IDs. Written once, at the time of processing. Compliance evidence produced at the point of the decision, not reconstructed from logs weeks later when an auditor asks why someone had access to something they should not have had.

JML decision Pipeline — what each gate defends against.

JML decision Pipeline — what each gate defends against.

Most people think JML is a provisioning problem. Building this engine taught me it is a decision-making problem.

User creation is the final step. The difficult part is everything before it.

The failures that show up in access reviews and compliance gaps are almost never caused by the provisioning step. They come from bad decisions upstream. A lifecycle action trusted from a label instead of verified against state. An entitlement assigned from memory instead of derived from policy. An SoD conflict that slipped through because the evaluation only looked at new access.

Provisioning is easy to implement. Getting the seven decisions right, in sequence, is what separates a working JML pipeline from a trustworthy one.

Henry Ezihe is an IAM engineer specialising in Microsoft Entra ID and Azure identity architecture. This article covers the architecture of a custom JML Identity Lifecycle Engine built on Azure Functions, Microsoft Graph API, and BambooHR. The Joiner and Mover phases are complete. github(https://github.com/donhenzo/JML-engine-Joiner-Mover-Leaver-)


메타데이터
post_id
690ffb352bd3
slug
what-it-takes-to-build-a-custom-jml-engine-that-most-iam-job-descriptions-assume-you-already-know-690ffb352bd3
url
https://medium.com/@Don_henzo/what-it-takes-to-build-a-custom-jml-engine-that-most-iam-job-descriptions-assume-you-already-know-690ffb352bd3
canonical_url
https://medium.com/@Don_henzo/what-it-takes-to-build-a-custom-jml-engine-that-most-iam-job-descriptions-assume-you-already-know-690ffb352bd3
author_url
https://medium.com/@Don_henzo
status
ok
fetched_at
2026-06-27 07:40:21