End-to-End IAM, Part 3: How Reconciliation and JML Work
This is part 3, and the last part, of the series on end-to-end-iam. Part 1 covered why Elytra and Ascendra are kept fully separate. Part 2 covered how Ascendra actually provisions accounts in Entra ID via Microsoft Graph. This part is about what happens when something goes wrong along the way, and why a single mechanism isn’t enough to keep HR and Entra ID in sync over time.
JML in practice: more than a nice-to-have
Joiner-Mover-Leaver (JML) describes the three lifecycle events every employment goes through: someone joins (Joiner), changes role (Mover), or leaves (Leaver). It sounds simple in theory, but in practice it’s exactly the problem every organization with more than a handful of employees has to solve safely:
- Joiner: the right accounts have to be created automatically, with the right access from day one — neither too much nor too little.
- Mover: when someone changes role or department, new access has to be added and old access that’s no longer justified has to be removed. That second half is often forgotten, which over time creates what’s called access drift — users accumulating permissions they once needed but no longer use.
- Leaver: when someone leaves, access has to disappear quickly and safely. This is the most security-critical of the three — a leftover active identity after an employment ends is a direct path for a breach.
The end-to-end-iam project models this entire flow, from the event in Elytra to an actually created, updated, or disabled account in Entra ID, via the event-driven provisioning described in part 2.
Why events alone aren’t enough
The event-driven model (Elytra publishes, Ascendra reads and acts) works well in the normal case. The problem is that it assumes every event actually arrives and gets processed correctly, exactly once. In reality, several things can go wrong:
- A Graph call can fail due to a transient network error, or because Entra ID’s API is under heavy load at that moment.
- Ascendra can be down right when an event should have been processed.
- A bug in the code can cause an event to be processed incorrectly without the call itself technically failing — which is exactly what happened in the case below.
Two mechanisms in Ascendra handle these risks in different ways: Retry and reconciliation.
Retry is the simple case: if a Graph call fails, an automatic new attempt is made instead of the failure just going silent. It catches transient, temporary problems.
Reconciliation is the coarser safety net. Every 15 minutes, Ascendra compares its own picture of reality (built from the events in the outbox) against what Entra ID actually shows via Graph, and corrects any differences. It doesn’t matter whether the difference is caused by a dropped event, an Ascendra outage, or a bug. Reconciliation only cares about the end result: does Entra ID match what the HR system says right now, or not.
It’s that combination — real-time event-driven provisioning plus periodic reconciliation as a safety net — that lets the system hold up even when individual steps fail.
The Resync bug: when the safety net had a hole
On top of the automatic reconciliation, there’s a manual button in Ascendra, Resync, that forces a fresh sync of a specific identity against Entra ID — for example if a previous sync failed. Resync doesn’t read anything new from the HR system. It takes what Ascendra already believes about the identity and sends it to Entra again via Graph.
That exact button caused the most serious bug in the whole project. The code behind Resync always sent accountEnabled = true to Graph, regardless of whether the person was actually still employed. Concretely, here’s what happened:
- An employee was terminated. Their account was correctly disabled in Entra ID, exactly as it should be.
- I clicked Resync on the same person again, just to test the feature.
- The account was re-enabled in Entra ID, even though the HR system Elytra still showed the person as terminated.
The root cause was that the Resync code hard-coded the assumption that a manual sync always means “set the account to active,” instead of asking the HR system for the person’s actual current status. It’s a classic case of a shortcut that works fine in the common case (someone clicks Resync to debug a currently active employee) quietly turning dangerous in the edge case (someone clicks Resync on an employee who has already left).
The fix was to remove the assumption entirely: accountEnabled is always derived from the HR system’s current employment status, no matter which piece of code actually makes the Graph call — whether that’s the regular event-driven provisioning, a Retry, a reconciliation run, or a manual Resync. One single place decides whether the account should be active, and every path runs through it.
Why this kind of bug is dangerous
What makes the Resync bug worth dwelling on isn’t the code itself, but what it reveals about how security gaps happen in IAM systems. The bug required no external attack, no breach, nothing sophisticated. It only required someone (in this case, me, testing) to click a perfectly legitimate button in the wrong order. A former employee’s access could have been restored with a single extra button click, without any other part of the system flagging it as wrong — because the Graph call itself succeeded perfectly. It was only the content of the call that was wrong.
It’s also exactly the kind of error reconciliation is designed to catch, even if the underlying bug in the Resync code had never been fixed: the next reconciliation run would have compared Entra ID against the HR system, seen that the account was active despite the person being terminated in Elytra, and corrected it within 15 minutes. Reconciliation works both as protection against dropped events and as a safety net against bugs elsewhere in the code — as long as reconciliation’s own logic has the right source of truth.
Wrapping up the series
Across these three parts, we went from the architectural decision to keep HR and IGA fully separate (part 1), through how that separation actually provisions real accounts in Entra ID via Microsoft Graph (part 2), to how the system catches its own mistakes through reconciliation and a single, consistent rule for what’s true about an identity (part 3).
The thread running through all of it is the same: as long as an integration is simulated, it’s easy to believe everything works. It’s only once it’s connected to a real system, and you actively go looking for where it can break, that the real problems surface — and that’s when an IAM system starts to feel like an actual one, not just a demo.